Skip to content
Latchkey

How to Speed Up Docker Builds in CI

Docker builds are often the slowest part of CI. BuildKit caching and a few layer-ordering habits cut most of the time.

A cold Docker build reinstalls everything every run. The fix is to reuse layers across runs and only rebuild what actually changed.

1. Enable BuildKit with a registry cache

Use docker/build-push-action with cache-from and cache-to so unchanged layers are pulled instead of rebuilt.

.github/workflows/build.yml
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: myorg/app:${{ github.sha }}
    cache-from: type=registry,ref=myorg/app:buildcache
    cache-to: type=registry,ref=myorg/app:buildcache,mode=max

2. Order layers cheap-to-expensive

Copy package manifests and install dependencies before copying source. Source changes every commit; dependencies rarely do, so a stable dependency layer stays cached.

3. Use multi-stage builds

Build in a fat stage, copy only artifacts into a slim final stage. Less to push, less to pull next run.

4. Skip the build when nothing changed

Add path filters so image builds run only when the Dockerfile or app source changes, not on every docs edit.

Measure before you optimise

Pipeline optimisation usually targets the step people assume is slow. Get the real per-step timings first, because the answer is frequently dependency install or a cold cache rather than the build itself.

Terminal
# per-job timings for the last 20 runs
gh run list --limit 20 --json databaseId,conclusion,createdAt,updatedAt \
  --jq '.[] | "\(.conclusion)\t\(.createdAt)\t\(.updatedAt)"'

# per-step timing inside one run
gh run view <run-id> --log | grep -E "^\S+\s+.*Run |##\[group\]" | head -40

Key takeaways

  • Registry-backed BuildKit cache is the biggest win.
  • Order Dockerfile layers so dependencies stay cached.
  • Multi-stage builds shrink push and pull time.

Frequently asked questions

How do I speed Up Docker Builds in CI?
A cold Docker build reinstalls everything every run. The fix is to reuse layers across runs and only rebuild what actually changed.
1. Enable BuildKit with a registry cache?
Use docker/build-push-action with cache-from and cache-to so unchanged layers are pulled instead of rebuilt.
2. Order layers cheap-to-expensive?
Copy package manifests and install dependencies before copying source. Source changes every commit; dependencies rarely do, so a stable dependency layer stays cached.
3. Use multi-stage builds?
Build in a fat stage, copy only artifacts into a slim final stage. Less to push, less to pull next run.
4. Skip the build when nothing changed?
Add path filters so image builds run only when the Dockerfile or app source changes, not on every docs edit.

Related guides

References

Run this faster and cheaper on Latchkey managed runners - self-healing included. Start free → 30-day trial · No credit card