Caching Go and Rust Dependency Downloads in Docker
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.
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
- Go: download in a layer keyed on
go.modandgo.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 \
go mod download
COPY . .
RUN \
go build -trimpath -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY /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.
- 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 /src/recipe.json recipe.json
RUN \
cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN \
cargo build --release --locked && cp target/release/server /out-server
FROM debian:bookworm-slim
COPY /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.
- 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 .
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
Keep
go.sumandCargo.lockcommitted and use-mod=readonlyand--locked, so the dependency layers are keyed on exact versions and builds fail rather than resolve something new.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.Prune cache mounts occasionally.
docker builder prune --filter type=exec.cachemountclears them if they grow large; they rebuild on the next build.
Platform caveats
Apple Silicon (ARM64): caches are per platform. Building
linux/amd64on 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 withGOARCHor 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 pruneand 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.