Docker build caching in GitHub Actions: from 12-minute builds to 90 seconds
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:
– 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: 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:
– 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.
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.
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.
—> 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 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
- 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.
- 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. - Double Up with Pip Cache Mounts: In the Dockerfile, utilizing
--mount=type=cache,target=/root/.cache/pipacts as a local fallback safety net. If a single dependency changes inrequirements.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.