Build1 publisher3 min readPublished
Your CI Build Is Slow Because The Cache Is Empty, Not Because The Base Image Is Fat
A dev.to teardown argues the 11-minute CI build that runs in 20 seconds locally is a cache-miss problem, and that one run with --progress=plain will prove it before you pay for bigger runners.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- If a Docker build is fast locally and slow in CI, the base image is almost never the problem; the cache-miss difference, not image size or base distro, accounts for the eleven minutes.
- CI runners are ephemeral and start with an empty layer cache unless a cache backend is explicitly wired up.
- A COPY . . placed above the dependency install throws away whatever cache was restored; if COPY . . appears above the dependency install, no cache backend will save that build.
- Locally the daemon keeps every intermediate layer on disk between builds; a hosted runner is a fresh VM, so docker build has nothing to compare against and every RUN re-executes from scratch, including a three-minute npm ci or pip install.
- The suggested diagnostic is: docker build --progress=plain -t myapp:ci . 2>&1 | grep -E 'CACHED|DONE'
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A post on dev.to makes a claim worth testing on your own pipeline: when a Docker build takes 11 minutes in CI and 20 seconds on a laptop, the base image is almost never the cause [1]. That gap is roughly 33x [19], and it is the kind of number that gets teams shopping for faster runners instead of reading their build log.
The mechanism is unglamorous. Your local daemon keeps every intermediate layer on disk between builds; a hosted runner is a fresh VM, so `docker build` has nothing to compare against and every `RUN` re-executes, including the three-minute `npm ci` or `pip install` nobody thinks about [2][5]. And a `COPY . .` placed above your dependency install throws away whatever cache you did manage to restore, because BuildKit invalidates a layer when its inputs change and every layer after it [3][9].
Both failures are visible in a single run. The author's diagnostic is `docker build --progress=plain -t myapp:ci . 2>&1 | grep -E 'CACHED|DONE'`: a warm local build produces a wall of `CACHED` lines, a cold runner produces almost none [6][7]. That difference, not the distro, is the eleven minutes [1].
This is also why the popular non-fix does nothing. Swapping `node:22` for `node:22-alpine` barely moves build time, because image size affects push and pull while cache hits affect build [8]. Two different bills.
The ordering fix is mechanical: things that change rarely go up top, things that change every commit go at the bottom [9]. In the Node shape that means a `deps` stage that copies only `package.json` and `package-lock.json`, runs `npm ci --omit=dev`, then a runtime stage that copies `node_modules` from `deps` and does `COPY . .` last [10]. Python is identical with `requirements.txt` and `pip install` above the source copy; with Poetry or uv, copy the manifest and lockfile together, since installing from a manifest without its lock defeats the reproducible layer [11]. Two things bite people here: a `.dockerignore` that misses `.git` or `node_modules` makes your build context change every run for reasons unrelated to your code, and anything writing a timestamp or build ID into an early layer invalidates everything below it permanently [12][13].
Only then does a cache backend earn its keep. On GitHub Actions the least-effort route is `cache-from: type=gha` with `cache-to: type=gha,mode=max` behind `setup-buildx-action@v3` and `build-push-action@v6` [14]. `mode=max` matters for multi-stage builds because it exports intermediate stage layers, and the expensive `npm ci` lives in a stage that never ships [15]. The cost is size: per the same post, GitHub's Actions cache is capped per repository at 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 [16]. A registry cache avoids that ceiling and works on any provider [17]. The post also names Depot as a managed builder that holds a persistent BuildKit cache volume across runs, skipping the export/import round trip [18].
What to watch: run the grep before and after reordering and count `CACHED` lines, not wall-clock minutes, since a noisy runner will hide a real improvement. Then check whether your `mode=max` cache is quietly evicting your test caches [16]. If `COPY . .` still sits above the install, no backend will save that build [3].