Skip to content
Cloud Native

Docker build caching in GitHub Actions: from 12-minute builds to 90 seconds

docker build cache — a row of blue and white lockers in a room

Every second added to a feedback loop is a tax on engineering velocity. For my algorithmic trading platform, our deployment pipeline was suffering under a massive tax rate. Every commit—even a minor tweak to a trading execution rule or a simple model parameter change—triggered a 12-minute, 43-second build-and-test cycle in GitHub Actions.

Most of this time was spent rebuilding our primary execution container. This container isn’t a lightweight Node.js app; it is a heavy-duty Python environment packed with PyTorch, TA-Lib, custom C++ extensions for fast order serialization, and scientific computation libraries.

A standard docker build command run on a GitHub Actions runner starts fresh every time. Because GitHub provisions a clean virtual machine for each workflow run, local layer caching does not exist.

I set out to crush this bottleneck. After hitting several dead ends with naive caching strategies and disk-serialization hacks, I migrated our pipeline to use BuildKit’s native GitHub Actions cache backend (type=gha). The build time plummeted from over 12 minutes to exactly 91 seconds.

Here is the step-by-step breakdown of how I did it, the dead ends I hit along the way, the configuration code, and the mechanics of why it works.


The Dead Ends: Why Naive Caching Failed

Before arriving at the optimal setup, I tried the intuitive approaches that many developers grab first. They did not work, and in some cases, they actually made our workflows slower.

Dead End 1: Standard actions/cache on /var/lib/docker

My first attempt was to use GitHub’s standard caching action to save and restore the Docker daemon’s internal state directory:

# Do not do this. It is slow and highly prone to corruption.
name: Cache Docker Layers
uses: actions/cache@v4
with:
path: /var/lib/docker
key: ${{ runner.os }}-docker-${{ github.sha }}
restore-keys: |
${{ runner.os }}-docker-

This failed catastrophically. The /var/lib/docker directory is managed by the Docker daemon (dockerd). It contains live sockets, overlay2 storage filesystems, and complex metadata databases. Accessing or writing to this directory while dockerd is running leads to race conditions and file locks.

Even when I stopped the daemon before caching and restarted it afterward, the cache unpack step took upwards of 4 minutes. Worse, we frequently encountered corrupted metadata errors during the cache-restore phase:

Error response from daemon: error restoring devmapper database: transaction rollback failed
Error: Process completed with exit code 1.

Dead End 2: Tarball Export (docker save and docker load)

Next, I attempted to manually export the cached image to a .tar archive, use actions/cache on that archive, and import it before building:

# A slow fallback approach that bottlenecks on runner disk I/O
name: Load Cached Image
run: |
if [ -f /tmp/docker-cache/image.tar ]; then
docker load -i /tmp/docker-cache/image.tar
fi

name: Build Image
run: |
docker build –cache-from=execution-engine:latest -t execution-engine:latest .

name: Save Image for Next Run
run: |
mkdir -p /tmp/docker-cache
docker save execution-engine:latest -o /tmp/docker-cache/image.tar

While this was reliable and avoided driver corruption, it hit a massive performance wall: disk I/O bottlenecks.

GitHub Actions Standard Runners (2-core vCPUs) have notoriously slow disk write speeds, often throttling around 100 MB/s. Writing a 3.5 GB trading execution container to a tarball, compressing it, saving it to disk, and then doing the reverse during the restore phase took over 6 minutes. The overhead of compressing and transferring the tarball wiped out almost all the gains of skipping the package installation steps.


The Solution: BuildKit and the gha Cache Backend

To solve this efficiently, we must bypass the host runner’s local filesystem and write cache layers directly to GitHub’s internal cache service API using BuildKit.

BuildKit is the modern engine under the hood of docker build. It supports a variety of cache backends. Rather than relying on local directory hacks or saving monolithic images, BuildKit can serialize individual compilation layers and push them directly to external storage backends.

