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

> A GitHub Actions Docker build slow enough to notice has six plausible fixes. Here is what each one returned on the same image, measured in seconds.

Source: https://latchkey.dev/learn/speed/speed-up-docker-builds-in-github-actions  
Updated: 2026-09-20

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.

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.

| Tactic | Control | With it | Saved |
| --- | --- | --- | --- |
| Cache-friendly layer order, rebuild after a source change | 7.1 s hostile order | 2.2 s | 4.9 s |
| Registry layer cache, rebuild after a source change | 12.2 s, no cache | 8.8 s | 3.4 s |
| Larger runner, 2 vCPU to 4, cold build | 12.2 s | 9.6 s | 2.6 s |
| BuildKit cache mount, lockfile change | 9.0 s | 6.6 s | 2.4 s |
| `.dockerignore`, 85 MB context to 1 MB, cold | 12.323 s | 12.324 s | none measurable |
| Multi-stage instead of one stage, cold build | 10.3 s and 10.4 s, one stage | 12.1 s on both passes | costs about 1.7 s, saves 24.5 MB |

> From the four scripts named under "What we ran" at the foot of this page, on Latchkey `latchkey-small` and `latchkey-medium` runners on 2026-09-20. One pass per row, except the cold builds in the first and last rows, which ran twice.

## 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
```

> Measured by `job-c.sh`. Persistent builder: 6.6 s. Fresh builder, same change: 12.1 s. The saving is real and it is not portable to a runner that starts clean.

## 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](/learn/speed/docker-layer-caching-in-github-actions) 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.

## FAQ

### 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.

## References

- [Docker Docs: optimize cache usage in builds (verified 2026-09-20)](https://docs.docker.com/build/cache/optimize/)
- [Docker Docs: cache storage backends (verified 2026-09-20)](https://docs.docker.com/build/cache/backends/)
- [GitHub Docs: GitHub-hosted runner specifications (verified 2026-09-20)](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
- [Latchkey documentation: Docker layer caching (verified 2026-09-20)](https://latchkey.dev/documentation/docker-layer-caching)
- [moby/buildkit 1512: allow controlling cache mounts storage location (verified 2026-09-20)](https://github.com/moby/buildkit/issues/1512)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
