How to Reduce Docker Image Build Time in CI/CD (GitHub Actions + BuildKit)

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.

The Ephemeral Runner Dilemma
Run #1: Fresh VM (Empty Cache) → 11m 40s Build
VM Destroyed (Layers Lost)
Run #2: Fresh VM (Empty Cache) → 11m 45s Build
VM Destroyed (Layers Lost)

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"]
The Power of Cache Mounts

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
Registry Cache Trade-off

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

1. Missing .dockerignore

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.

2. Using Dynamic RUN Commands

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.

3. Omission of mode=max

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.

4. Changing Layer Order Arbitrarily

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

What is the difference between inline cache and the registry cache backend?
Inline cache embeds build metadata directly inside the application image manifest itself. It does not require a separate storage tag, but it can only cache the final target stage (acting like mode=min). The dedicated registry cache backend (type=registry) uploads full layer archives to a standalone repository tag, supporting complete multi-stage caching (mode=max).
Does GitHub Actions charge for cache storage?
No, standard GitHub Actions caching is free, but each repository is capped at 10 GB. If your total cache usage exceeds 10 GB, GitHub will automatically evict the oldest cache entries to make room for new ones.
Why does my build still take long even when all layers say "CACHED"?
If all layers are cached but the step still takes significant time, you are likely experiencing network latency while transferring large cache archives from the GitHub cache backend to the runner, or your build context is unnecessarily massive because of a missing .dockerignore file.
Can I use BuildKit cache mounts (--mount=type=cache) with GitHub Actions cache?
Yes. When using docker/build-push-action with type=gha,mode=max, BuildKit captures both ordinary image layers and the contents of cache mount points, serializing them into the workflow cache.
Should I use Alpine or Debian slim base images for faster CI builds?
While Alpine images have a smaller initial download footprint (around 5 MB vs 30 MB for Debian slim), they use musl libc instead of glibc. For Node, Python, or Ruby, this can force native binary extensions to compile from source during installation, making builds significantly slower. Debian slim is often faster overall for languages with pre-compiled glibc wheels and binaries.

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