Skip to content
compiler.dev

Docker Build Slow in CI? Find the Cause and Fix It

Last updated

A Docker build is slow in CI for one of five reasons: the build context is huge, layers are ordered so that nothing is cached, the cache does not survive between runs, the image does too much in one stage, or the machine is small. Measure first. Most builds have one dominant cause, and fixing the wrong one wastes an afternoon.

This is the diagnosis guide. For the cache backends themselves, see Buildx cache in GitHub Actions. For the basics of layer caching, see Docker layer caching in CI.

Step 1: see where the time goes

Use plain progress output so each step shows its duration, and turn on BuildKit's timing:

docker buildx build --progress=plain -t app:test . 2>&1 | tee build.log

Each step prints #N DONE 12.3s, and cached ones print CACHED. Read the log top to bottom and note three things:

  1. How long load build context or transferring context took, and how many MB.
  2. Which steps say CACHED, and which should have been.
  3. Which single step is longest.

In a GitHub Actions run, the same log is in the build step. Compare a run on a branch you just pushed with a re-run of the same commit. If the re-run is much faster, your cache works in some cases and not others; if both are slow, it is not working at all.

Step 2: build context size

The first line of a build, transferring context: 1.2GB, is a giveaway. Everything in the context directory is sent to BuildKit before the first instruction runs, unless .dockerignore excludes it. In CI this often includes node_modules, .git, test artifacts, coverage and build output.

A starting .dockerignore:

.git
node_modules
**/node_modules
dist
build
coverage
*.log
.env*
.github

Why it matters beyond transfer time: a COPY . . instruction invalidates its layer when any file in the context changes. Large, noisy contexts mean the layer rebuilds on every commit and drags every later layer with it. With a good .dockerignore, the context stays stable between commits.

Check what is being sent:

docker buildx build --progress=plain . 2>&1 | grep -i "transferring context"

Step 3: layer order and cache misses

Docker reuses a layer only if its instruction and everything above it are unchanged. Put things that change rarely first, and things that change on every commit last. The classic mistake:

FROM node:22
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

Any source change invalidates COPY . ., so npm ci reruns every build. Fix:

FROM node:22
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Now npm ci reruns only when the lockfile changes. Same idea for every ecosystem: copy manifests, install, then copy source.

Other things that silently break cache:

  • ARG or ENV with a changing value (a commit SHA or build date) placed early. Everything below it misses. Move it to the last step.
  • RUN apt-get update in its own layer. It gets cached stale. Combine apt-get update && apt-get install -y ... in one RUN, and clean the lists in the same layer.
  • COPY --from of an unpinned base. A moving latest tag changes the base layer. Pin versions or digests.

Step 4: the cache does not exist on a fresh runner

This is the biggest cause in CI, and it is invisible on a laptop. A fresh GitHub-hosted runner starts with no images and no BuildKit state. Every build is a full build, even with a perfect Dockerfile. You have to export the cache somewhere and import it next run, with cache-from and cache-to on docker/build-push-action. The options (GitHub Actions cache, a registry, a local directory) and their limits are covered in Buildx cache in GitHub Actions.

Also check the cache scope: PR builds read main's cache but not each other's, and the 10 GB repository limit evicts old entries; see cache limits.

Step 5: multi-stage builds

Multi-stage builds help in two ways: the final image is small, and BuildKit builds independent stages in parallel and skips stages the target does not need.

FROM node:22 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22-slim AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

If tests and linters are in the same Dockerfile, give them their own stage and build it with --target test in a separate job so the runtime image build does not wait on it.

Step 6: BuildKit settings

BuildKit is the default builder in current Docker, and docker/setup-buildx-action configures the buildx builder explicitly. Things to check:

  • Cache mounts (RUN --mount=type=cache,target=/root/.npm npm ci) speed up repeated installs locally, but the mount lives on the builder, not in the exported layer cache. On a fresh runner it starts empty. See the section in the Buildx cache guide.
  • Multi-platform builds. Building linux/amd64,linux/arm64 on an amd64 runner emulates arm64 with QEMU, which can be many times slower. Use native arm runners or cross-compile in the build stage with --platform=$BUILDPLATFORM.
  • Pushing large layers. If time goes in exporting to image and the push, your layers are too large. Look for COPY of build outputs you do not need, or chown -R on big trees, which duplicates every file into a new layer. Use COPY --chown.
  • Slow base image pulls. Use a smaller base, or a registry mirror close to the runner.

Step 7: the machine

When every step is cached sensibly and a cold build is still long, the remaining time is compilation and compression, and both scale with CPU. A GitHub-hosted 2-core runner is the default. A larger machine cuts compile time but not network time, so do this last. For dedicated remote Docker builders, Depot is a well-regarded option built around that exact problem; see the Depot comparison. compiler.dev runs your existing workflow on 8 vCPU machines with a Docker layer cache ready, at $0.012 per minute: see pricing and alternatives to GitHub-hosted runners.

A quick checklist

  1. Context under about 100 MB, with a .dockerignore.
  2. Manifests copied and installed before source.
  3. cache-from and cache-to set in the build action.
  4. Moving values (SHA, date) in the last layers.
  5. Independent stages, with tests in their own target.
  6. Native, not emulated, architecture builds.

FAQ

Why is my Docker build fast locally and slow in CI?

Your laptop has a persistent layer cache and image store. A fresh CI runner starts empty unless you export and import the cache.

Does .dockerignore speed up builds?

Yes, twice: less data to transfer, and fewer cache invalidations from files that do not matter to the image.

Is BuildKit on by default?

In current Docker releases, yes. In GitHub Actions, docker/setup-buildx-action sets up a buildx builder, which is required for most cache backends.

Should I build on every commit?

Only if you need the image. Build on pull requests without pushing, push on merges. Skipping image builds when no Docker inputs changed is often the fastest fix; use paths filters or an affected-only check.

Is the GitHub Actions cache enough for Docker?

For small to medium images, yes. For large images the 10 GB shared limit becomes the constraint, and a registry cache is better.

Run this Docker build on compiler.dev 200 free minutes

Start free