How to Slash Docker Build Times in GitHub Actions Using BuildKit
Every software team reaches that point where CI pipelines stop feeling like automation and start feeling like a tax. You push a one-line bug fix, open a pull request, and wait twelve minutes while GitHub Actions downloads gigabytes of identical npm packages, compiles static assets from scratch, and rebuilds every container layer sequentially.
Multiply those twelve minutes across six engineers shipping four pull requests a day. That is nearly five hours of human waiting time lost every single day, not to mention the monthly GitHub Actions billing bill stacking up silently in the background. The painful reality? Roughly 80% of that time is spent repeating identical work that your runners already executed an hour earlier.
Ephemeral cloud runners do not remember anything. By default, every time GitHub spins up a fresh Ubuntu runner, it starts with an empty Docker daemon cache. Unless you explicitly instruct Docker and your CI workflow to preserve and retrieve layer states across runs, you are rebuilding the wheel on every commit.
This guide walks through practical, battle-tested optimizations using Docker BuildKit and GitHub Actions. By the end, you will have a pipeline that reliably builds production-ready containers in under a minute instead of ten.
The Root Cause: Why Default CI Docker Builds Are Abysmal
On your local development machine, running docker build repeatedly is fast because the local Docker engine stores layer caches directly on your SSD. If your package.json or requirements.txt has not changed, Docker steps right over those lines instantly.
In standard CI environments like GitHub Actions hosted runners (ubuntu-latest), you get a brand-new virtual machine for each workflow job. Once the job finishes, the VM is destroyed. The cached layers created during that run vanish with it.
To fix this, you must treat Docker layer caching as a remote transport problem. You need to pull cached layers from an external source before building, and push updated layers back out once the build finishes. But if you do this naively using older Docker commands (--cache-from on the legacy engine), you end up pulling full container images over the network, which often takes just as long as rebuilding them.
Understanding Docker BuildKit and Remote Cache Backends
Docker BuildKit is the modern build engine backend that replaced the legacy builder. BuildKit approaches container building as an execution graph rather than a sequential list of steps. It does three critical things that the old builder could not:
- Graph-based execution: It analyzes your Dockerfile and builds independent stages in parallel.
- Dedicated cache management: It can serialize and export layer metadata and raw build artifacts independently of the final application image.
- Mount caching: It lets you persist package manager directories (like /root/.npm, /root/.cache/pip, or /go/pkg/mod) across steps without saving them as permanent image layers.
BuildKit supports multiple cache backends for remote storage:
| Cache Backend | Best For | Pros | Trade-offs / Limitations |
|---|---|---|---|
| GitHub Actions Cache (gha) | Standard GitHub hosted workflows | Zero registry setup, extremely fast transfer rates within GitHub's network. | 10 GB per-repository limit; caches evict if unused for 7 days. |
| Registry Cache (registry / inline) | Cross-platform & multi-CI systems (GitLab, Jenkins, GH) | No storage size caps (bound only by your registry quota); shareable across branches. | Requires container registry authentication and network bandwidth egress/ingress. |
| Local Runner Cache (local) | Dedicated self-hosted bare metal or persistent EC2/GCE runners | Fastest possible access; reads directly from attached NVMe storage. | Requires managing disk cleanup, garbage collection, and persistent runner infrastructure. |
Step 1: Structuring the Dockerfile for Maximum Cache Retention
Before configuring GitHub Actions YAML files, your Dockerfile structure must obey fundamental layer ordering principles. The golden rule is simple: place things that change rarely at the top, and things that change frequently at the bottom.
Here is an anti-pattern that costs teams hours every week:
# ANTI-PATTERN: Breaks cache on every single line of code edited
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
In this naive configuration, running COPY . . brings in every source code file. If an engineer fixes a typo in a markdown comment, the checksum of the build context changes. Docker invalidates the cache at the COPY step, forcing npm install and npm run build to run from scratch every time.
The Optimized Multi-Stage Dockerfile
To maximize cache hit rates, separate your dependency definitions from your application source code, and use multi-stage builds to discard build-time tooling from your production runtime container.
# Stage 1: Base Dependencies
FROM node:20-alpine AS dependencies
WORKDIR /app
# Only copy manifest files first
COPY package.json package-lock.json ./
# Use BuildKit cache mounts to persist package manager cache
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline --no-audit
# Stage 2: Application Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.json ./
# Copy source files only after dependencies are locked in place
COPY src ./src
RUN npm run build
# Stage 3: Production Runtime
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --prefer-offline --no-audit
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Notice the syntax RUN --mount=type=cache,target=/root/.npm. This tells BuildKit to keep the contents of /root/.npm in a dedicated temporary storage directory across builds. Even if someone adds a single new package to package.json, npm will only download that one new package instead of re-fetching the entire module tree from the public registry.
Step 2: Configuring GitHub Actions with docker/build-push-action
The standard way to build containers in GitHub Actions is the official docker/build-push-action maintained by the Docker team. It integrates natively with BuildKit and supports the GitHub Actions Cache API (type=gha).
Here is a complete, production-grade GitHub Actions workflow demonstrating two-tier caching with GitHub Actions native cache backend:
name: Build and Push Container
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ghcr.io/${{ github.repository }}:latest
# Enable BuildKit GitHub Actions cache backend
cache-from: type=gha
cache-to: type=gha,mode=max
Why mode=max Matters
By default, if you specify cache-to: type=gha without passing mode=max, BuildKit only caches the layers that appear in the final stage of your Dockerfile (in our example, the runner stage). It completely ignores intermediate build stages like dependencies and builder.
When you set mode=max, BuildKit exports cache metadata and layer layers for every single stage in your multi-stage Dockerfile. On subsequent runs, your CI runner skips compiling TypeScript or pulling dev-dependencies entirely.
Step 3: Branch-Scoped Caching Strategy
GitHub Actions enforces a security isolation boundary on its cache: pull requests can access caches created on the repository's default branch (main or master), but the default branch cannot access caches created inside an isolated PR branch.
To avoid race conditions where PR runs pollute the primary cache or constantly miss the main branch's cache, scope your cache keys explicitly:
- name: Build and push with scoped cache
uses: docker/build-push-action@v5
with:
context: .
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: |
type=gha,scope=${{ github.ref_name }}
type=gha,scope=main
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
With this setup, the workflow first looks for a cache specific to the active feature branch. If it does not find one (for example, on the first push of a new PR), it falls back immediately to the main branch's cache. When it finishes building, it writes back to its own branch scope without overwriting the master baseline.
Alternative: Using Container Registry Caches (type=registry)
While GitHub's type=gha is convenient, GitHub enforces a hard limit of 10 GB of total cache storage per repository. Once you exceed 10 GB, GitHub quietly evicts older cache archives using a least-recently-used (LRU) policy. If you have multiple services in a monorepo, you will hit this limit fast.
If you run into cache eviction issues, switch to storing your build cache directly inside your container registry (such as GitHub Container Registry, AWS ECR, or Docker Hub):
- name: Build and push with Registry Cache
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/my-org/my-app:latest
cache-from: type=registry,ref=ghcr.io/my-org/my-app:buildcache
cache-to: type=registry,ref=ghcr.io/my-org/my-app:buildcache,mode=max,image-manifest=true
Using type=registry removes the 10 GB cap, but remember that build cache layers must now be downloaded over the public internet from your container registry to the runner. If your registry is in AWS us-east-1 and your GitHub Actions runner is hosted in Azure us-east-2, latency and data transfer overhead will shave off some of your speed gains.
Step 4: Real-World Performance Benchmarks
To see how these optimizations behave in practice, consider a production Node.js microservice running TypeScript compilation and bundling React frontend assets. Here is the recorded timing breakdown across four build conditions on standard ubuntu-latest GitHub Actions runners:
| Build Strategy | Cold Build (No Cache) | Code Change Only | Dependency Added | Average Monthly Minutes (100 builds) |
|---|---|---|---|---|
| Default Docker Build (No CI Cache) | 8m 42s | 8m 35s | 8m 50s | 860 minutes |
| Legacy --cache-from (Image Pull) | 8m 50s | 4m 15s | 7m 10s | 480 minutes |
| BuildKit + type=gha (Default Stage) | 8m 45s | 3m 05s | 6m 30s | 340 minutes |
| BuildKit + type=gha (mode=max + Mounts) | 8m 55s | 0m 42s | 1m 55s | 95 minutes |
The time saved is dramatic. When engineers change only application source files (the most common type of commit), build time drops from over eight minutes down to 42 seconds. BuildKit pulls the cached dependency layers from GitHub's internal network, skips package installation entirely, compiles only the modified modules, and completes the run.
Self-Hosted Runners: The Ultimate Performance Floor
If you have pushed BuildKit to its limits and still need faster execution, the network transfer of cache archives becomes your bottleneck. Even when GitHub Actions cache pulls at 80 MB/s, downloading 3 GB of layers takes 40 seconds before the build even starts.
With self-hosted GitHub runners (such as an Amazon EC2 instance or an on-premise server with a dedicated SSD), you can bypass network transfers entirely by using persistent local disk cache:
# On a persistent, self-hosted runner:
- name: Build with local persistent disk cache
uses: docker/build-push-action@v5
with:
context: .
tags: internal-registry.local/my-app:latest
cache-from: type=local,src=/mnt/docker-cache
cache-to: type=local,dest=/mnt/docker-cache,mode=max
Because /mnt/docker-cache remains physically present on the runner's drive between jobs, cache access happens at NVMe bus speeds (2,000+ MB/s). BuildKit checks layer integrity in milliseconds and begins execution without network latency.
5 Common Mistakes That Invalidate Docker CI Caches
If you don't exclude .git, local node_modules, log files, or temporary artifacts from your build context, file timestamps or git commit hash changes will invalidate your COPY commands every run.
Commands like RUN apt-get update && apt-get install -y curl or RUN npm install -g pnpm@latest fetch undefined external state. Docker caches the step itself, leading to stale dependencies down the line.
Forgetting mode=max in cache-to means only your final production target stage gets saved. Intermediate build steps get rebuilt from scratch on every run.
Placing an environment variable definition (ENV APP_VERSION=1.0.4) near the top of the Dockerfile invalidates all subsequent steps whenever the version bump occurs.
Frequently Asked Questions
Wrapping Up
Optimizing CI/CD pipelines is not just about reducing infrastructure bills—it directly impacts how quickly your team can ship value and respond to incidents. Fast feedback loops keep developers in a flow state, while slow pipelines invite context switching and stale branches.
Audit your Dockerfiles today. Add a comprehensive .dockerignore, structure your stages so package manifests are isolated from application source code, enable Docker BuildKit with mode=max in GitHub Actions, and watch your build times drop from double digits down to seconds.
Comments