The most efficient backend for this scenario is the native GitHub Actions cache exporter (type=gha).

flowchart TD
 A["Runner Starts Workflow"] --> B["Setup Docker Buildx Engine"]
 B --> C["Query GitHub Actions Cache API"]
 C -->|"Cache Hit"| D["Pull Cached Layers in Parallel via Network"]
 C -->|"Cache Miss"| E["Execute Local Layer Build"]
 D --> F["Compile New Layer Adjustments"]
 E --> F
 F --> G["Export Updated Layers to GHA Cache API"]
 F --> H["Push Production Image to Registry"]

The magic of type=gha is that it works at the layer level, communicates directly with GitHub’s fast internal API network via HTTP/2, and handles concurrency out of the box. Only the modified or invalidated layers are transferred over the network, minimizing disk I/O on the runner.


The Code

To implement this, you need two tightly integrated components: an optimized, multi-stage Dockerfile and a correctly configured GitHub Actions workflow YAML file.

1. The Optimized Dockerfile

For BuildKit caching to work effectively, your Dockerfile must be structured so that layers that change infrequently (like system packages and Python dependencies) are isolated at the top, while code that changes on every commit is placed at the very bottom.

# syntax=docker/dockerfile:1.4
FROM python:3.11-slim-bookworm AS builder

# Install system dependencies required for compilation
RUN apt-get update && apt-get install -y –no-install-recommends \
build-essential \
curl \
g++ \
gfortran \
libta-lib0 \
ta-lib-dev \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /build

# Isolate dependency installation to protect the cache layer
COPY requirements.txt .

# Use BuildKit's pip cache mount to accelerate repetitive installs locally
# and build wheels for high-cost C-extensions
RUN –mount=type=cache,target=/root/.cache/pip \
pip wheel –wheel-dir=/build/wheels -r requirements.txt

# Final runtime stage
FROM python:3.11-slim-bookworm AS runner

