Skip to content
Latchkey LogoLatchkey home

GitHub Actions Docker build slow: six tactics, ranked by result

A GitHub Actions Docker build slow enough to be worth an afternoon has six plausible fixes, and they are not worth the same. We ran all six against one image on Latchkey runners, one variable at a time: reordering four lines of the Dockerfile beat every other change, and the two tactics that lead most lists returned nothing or cost time.

Bars: layer order 4.9 s, registry cache 3.4 s, runner 2.6 s, mount 2.4 s, dockerignore 0 s, multi-stage -1.7 s
Each tactic against its own control on the same Node image, one variable at a time. From job-b.sh, job-c.sh, job-d.sh and job-e.sh on Latchkey runners, 2026-09-20.

The advice on this topic is remarkably consistent and almost never carries a number, which is how .dockerignore and multi-stage builds ended up at the top of every list. Both are good practice. On the build we measured, one of them returned nothing and the other cost 1.7 seconds, while the change nobody leads with, the order of four lines in a Dockerfile, returned 4.9.

So this page is ordered by what each change did to the clock on one image: a Node build with 414 packages in the lockfile, on Latchkey runners, on 20 September 2026. Each tactic is measured against its own control and changes one variable, and the scripts are committed so you can disagree with them.

The ranking

Read the saving column as what that one change was worth against the control beside it. The controls differ on purpose, because these tactics answer different questions, and the rows do not add up: two of them are competing to remove the same install.

TacticControlWith itSaved
Cache-friendly layer order, rebuild after a source change7.1 s hostile order2.2 s4.9 s
Registry layer cache, rebuild after a source change12.2 s, no cache8.8 s3.4 s
Larger runner, 2 vCPU to 4, cold build12.2 s9.6 s2.6 s
BuildKit cache mount, lockfile change9.0 s6.6 s2.4 s
.dockerignore, 85 MB context to 1 MB, cold12.323 s12.324 snone measurable
Multi-stage instead of one stage, cold build10.3 s and 10.4 s, one stage12.1 s on both passescosts about 1.7 s, saves 24.5 MB

1. Order the Dockerfile so a commit cannot reach the install

Copying the source before installing dependencies means every commit invalidates the install layer, and the install is the expensive part. Holding the stage count at one and moving only where COPY sits, a rebuild after a one-line source change went from 7.1 seconds to 2.2. That is the largest number on this page, and it costs you four lines of a Dockerfile and no money at all.

Cold, with nothing to reuse, the two orders are the same: 10.3 seconds against 10.3, and the repeat pass put them a tenth of a second the other way. Ordering buys nothing on the first build a repository ever does. It buys everything on the second and on all of the ones after that.

Dockerfile
# the install layer survives every commit that only touches src/
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
RUN npm run build

2. Give the builder a cache that outlives the runner

A fresh runner has no layers, so without an external cache backend every instruction re-executes whatever you changed. Exporting to a registry and reading back from it took the rebuild after a source change from 12.2 seconds to 8.8.

The export is not free: writing the mode=max cache cost 15.7 seconds against a 12.2 second build with no cache. You pay that on the runs that write and collect on every run that reads.

.github/workflows/build.yml
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
    cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max

3. Put the build on a bigger runner

Doubling the cores took the same cold build from 12.2 seconds to 9.6, a 21 percent improvement, which is a solid return for a one-word change to runs-on. It sits here rather than higher because it is linear and it repeats on your invoice: you pay the higher rate on every minute, including the ones the two changes above would have removed outright.

Do it after the cache, not instead of it: a warm registry cache on the small runner beat a cold build on the medium one, 8.8 seconds against 9.6.

4. Mount a package cache for the install layer

A RUN --mount=type=cache keeps the package manager's download directory outside the layer, so a lockfile change re-runs the install without re-downloading everything. It took that build from 9.0 seconds to 6.6.

The catch is important enough to rank it here. Cache mounts live in the builder's own state, and they are not exported by cache-to. We measured the same lockfile change on a freshly created builder and got 12.1 seconds, no better than cold. On ephemeral runners this tactic is worth nothing unless the builder itself persists between jobs.

