A one-line change to a Go service triggers go: downloading github.com/aws/aws-sdk-go-v2 v1.30.3 and two hundred more lines before compilation starts, and a Rust service prints Updating crates.io index followed by five minutes of Compiling for dependencies that have not changed in months. Both ecosystems cache aggressively on a laptop, but a naive Dockerfile throws those caches away on every build. This page restructures Go and Rust Dockerfiles so dependency downloads and compilation are reused across builds, as part of Docker build cache optimization.

The techniques are the same two used for Node and Python elsewhere on this site — order layers so dependencies are resolved before source is copied, and mount persistent caches for package managers — applied to Go's module cache and Cargo's registry and target directories.

Diagnostic

Time a rebuild after touching one source file and see which steps ran:

#!/usr/bin/env bash
set -euo pipefail
docker build -t svc:before . >/dev/null
echo "// touch" >> cmd/server/main.go
start=$(date +%s)
docker build --progress=plain -t svc:before . 2>&1 | grep -E 'CACHED|go: downloading|Compiling|Updating crates' | sort | uniq -c | sort -rn | head -6
echo "rebuild: $(( $(date +%s) - start ))s"
git checkout -- cmd/server/main.go
grep -nE '^(COPY|RUN)' Dockerfile

Expected bad output for a Go service:

    214 go: downloading
      2 CACHED
rebuild: 96s
3:COPY . .
4:RUN go build -o /out/server ./cmd/server

Every module is downloaded again because COPY . . precedes the build, so any source change invalidates the layer that fetches dependencies.

Go Service Rebuild After a One-Line Change Bar chart comparing rebuild time for three Dockerfile structures. Go Service Rebuild After a One-Line Change COPY . . then build 96 s go mod download layer 31 s plus cache mounts 7 s
Measured on a service with 214 modules; the cache mount also reuses compiled packages.

Root cause

Go and Cargo separate dependency resolution (downloading modules or crates) from compilation, and both keep caches on disk — GOMODCACHE and GOCACHE for Go, ~/.cargo/registry and target/ for Rust. In a Dockerfile, those caches live in the image layer where the build ran, and a layer is reused only if everything before it is unchanged. With COPY . . before the build, every source edit changes the copied files, so the layer that downloads and compiles is rebuilt from scratch with empty caches. Ordering alone helps with downloads: copying only go.mod/go.sum or Cargo.toml/Cargo.lock first lets the download step be cached until dependencies change. But compilation caches cannot be split out by ordering — they depend on the source — which is where BuildKit cache mounts come in: a mount persists a directory across builds without baking it into any layer.

Resolution

  1. Go: download in a layer keyed on go.mod and go.sum, and mount the module and build caches:
# syntax=docker/dockerfile:1.7
FROM golang:1.23-bookworm AS build
WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-mod=readonly
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -trimpath -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]

/go/pkg/mod is GOMODCACHE in the official image and /root/.cache/go-build is GOCACHE; mounting both means changed packages recompile while everything else is reused.

  1. Rust: build dependencies separately with cargo-chef, and mount the registry and target caches:
# syntax=docker/dockerfile:1.7
FROM rust:1.81-bookworm AS chef
RUN cargo install cargo-chef --locked --version 0.1.67
WORKDIR /src

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS build
COPY --from=planner /src/recipe.json recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/src/target \
    cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/src/target \
    cargo build --release --locked && cp target/release/server /out-server

FROM debian:bookworm-slim
COPY --from=build /out-server /usr/local/bin/server
CMD ["server"]

cargo chef prepare produces a recipe that changes only when dependencies change, so the cook layer — which compiles all dependencies — is cached across source edits. The binary is copied out of the mounted target directory because cache mounts are not part of the image.

  1. Share caches between laptop and CI by exporting the layer cache to a registry; cache mounts stay local, but the dependency layers travel:
#!/usr/bin/env bash
set -euo pipefail
docker buildx build --cache-from type=registry,ref=ghcr.io/acme/svc:buildcache \
  --cache-to type=registry,ref=ghcr.io/acme/svc:buildcache,mode=max -t svc:dev --load .
Layer Order for Dependency Caching Ordered Dockerfile steps that keep dependency downloads and compilation cached across source changes. Layer Order for Dependency Caching 1 — copy dependency manifests only 2 — download or cook dependencies cached layer 3 — copy source 4 — compile with cache mounts incremental 5 — copy binary to slim runtime
Source is copied last, so only the final compile step reruns for a code change.

Expected output

$ echo "// touch" >> cmd/server/main.go && docker build --progress=plain -t svc:after . 2>&1 | grep -E 'CACHED|go: downloading' | sort | uniq -c
      4 CACHED
$ echo "rebuild: 7s"
rebuild: 7s

No modules are downloaded, the dependency layer is cached, and the compile step reuses the build cache so only the changed package recompiles. For the Rust service, the cargo chef cook layer shows CACHED and only the application crate compiles.

The first build after this change is not faster — it still downloads and compiles everything, and it populates the caches. The benefit appears from the second build onward, which is why the before-and-after comparison must use a warm builder. In CI, where builders are often fresh, the registry cache export from step three matters more than the cache mounts, since it is what carries the dependency layers from one run to the next.

Prevention

  1. Keep go.sum and Cargo.lock committed and use -mod=readonly and --locked, so the dependency layers are keyed on exact versions and builds fail rather than resolve something new.

  2. Watch the rebuild time in CI for a trivial change on a warm builder; a jump usually means someone moved a COPY . . above the dependency step.

  3. Prune cache mounts occasionally. docker builder prune --filter type=exec.cachemount clears them if they grow large; they rebuild on the next build.

Go and Rust Cache Locations Comparison of the directories to cache for Go and Rust builds in Docker. Go and Rust Cache Locations Go Rust go.mod and go.sum first Cargo.toml, Cargo.lock first /go/pkg/mod mount cargo registry mount /root/.cache/go-build mount target/ mount go mod download layer cargo chef cook layer
Mount the download cache and the compile cache; order layers so manifests come first.

Platform caveats

Apple Silicon (ARM64): caches are per platform. Building linux/amd64 on an M-series Mac uses separate cache entries and runs the compiler under emulation, which is much slower; for local work build the native platform, and cross-compile with GOARCH or a Rust target instead of emulating when you need amd64.

macOS (Docker Desktop): cache mounts live inside the Docker VM's disk, not on the host; they persist across builds but are cleared by docker builder prune and count against the VM disk size.

CI runners: ephemeral runners start with empty cache mounts. Rely on registry layer caches for dependency layers and accept a cold compile cache, or use a persistent builder.

Rollback

Revert the Dockerfile; builds return to the previous behaviour without any other cleanup:

#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- Dockerfile
docker builder prune --filter type=exec.cachemount --force
docker build -t svc:dev .

Frequently Asked Questions

Why does Go download every module on each build?

Because COPY . . comes before go mod download or go build, so any source change invalidates the layer that holds the module cache. Copy go.mod and go.sum first, download, then copy source, and mount /go/pkg/mod as a cache.

What does cargo-chef do?

It extracts a recipe of your dependencies from the manifests, so a Docker layer can compile only the dependencies. That layer is reused until dependencies change, even when the application source changes.

Why copy the Rust binary out of target/?

Because target/ is a cache mount, and mounts are not included in the image layer. Copying the binary to another path in the same RUN step puts it in the layer so a later stage can use it.

Do cache mounts work in CI?

Only on persistent builders. On ephemeral runners, the mount starts empty each run; use registry or GitHub Actions layer caches to carry dependency layers between runs instead.