Skip to content
rusthacks.dev
DevOps

Rust Docker Builds That Recompile Everything and the Fix

Why a one-line change recompiles every crate in Docker, and the fixes in order of effort: copy order, a dummy main trick, cargo-chef, BuildKit cache mounts and a pinned base.

3 min read
Summarize in AI
Flat illustration for the post “Rust Docker Builds That Recompile Everything and the Fix”, composed around cargo.

The build is slow for one reason: each time you change a source file, Docker restarts compilation at the earliest changed layer, and the earliest changed layer was COPY . .. Everything below it, including compiling 300 dependencies, runs again. The fix is to make the dependency compile depend on files that rarely change. This post gives three versions in increasing power, and a checklist of silent cache busters. It is a companion to cargo-chef.

Docker with BuildKit, Rust 1.90.

The failing Dockerfile

Dockerfile
FROM rust:1.90-bookworm
WORKDIR /app
COPY . .
RUN cargo build --release

Any change in the build context changes the COPY layer, so cargo build runs from cold, including dependencies. Run docker build --progress=plain . twice after editing a comment and watch the cargo output scroll.

Fix 1: manifests first, stub main

Dockerfile
FROM rust:1.90-bookworm
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
    && cargo build --release --locked \
    && rm -rf src target/release/deps/myapp* target/release/myapp
COPY src ./src
RUN cargo build --release --locked

The first cargo build compiles all dependencies against an empty main. That layer's inputs are the two manifests. After copying the real source, the second build only compiles your crate. Removing deps/myapp* forces Cargo to rebuild the real crate rather than think the stub is current. This works for one crate; in a workspace you must stub every member, which is where it gets brittle.

Fix 2: cargo-chef

Generates the recipe for you, handles workspaces and several binaries. The full Dockerfile is in cargo-chef and layer caching.

Fix 3: cache mounts

Dockerfile
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    --mount=type=cache,target=/usr/local/cargo/git \
    --mount=type=cache,target=/app/target \
    cargo build --release --locked && cp target/release/myapp /usr/local/bin/myapp

BuildKit stores those directories outside the image layers and reuses them. Even your own crate benefits from incremental data. Limits: the cache lives on the builder, so it does not persist on throwaway CI runners without a cache backend, and target is not in the image, hence the cp.

Silent cache busters

  1. **No .dockerignore.** Copying .git or target into the context makes every commit invalidate layers. Add target, .git and node_modules.
  2. **ARG with a changing value** above the cargo step, such as a build timestamp or commit SHA. Put it after the compile, in a later layer.
  3. Unpinned base image tags. rust:latest moves, and every move rebuilds everything. Pin rust:1.90-bookworm.
  4. Different flags. --release in one stage and not another, or RUSTFLAGS differences, produce separate artifacts.
  5. **Copying Cargo.lock after the source**, or leaving it out of COPY.

Check that it works

Build, change one line in src/, build again, and read the plain progress output. The dependency layer should say CACHED. If it recompiles, find the first layer not marked cached; the line above it names what changed.

Limits

A dependency update still recompiles the changed crates and everything depending on them. For local development, you may prefer mounting the source into a container and running cargo there, which skips image builds entirely. The Docker documentation explains the rules in build cache invalidation.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.