Dockerfile
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund

5. .dockerignore: no time here, and still not optional

We measured the same cold build with an 85 MB context of 7,348 files and then with a 1 MB context of 8 files, both counted by job-c.sh and recorded in job-c.log. The two came in a millisecond apart, over a local socket on the same machine.

Keep the file regardless. It stops COPY . . pulling a local node_modules into the image, which is a correctness problem rather than a speed one. If your builder is remote and the context crosses a network, this measurement does not describe you.

6. Multi-stage costs build time and buys image size

This is the row the isolated measurement turned around. Holding the COPY order and changing only the stage layout, the multi-stage build took 12.1 seconds against 10.3 for the single-stage one, and repeated at 12.1 against 10.4. It costs about 1.7 seconds, because the version that gives you a slim runtime installs twice: once to build, once for production dependencies only.

What it buys is 97,529,871 bytes down to 73,041,382, against a node:20-slim base of 71,390,006, and that lands on every pull of the image rather than on the build clock. The 1.7 seconds also comes back once a cache carries the intermediate stages: the same four-stage image restores in 9.7 seconds with mode=max against 11.3 with mode=min, about 1.6 seconds for the stages. Cold it costs that; warm it returns it, and the image is smaller either way.

What we ran, and what it does not tell you

Four scripts, on 20 September 2026, all committed under content/repro/timings/ with the output they produced and the job record naming the runner size and the exit code. docker-layer-caching-in-github-actions/job-b.sh covered the cache backends, and under this page job-c.sh the cache mount and the context, job-d.sh the runner comparison on a latchkey-medium runner, and job-e.sh the layer order and the stage layout. Docker 29.7.2, buildx 0.36.1, Node 20.20.2.

job-e.sh exists because job-c.sh got two comparisons wrong. It put a single-stage cache-hostile Dockerfile against a four-stage cache-friendly one, which moves the COPY order and the stage count together, so neither row meant what it said. job-e.sh changes one variable at a time: three cold builds, no cache backend and a fresh builder before each, then a rebuild after a source change with the stage count held at one. Rows one and six come from it, and the numbers job-c.sh produced for those two are not used anywhere on this page.

job-b.sh needed a second attempt. The first aborted at its first docker buildx rm, because the runner executes the command under set -e and the builder did not exist yet; the committed script differs from it by one added line, set +e, and no timing carried over.

One pass per row means these seconds carry runner noise, and a difference under about half a second is not real. The cold builds in job-e.sh ran twice for that reason and landed within a tenth of a second of themselves. The registry measurement used a registry on the runner itself, so it is a floor rather than what an off-host one gives you.

Frequently asked questions

Why is my Docker build slow in GitHub Actions but fast locally?
Your local daemon keeps layers between builds and a CI runner starts with an empty image store, so without an external cache backend every instruction re-executes no matter what changed. That accounted for 3.4 seconds of a 12.2 second build on the image we measured, and it scales with the size of your install step.
How can I speed up Docker builds in GitHub Actions?
In the order our measurements support: fix the Dockerfile ordering first, because it is free and it was the largest number we recorded, then add a layer cache backend, then consider a larger runner, then a cache mount if your builder persists. .dockerignore returned nothing here, and multi-stage cost build time and bought image size.
Does a bigger runner make Docker builds faster?
Yes, and less than a cache does. Going from 2 vCPU to 4 took the same cold build from 12.2 seconds to 9.6, about 21 percent. A warm registry cache on the smaller runner beat that at 8.8 seconds without raising the per-minute rate. Size the runner last.
Do BuildKit cache mounts work on GitHub Actions runners?
They work within one job and usually not between jobs. A cache mount lives in the builder's state and is not exported by cache-to, so on an ephemeral runner it starts empty every run. We measured a lockfile change at 6.6 seconds on a warm builder and 12.1 seconds on a fresh one, which is the same as no cache at all. BuildKit tracks the gap as moby/buildkit issue 1512, "Allow controlling cache mounts storage location", still open as of 20 September 2026.

Related guides

References

Fix the layer order first. Then let Latchkey keep the layers between jobs for you. Start free → 30-day trial · No credit card