Products

Pricing

FAQs

Blog

Blog

·

gitlab

·

Sep 3, 2026

Docker Builds 3× Faster With a Persistent BuildKit Builder

A dedicated runner already keeps your Docker layer cache warm. Switching from plain docker build to a persistent BuildKit builder with registry cache took our warm builds from 46 to 14 seconds — and made every other runner warm too.

Cyrille Sepele

· 3 min read

ROCKETRUNNER

gitlab

A dedicated runner with a persistent Docker daemon already beats shared runners: your layer cache survives between jobs, so most builds skip most work. That was our starting point — a warm docker build took 46 seconds.

The same build now takes 14 seconds. One CI change, no Dockerfile changes.

What plain docker build leaves on the table

Even with a warm cache, the classic workflow has three drags:

  • Linear execution. docker build walks your Dockerfile top to bottom. Multi-stage builds where stages could run in parallel — or be skipped entirely — still get evaluated one after another.
  • Build, then push. The image is assembled locally first, then docker push re-checks and uploads layers as a separate step.
  • Single-host cache. The cache lives on one runner. A job that lands on a different runner — or runs after a disk cleanup — starts from zero.

The change: a persistent builder plus registry cache

BuildKit fixes all three, and on a dedicated runner you can make its cache as persistent as the daemon's. The whole change fits in the build job:

build:
  image: docker:latest
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
    # Registers the builder client-side; on first build, buildx finds an
    # existing builder container by name on the host and re-attaches.
    - docker buildx create --name ci --use
    - docker buildx build
      --push
      --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:cache
      --cache-to type=registry,ref=$CI_REGISTRY_IMAGE:cache,mode=max
      --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
      .

Note what's not there: no services: docker:dind. On a runner that mounts the host Docker socket — which is how RocketRunner provisions runners — jobs talk to the host daemon directly. The builder is a small BuildKit container that lives on the runner host, keeps running between jobs, and holds the build cache. Every job re-attaches to it by name.

Where the 3× comes from

  • Parallel, lazy stage evaluation. BuildKit builds the dependency graph of your stages and only runs what the target actually needs — in parallel where possible.
  • Push from the builder. --push streams only missing layers straight out of the builder. No local image assembly, no separate push step.
  • mode=max cache. Intermediate stages (your frontend compile, your dependency install) are cached individually, not just the final image lineage. An unchanged service rebuilds as pure cache hits.
  • Registry cache as backstop. --cache-to type=registry publishes the cache to your GitLab registry. A build on a different runner — or on this one after a cache wipe — pulls warm cache instead of starting cold. With several runners, they effectively share one cache.

The builder handles concurrent jobs, too: one BuildKit daemon serves parallel build sessions and deduplicates shared work between them.

Honest caveats

  • The first build is slower. The builder starts with an empty cache and additionally uploads cache layers. Savings start with the second build.
  • RUN --mount=type=cache directories don't travel. Package-manager caches inside cache mounts persist with the builder container but are never exported to the registry. A step that re-runs on a cold builder pays its full download cost once.
  • You need a persistent host daemon. This is the part shared runners can't give you: their daemon (via docker-in-docker) is created and destroyed with every job, so the builder dies with it. A dedicated runner with a socket-mounted daemon is the whole trick.

Try it

RocketRunner runners come provisioned exactly this way — persistent Docker daemon, socket mounted into jobs, disk cleanup that leaves running builders untouched. Drop the snippet above into your .gitlab-ci.yml and compare your next two pipelines.

// Keep reading

Related posts