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:

InstructionBefore layoutAfter layoutTime returned
apt-get install build-essentialrebuildsCACHED0:55
COPY requirements.txtpart of COPY . .CACHED0:01
pip install -r requirements.txtrebuildsCACHED2:20
COPY . .rebuildsrebuilds0:00
python -m compileall apprebuildsrebuilds0:00
Export and push changed layersfull setchanged layers only0:55

The .dockerignore belongs with the change, since COPY . . is only as stable as the context it reads:

.git
.venv
__pycache__
*.pyc
node_modules
dist

Apply 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.

EcosystemMount targetWhat the mount keeps between builds
npm/root/.npmDownloaded package tarballs, keyed by version and integrity hash
pnpm/root/.local/share/pnpm/storeThe content-addressable store that every project links against
pip/root/.cache/pipDownloaded and locally built wheels
uv/root/.cache/uvResolved wheels and the source distributions built from them
Go/root/.cache/go-build and /go/pkg/modCompiled package archives and downloaded modules
Cargo/usr/local/cargo/registry and /app/targetCrate sources and compiled artifacts for unchanged crates
Maven/root/.m2/repositoryResolved jars and their metadata
Gradle/root/.gradle/cachesDependency jars and the build cache entries
apt/var/cache/apt and /var/lib/apt/listsDownloaded .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-amd64

The 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-slim base, 180 packages in requirements.txt, roughly 900 MB final image.
  • 400 image builds per month: 10 with a base image bump (cold builder), 40 with a requirements.txt change, 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-2x at $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

StepCold builderrequirements.txt changeSource change
Pull python:3.12-slim0:200:000:00
apt-get install build-essential0:550:000:00
COPY requirements.txt0:010:010:00
pip install -r requirements.txt2:200:500:00
COPY . .0:040:040:04
python -m compileall app0:350:350:35
Export and push changed layers1:200:500:25
Total5:352:201: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

LayoutBuilder minutesBuilder costJob minutesJob costTotal
Before: source copy on top, no cache mounts854.2 at $0.06$51.252,296.7 at $0.004$9.19$60.44
After: ordered instructions plus cache mounts373.7 at $0.06$22.42722.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.