Lesson 15 of 30
Building a Docker image is just another pipeline step — but a naive one is slow in CI for a specific, fixable reason: it starts from zero layer cache on every run.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Once your app is containerized, "build the artifact" in a pipeline usually means "build a Docker image, then push it somewhere a deployment step can pull it from." Mechanically that's three steps: docker build, docker push to a registry (Docker Hub, GitHub Container Registry, ECR, GCR), and a deploy step elsewhere that references the resulting image tag. Simple in principle — the part that trips teams up is speed.
Docker builds are fast locally because of layer caching: each instruction in a Dockerfile (RUN, COPY, etc.) produces a layer, and if that instruction and everything before it are unchanged, Docker reuses the cached layer instead of re-running it. On your laptop, that cache sits on disk and survives between builds, so a rebuild after a small code change is nearly instant. A CI runner is usually a fresh, disposable virtual machine on every single run — no local disk history, no cache, nothing left over from the last build. Without extra setup, every CI build is a cold build: it re-downloads every base layer, reinstalls every dependency, from scratch, every single time. That's the actual reason Docker-in-CI has a reputation for being slow — not Docker itself, but CI's disposable-runner model working against Docker's caching model by default.
name: Build and push image
on:
push:
branches: [main]
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push, with registry-backed layer cache
uses: docker/build-push-action@v6
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
cache-from/cache-to are what fix the cold-cache problem: instead of relying on the runner's local disk (which is gone by next run), the build pushes its layer cache to the registry itself, tagged separately (:buildcache), and pulls that cache back down at the start of the next run. Combined with a Dockerfile ordered so slow-changing instructions (installing dependencies) come before fast-changing ones (copying application source):
FROM node:24-slim
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile # cached unless deps change
COPY . . # invalidates cache only from here down
RUN pnpm build
...a source-only change reuses the entire dependency-install layer from cache and only re-runs the fast COPY . . and pnpm build steps.
docker build alone in a CI job, with no cache-from/cache-to, gets zero benefit from any previous run. The caching has to be wired up on purpose; it doesn't happen by default the way local Docker caching does.:${{ github.sha }} is the real, immutable, deployable image; :buildcache is a separate tag that exists purely to carry layer cache between runs and is never what gets deployed. Confusing the two, or deploying the cache tag, is a real mistake people make.docker build in CI is often a Dockerfile ordering problem, not a Docker problem. Before reaching for more caching infrastructure, check whether frequently-changing files (source code) are copied before rarely-changing ones (dependency manifests) — that alone can be the entire fix.The caching-dependencies lesson covers caching node_modules or ~/.pnpm-store between CI runs so pnpm install doesn't redo work it already did. Docker layer caching is the exact same problem — avoid redoing work that hasn't changed — just operating one layer down, at the level of container image build instructions instead of package manager installs.