Dockerfile Changes That Cut Build Time
Most Dockerfile build time on GitHub Actions is rebuilt work. Order instructions by change frequency, add BuildKit cache mounts, keep the cache on a builder.
Last verified:
Most of the time a Dockerfile spends on GitHub Actions is spent rebuilding instructions whose cache key changed, usually because a source copy sits above the dependency install and invalidates everything below it. Two edits fix the common case: order instructions by how often their inputs change, and move package manager download directories into BuildKit cache mounts so a dependency change replays from local files instead of the network.
Both edits only pay off when the layer cache and the mounts outlive the job. Per-minute rates for job runners and builder profiles are on the pricing page.
This guide covers how to find the instruction that invalidates your cache, a before and after Dockerfile with the layers that survive a source change, the cache mount syntax per ecosystem, and a time and cost model with the builder rate applied. For the wider picture, start at Docker builds on GitHub Actions.
Diagnosis
Run the build once with plain progress output and read it from the top:
docker buildx build --progress=plain -t api:local .BuildKit prints #8 CACHED for a reused instruction and #8 DONE 12.3s for one it executed. The first line without CACHED is the instruction that broke the chain, and every instruction after it in the same stage rebuilt because of it. Chasing the slowest step is the wrong move when a cheap step above it is the one that missed.
Three shapes account for most of what you will find.
A source copy above the dependency install. COPY . . hashes the whole build context, so any edited file changes its cache key and the install below it reruns. This is the single most common cause and the one the next section fixes.
A build context that carries files the image never uses. Look for a transferring context: 412.55MB line near the start of the output. Without a .dockerignore, .git, virtualenvs, node_modules, and previous build output all enter the context, so they cost transfer time and they change the context hash on runs where nothing relevant moved.
A package manager that redownloads on every dependency change. Even with correct ordering, one added library invalidates the dependency layer and the installer refetches every package from the network. Cache mounts are what stop that, and they are covered under Configuration.
A fourth cause sits outside the Dockerfile: a fresh buildx instance per job starts with an empty layer store, so a correctly ordered Dockerfile still rebuilds from zero on every run. That is a builder problem rather than a file problem.
Fix
Order instructions by how often their inputs change, least often first. System packages change monthly, dependency manifests change weekly, source changes on every commit, so they belong in that order top to bottom.
Before, with the source copy at the top:
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y build-essential
RUN pip install -r requirements.txt
RUN python -m compileall app
CMD ["gunicorn", "app.main:app"]After, with the copy split in two and cache mounts added:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
rm -f /etc/apt/apt.conf.d/docker-clean && \
apt-get update && \
apt-get install -y --no-install-recommends build-essential
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
RUN python -m compileall app
CMD ["gunicorn", "app.main:app"]Here is what each instruction does on a commit that edits one Python file:
| Instruction | Before layout | After layout | Time returned |
|---|---|---|---|
apt-get install build-essential | rebuilds | CACHED | 0:55 |
COPY requirements.txt | part of COPY . . | CACHED | 0:01 |
pip install -r requirements.txt | rebuilds | CACHED | 2:20 |
COPY . . | rebuilds | rebuilds | 0:00 |
python -m compileall app | rebuilds | rebuilds | 0:00 |
| Export and push changed layers | full set | changed layers only | 0:55 |
The .dockerignore belongs with the change, since COPY . . is only as stable as the context it reads:
.git
.venv
__pycache__
*.pyc
node_modules
distApply the same split to every ecosystem: package.json and the lockfile before a Node tree, go.mod and go.sum before a Go tree, Cargo.toml and Cargo.lock before a Rust tree, pom.xml before a Java tree. When the ordering is right and the image is still large or still slow to push, the next levers are stage layout, covered in multi stage Docker builds and layer reuse, and the size of what you ship, covered in container image size reduction.
Configuration
Cache mounts by package manager
A cache mount is attached for the duration of one RUN instruction and is never committed into the layer, so it speeds up the rebuild without adding image size. Use sharing=locked where the tool holds no lock of its own.
| Ecosystem | Mount target | What the mount keeps between builds |
|---|---|---|
| npm | /root/.npm | Downloaded package tarballs, keyed by version and integrity hash |
| pnpm | /root/.local/share/pnpm/store | The content-addressable store that every project links against |
| pip | /root/.cache/pip | Downloaded and locally built wheels |
| uv | /root/.cache/uv | Resolved wheels and the source distributions built from them |
| Go | /root/.cache/go-build and /go/pkg/mod | Compiled package archives and downloaded modules |
| Cargo | /usr/local/cargo/registry and /app/target | Crate sources and compiled artifacts for unchanged crates |
| Maven | /root/.m2/repository | Resolved jars and their metadata |
| Gradle | /root/.gradle/caches | Dependency jars and the build cache entries |
| apt | /var/cache/apt and /var/lib/apt/lists | Downloaded .deb files and package index lists |
The # syntax=docker/dockerfile:1.7 directive at the top of the file pins the frontend that parses these mounts, which keeps behavior stable across builder versions.
Keeping the mounts between runs
Cache mounts live on the builder disk, so they disappear with a per-job buildx instance and they are not carried by registry or GitHub Actions cache exports. Point the build at a builder profile instead, which is one dedicated build VM with persistent layer cache and mount storage, selected by name:
name: docker
on:
pull_request:
jobs:
build:
runs-on: warp-ubuntu-latest-x64-2x
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: Warpbuilds/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/acme/api:${{ github.sha }}
profile-name: api-amd64The job runner stays small because the build runs elsewhere: checkout, registry login, and wait. Pick a Linux runner here, since macOS runners do not support nested virtualization and cannot run Docker. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the product surface.
Builder profile inputs, session billing, and cache reset are documented in the remote Docker builders documentation. Dependency caching outside the Docker build uses a separate action, covered in the caching documentation. Builder sizes and disks are listed on remote Docker builders.
Cost or Time Model
The model is arithmetic on stated assumptions rather than a measurement. Replace each row with the durations printed in your own build log before you act on the result.
Assumptions
- The Python service above:
python:3.12-slimbase, 180 packages inrequirements.txt, roughly 900 MB final image. - 400 image builds per month: 10 with a base image bump (cold builder), 40 with a
requirements.txtchange, 350 that touch source only. - 0.5 minutes of job overhead per build for checkout, registry login, and action setup.
- Pull request builds arrive in bursts, four to a builder session, staggered by 0.5 minutes.
- Job runner
warp-ubuntu-latest-x64-2xat $0.004 per minute and a 16 vCPU, 32 GB, 100 GB disk builder profile at $0.06 per minute, both from the pricing page.
Three cache states on the reordered Dockerfile
| Step | Cold builder | requirements.txt change | Source change |
|---|---|---|---|
Pull python:3.12-slim | 0:20 | 0:00 | 0:00 |
apt-get install build-essential | 0:55 | 0:00 | 0:00 |
COPY requirements.txt | 0:01 | 0:01 | 0:00 |
pip install -r requirements.txt | 2:20 | 0:50 | 0:00 |
COPY . . | 0:04 | 0:04 | 0:04 |
python -m compileall app | 0:35 | 0:35 | 0:35 |
| Export and push changed layers | 1:20 | 0:50 | 0:25 |
| Total | 5:35 | 2:20 | 1:04 |
The middle column is what the cache mounts buy. The dependency layer is invalid, so pip install runs, and it resolves from wheels already on the builder disk rather than from the network: 0:50 instead of 2:20.
With the before layout on the same builder, COPY . . invalidates everything under it, so every build runs apt, runs pip install over the network, and exports the full layer set: 5:14 per build whatever changed.
Monthly cost, 400 builds
| Layout | Builder minutes | Builder cost | Job minutes | Job cost | Total |
|---|---|---|---|---|---|
| Before: source copy on top, no cache mounts | 854.2 at $0.06 | $51.25 | 2,296.7 at $0.004 | $9.19 | $60.44 |
| After: ordered instructions plus cache mounts | 373.7 at $0.06 | $22.42 | 722.5 at $0.004 | $2.89 | $25.31 |
The wall clock a pull request waits moves from 5.73 minutes to 1.57 minutes on these numbers, and the monthly bill on the same 400 builds moves by $35.13. Two levers move it further: more builds per session, since concurrent jobs on one profile are billed as a single session from the first job's start to the last job's completion, and a larger profile disk when several images share a profile and eviction starts costing hit rate.
The job runner itself carries a list price edge on top of the builder savings: warp-ubuntu-latest-x64-2x costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest, 33 percent lower list price, from the GitHub Actions minute multipliers reference, checked on 2026-08-13.
FAQ
Does RUN --mount=type=cache make the image bigger?
No. A cache mount is attached for the duration of the RUN instruction and is never committed into the resulting layer, so the package manager download directory adds nothing to image size. The tradeoff is that the mount lives on the builder rather than in the image, so it only survives when the builder survives.
Why does my apt cache mount stay empty on Debian and Ubuntu images?
Those base images ship /etc/apt/apt.conf.d/docker-clean, which deletes downloaded .deb files after every install and sets Keep-Downloaded-Packages to false. Remove that file or write a Keep-Downloaded-Packages true config inside the same RUN instruction before apt-get update, otherwise the mount is emptied before the build ends.
Do cache mounts survive between GitHub Actions runs?
Only when the build runs on a builder that outlives the job. A buildx instance created inside a job is destroyed with the job, and registry or GitHub Actions cache exports carry layer blobs rather than cache mount contents, so the next run starts with an empty mount.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.