RUN apt-get update && apt-get install -y –no-install-recommends \
libta-lib0 \
curl \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy compiled wheels from the builder stage
COPY –from=builder /build/wheels /app/wheels
RUN pip install –no-index –find-links=/app/wheels /app/wheels/*.whl \
&& rm -rf /app/wheels

# Copy application source code (this changes on every commit)
COPY src/ /app/src/

# Run the execution application
ENTRYPOINT ["python", "-m", "src.main"]

2. The GitHub Actions Workflow Config

The workflow file configures BuildKit via docker/setup-buildx-action and uses docker/build-push-action to connect our build to the GitHub Action cache.

name: Continuous Integration & Deployment

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
name: Checkout Repository
uses: actions/checkout@v4

# Set up QEMU for multi-architecture builds if necessary
name: Set up QEMU
uses: docker/setup-qemu-action@v3

# Set up BuildKit (Buildx) builder instance
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# Log in to private Container Registry
name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Build and push with explicit GitHub Actions Cache integration
name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository }}/execution-engine:${{ github.sha }}
ghcr.io/${{ github.repository }}/execution-engine:latest
# Read cache from GHA cache backend
cache-from: type=gha
# Write cache to GHA cache backend. mode=max ensures intermediate build stage layers are preserved
cache-to: type=gha,mode=max


Behind the Settings: mode=max and Cache Backends

The line cache-to: type=gha,mode=max is where the core optimization lives.

By default, Docker’s cache exporter runs in mode=min. In min mode, only the layers that make up the final stage of the image (the runner stage in our multi-stage build) are cached. The intermediate layers created in our builder stage—such as the compiled C++ extensions and the compiled TA-Lib wheels—are thrown away.

By changing this to mode=max, we instruct BuildKit to serialize and export the cache metadata and layers for every single stage, including intermediate builder stages. On subsequent runs, BuildKit looks at the builder stage and sees that the requirements.txt has not changed. It immediately pulls the pre-built wheels from the GitHub cache, completely skipping the heavy C++ compiling steps and skipping the execution of pip wheel.


Results: Before vs. After

The impact of shifting from no caching (and subsequent failed disk hacks) to BuildKit’s type=gha was immediate. Below is the step-by-step performance profile tracking our production runs.

Before: Pure Build Without Cache

Every run started from scratch. Compiling our specific numerical stack was painfully slow.

Step 5/11 : RUN pip wheel –wheel-dir=/build/wheels -r requirements.txt
—> Running in a2f4b93cf258
Collecting torch==2.1.2 …
Collecting numpy==1.26.2 …
Building wheels for collected packages: TA-Lib, custom-c-extensions
Building wheel for TA-Lib (setup.py) … done
Building wheel for custom-c-extensions (setup.py) … done
Successfully built TA-Lib custom-c-extensions
Removing intermediate container a2f4b93cf258
—> a54d893f11bc

Build completed in 12m 43s.

After: Build with type=gha Cache Hit

When a cache hit occurs, BuildKit performs dependency evaluation and matches the sha256 checksums of the instructions. Instead of downloading and running compilation, it imports the layers over the fast internal network.

#10 [builder 4/4] RUN –mount=type=cache,target=/root/.cache/pip pip wheel –wheel-dir=/build/wheels -r requirements.txt
#10 sha256:d8422119283f3e1b764b85521bcfb4a8eef8cf907b8a7f14b62e49c7161b9a92
#10 CACHED

#13 [runner 3/4] COPY –from=builder /build/wheels /app/wheels
#13 sha256:87ca524a87cbfb45f43db487f232491a182fa2c8f04128f918bcde76a39d892a
#13 CACHED

#14 [runner 4/4] RUN pip install –no-index –find-links=/app/wheels /app/wheels/*.whl && rm -rf /app/wheels
#14 sha256:c2934bca8cfb452ba31fcfbc4c4e7436b1392fa2c8f0c2e391cb4823a9d31bf9a
#14 CACHED

#15 [runner 5/5] COPY src/ /app/src/
#15 sha256:f123f9876ab3452ba31fcfbc4c4e7436b1392fa2c8f0c2e391cb4823a9d31bfa2b
#15 … [building only changed application files] … done

Build completed in 1m 31s.

Phase Old Build Time (No Cache) BuildKit Cache Hit (type=gha,mode=max) Speedup
Workspace Setup 4s 5s
System dependencies (apt) 58s 2s (Cached) 29.0x
Pip Wheel Compilation 8m 12s 0s (Cached) Infinity
Image Assembly 2m 44s 11s 14.9x
Layer Sync / Export 45s 1m 13s -0.6x
Total Build Pipeline 12m 43s 1m 31s (91s) 8.4x

We see a minor time penalty during the export phase of cache hits (1m 13s) because BuildKit checks and matches layer indexes with the GitHub Actions runner cache API. This overhead is trivial compared to the nearly 11-minute savings on compiling.


Lessons Learned & Best Practices

  1. Mind the 10GB GitHub Actions Cache Limit: GitHub limits total workflow cache sizes to 10 GB per repository. If your image intermediate steps total 3 GB, you can quickly hit this limit if you keep building multiple PR branches. GitHub automatically evicts older cache blobs, but keeping images trimmed and using multi-stage builds minimizes eviction frequencies.
  2. Order Matters: Always put volatile actions (like copying your source code folder COPY src/ /app/src/) as late as possible. If you copy your source code before running a library dependency install, a simple comment change in your app code will invalidate that layer and trigger a complete reinstall of all dependencies downstream.
  3. Double Up with Pip Cache Mounts: In the Dockerfile, utilizing --mount=type=cache,target=/root/.cache/pip acts as a local fallback safety net. If a single dependency changes in requirements.txt, the cache layer is invalidated, but pip still uses its local cache directory inside the runner to avoid pulling untouched packages down from PyPI over the open internet.

Join the conversation

Your email address will not be published. Required fields are marked *