DEV Community

Libme
Libme

Posted on

Why Your Docker Build Takes 11 Minutes in CI When It Takes 20 Seconds Locally

If your Docker build is fast locally and slow in CI, the base image is almost never the problem. CI runners are ephemeral, so they start with an empty layer cache unless you explicitly wire one up, and a COPY . . placed above your dependency install throws away whatever cache you did manage to restore. Fix the layer ordering first, then attach a cache backend, and measure with --progress=plain so you can see which steps actually say CACHED.

This is the version of the problem I keep running into on small teams: the build was fine when it lived on one laptop, then it moved to a hosted runner and quietly became the longest step in the pipeline.

Why is the Docker cache empty on every CI run?

Locally, your daemon keeps every intermediate layer on disk between builds. A hosted runner is a fresh VM. When the job starts, docker build has nothing to compare against, so every RUN re-executes from scratch — including the three-minute npm ci or pip install you never think about.

You can confirm it in one run. Add plain progress output and read the log:

docker build --progress=plain -t myapp:ci . 2>&1 | grep -E 'CACHED|DONE'
Enter fullscreen mode Exit fullscreen mode

On a warm local build you'll see a wall of CACHED lines. On a cold runner you'll see almost none. That difference — not the image size, not the base distro — is your eleven minutes.

The related trap is that people "fix" this by switching from node:22 to node:22-alpine and are confused when the build time barely moves. Image size affects push and pull time; cache hits affect build time. They're different bills.

Takeaway: a slow CI build is a cache-miss problem until you have proven otherwise with --progress=plain.

How should a Dockerfile be ordered so the cache actually holds?

BuildKit invalidates a layer when its inputs change, and every layer after it. So the rule is: things that change rarely go up top, things that change on every commit go at the bottom. In practice that means copying your manifest and lockfile alone, installing, and only then copying source.

Here's a Node example with the two common mistakes removed:

# syntax=docker/dockerfile:1
FROM node:22-slim AS deps
WORKDIR /app

# Only the lockfile inputs — this layer survives ordinary code changes.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
# Source last: a code-only commit invalidates nothing above this line.
COPY . .
USER node
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

The Python shape is identical — COPY requirements.txt . and RUN pip install -r requirements.txt before COPY . .. If you use Poetry or uv, copy the manifest and the lockfile together, because installing from a manifest without its lock defeats the point of a reproducible layer.

Two details that bite people:

  • A .dockerignore that misses .git or node_modules means your COPY . . context changes on every build for reasons unrelated to your code. Check it before blaming BuildKit.
  • Anything that writes a timestamp or a build ID into an early layer will invalidate everything below it, forever. Push those to the last stage.

Takeaway: if COPY . . appears above your dependency install, no cache backend on earth will save that build.

How do you make the cache survive between CI runs?

Ordering only pays off if there's something to restore from. BuildKit can export its cache to an external backend and import it on the next run. On GitHub Actions the least-effort option is the built-in cache backend:

name: build
on: [push]

jobs:
  image:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: false
          tags: myapp:ci
          cache-from: type=gha
          cache-to: type=gha,mode=max
Enter fullscreen mode Exit fullscreen mode

mode=max exports intermediate stage layers too, not just the final image — which is what you want when you have a multi-stage build, since the expensive npm ci lives in a stage that never ships. The tradeoff is a larger cache, and GitHub's Actions cache is capped per repository (10 GB as of mid-2026) with least-recently-used eviction, so a mode=max cache on a busy monorepo can push your other caches out.

The other option is a registry cache, which works on any CI provider and has no 10 GB ceiling:

          cache-from: type=registry,ref=ghcr.io/you/myapp:buildcache
          cache-to: type=registry,ref=ghcr.io/you/myapp:buildcache,mode=max
Enter fullscreen mode Exit fullscreen mode

If you want this without maintaining runner infrastructure, Depot is the managed builder that keeps a persistent BuildKit cache volume across runs so you skip the export/import round trip entirely.

One version note: the type=gha backend was rewritten to use GitHub's newer cache service, and older Buildx releases talked to an API that GitHub has since retired. If you pinned setup-buildx-action or a Buildx version a couple of years ago and your cache silently stopped working, that's the first thing to check.

Takeaway: cache-from/cache-to is the line that turns a correct Dockerfile into a fast pipeline; without it the ordering work is invisible.

What about RUN --mount=type=cache?

Cache mounts are the other half, and they're the part that surprises people. They give a RUN step a persistent directory for a package manager's own cache:

RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
Enter fullscreen mode Exit fullscreen mode

This is genuinely good on a long-lived builder: even when the lockfile changes and the layer must rebuild, the package manager re-downloads almost nothing.

The catch: cache mount contents are builder-local state. They are not part of the exported layer cache, so cache-to: type=gha does not carry them to the next ephemeral runner. On hosted CI they mostly help within a single build, or when you run a self-hosted/persistent builder. Treat them as a bonus on top of correct layer ordering, not a substitute for it.

Takeaway: cache mounts speed up rebuilds on a builder that sticks around; layer cache export is what helps a fresh runner.

Which fix applies to which symptom?

Symptom Likely cause Fix
Every step rebuilds, no CACHED lines No cache backend on an ephemeral runner cache-from/cache-to (gha or registry)
Dependency install reruns on code-only commits COPY . . above the install Copy manifest + lockfile first
Cache hits locally, misses in CI on the same commit Build context differs Fix .dockerignore, check generated files
Build is fast, deploy is slow Image size, not cache Multi-stage, slim base, drop build tools
Cache worked, then stopped after months Cache evicted or backend API changed Check size limits; update Buildx/actions

Takeaway: match the fix to the symptom you measured, because "slow build" and "slow deploy" have almost no overlap in causes.

FAQ

Why is my Docker build slow in GitHub Actions but fast locally?
Because the hosted runner is a fresh machine with no layer cache. Local builds reuse layers your daemon kept on disk; CI has nothing to reuse until you configure cache-from/cache-to with a backend like type=gha or type=registry.

Does docker build --no-cache explain the difference?
Only if it's actually in your workflow — check for it, since people add it to debug a stale build and forget to remove it. Otherwise the empty cache is structural to the runner, not a flag.

Will a smaller base image make my build faster?
Rarely. A slim or Alpine base makes the image faster to push and pull, but build time is dominated by cache misses on install steps. Fix ordering and cache export first, then optimize size for deploy speed.

Bottom line

Start by reading --progress=plain output on a CI run and counting CACHED lines — that tells you whether you have a cache problem or a size problem. If you have a cache problem, reorder the Dockerfile so lockfiles land above source, then add a cache backend: type=gha if you live on GitHub Actions and your cache fits under the repo limit, type=registry if you're on another provider or you keep getting evicted. Add cache mounts only after those two are in place, and only expect them to pay off on a persistent builder. Most of the eleven minutes goes away at step two.

Related reading

Top comments (0)