sehaj/learn
Docker/07 Production

07.05 · with code · 11 min

Build cache in CI

CI runners start empty, so the layer cache from 02.02 is gone every build. Export it, import it, and use cache mounts for the rest.

The core idea

On your laptop, the build cache lives in Docker and persists between builds. A CI runner is usually a fresh virtual machine every time: no cache at all, so every build installs every dependency from scratch. The fix is to export the cache somewhere that outlives the runner at the end of a build, and import it at the start of the next.

Simulate a fresh CI runner

A buildx builder that runs in its own container (--driver docker-container) has its own cache. Deleting and recreating it is a good stand-in for a fresh runner:

❯terminal
$ docker buildx create --name cibuilder --driver docker-container
$ docker buildx build --builder cibuilder --cache-to type=local,dest=./bcache,mode=max -t notes-api:ci .
$ du -sh bcache
91M
$ docker buildx rm cibuilder && docker buildx create --name cibuilder --driver docker-container
$ docker buildx build --builder cibuilder --progress=plain --cache-from type=local,src=./bcache -t notes-api:ci .
#10 [build 2/7] WORKDIR /app
#10 CACHED
#11 [build 4/7] RUN npm ci
#11 CACHED
#13 [build 6/7] COPY src ./src
#13 CACHED
#16 [build 7/7] RUN npm run build
#16 CACHED

Output trimmed. A brand-new builder, and every step is CACHED, because the cache came from the exported folder. Timed on this machine:

Fresh builderTime
no cache40 s
with the imported cache27 s

Both include about 15 seconds of starting the builder and fetching the base image. For a real app, with hundreds of dependencies or a compile step that takes minutes, the cached build is minutes faster.

Where to keep the cache

--cache-to / --cache-fromStoredGood for
type=ghaGitHub Actions' cache serviceGitHub Actions; the simplest option
type=registry,ref=…:buildcachea separate tag in your registryany CI; shared between pipelines
type=local,dest=…a folderCI systems that persist folders between jobs
type=inlineinside the image itselfvery simple setups; final stage only

mode=max exports the layers of every stage, including the build stage where npm ci happens. The default, mode=min, only exports the final image's layers, which for a multi-stage build misses the expensive part.

In GitHub Actions

The project's workflow (07.06 Project: ship with GitHub Actions) uses the GitHub Actions cache:

.github/workflows/notes-api.yml
    - name: Build for scanning
      uses: docker/build-push-action@v7
      with:
        context: ${{ env.CONTEXT }}
        load: true
        tags: notes-api:scan
        cache-from: type=gha
        cache-to: type=gha,mode=max

Its first two real runs on this repo:

❯terminal
run 1 (empty cache) Build for scanning: 30s
run 2 (source changed) Build for scanning: 19s
#10 importing cache manifest from gha:1130349528686026853
#13 [build 4/7] RUN npm ci
#13 CACHED

Run 2 changed server.ts, so the source copy and compile ran again, but npm ci came straight from the cache.

Cache mounts: the other cache

The layer cache is all-or-nothing: change one dependency and the whole npm ci layer reruns, downloading every package again. A cache mount keeps a directory between builds without putting it in the image:

Dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm npm ci

npm's download cache in /root/.npm survives between builds, so after a lockfile change only the new packages are downloaded. It works for any package manager: /root/.cache/pip, /go/pkg/mod, /var/cache/apt. Cache mounts live in the builder, so on CI they share the same "fresh runner" problem; layer-cache export is the bigger win there.

Predict first

Your CI uses --cache-to type=gha (default mode) with a multi-stage Dockerfile. npm ci in the build stage still runs on every build. Why?

Commit to an answer before you read on.
CheckpointMake a cold build warm

Repeat the experiment above: build with --cache-to type=local, recreate the builder, build with --cache-from, and compare times. Then add a cache mount to the build stage's npm ci and see what happens when you add a dependency.

STUCK? ▸

"cache export feature is currently not supported for docker driver": the default builder can't export most cache types. Create one with docker buildx create --driver docker-container and pass --builder.

Recap

Interview questions

MidfreqMust-knowScenario

Docker builds in your CI take 12 minutes, but locally they take 30 seconds. Why, and what would you do?

Report a mistakeEdit this page