Your Dockerfile rebuilds everything
If every build re-downloads your dependencies, the cause is nearly always one line in the wrong place.
Docker caches per instruction. An instruction's cache entry is valid only if the instruction and all its inputs are unchanged and every instruction before it also hit. So the first line that changes invalidates everything below it, forever downward. Order is not style here; it is the whole mechanism.
The broken version:
FROM golang:1.23-alpine
WORKDIR /src
COPY . .
RUN go mod download
RUN go build -o /out/app .
COPY . . changes whenever any file changes. Edit one line of a handler and go mod download re-runs from scratch.
The fix is to copy the dependency manifest on its own, resolve dependencies, and only then copy source:
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .
Now go mod download is invalidated only when go.mod or go.sum changes, which is what you meant. The same shape applies everywhere: package.json before npm ci, Cargo.toml before a dependency build, requirements.txt before pip install.
.dockerignore is part of the cache
Without one, COPY . . includes .git. Every commit changes .git, so the copy's hash changes on every commit even when no source file did — and you have also shipped your entire history into the build context.
.git
.gitignore
Dockerfile
README.md
*.md
.env
Excluding .env matters for a second reason: anything in the build context can end up in a layer, and a layer is not deleted by a later RUN rm. Deleting a secret in a subsequent instruction leaves it in the earlier layer, readable by anyone who pulls the image.
Multi-stage is not only about size
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
The final image contains one file. The Go toolchain, the module cache, the source, and your .git if you were careless — none of it is in the shipped artefact, because only what you explicitly COPY --from crosses the stage boundary.
That is a security property as much as a size one. A compiler and a package manager in a production image are useful to an attacker and useless to you.
Verify it rather than believing it
docker build -t app . && docker build -t app .
The second build should be entirely CACHED up to the first line that genuinely changed. If it is not, read the output top to bottom and find the earliest non-cached step — everything below it is collateral, so there is exactly one thing to fix.
Two things reliably surprise people here:
- Timestamps do not matter, content does. Docker hashes file contents and metadata, not modification times, so a
touchalone will not bust the cache. ARGbusts everything after it. A build argument that changes per build — a git SHA, a build timestamp — invalidates every instruction from its first use downward. Declare it as late as possible, ideally in the final stage only.
Cache misses are cheap to diagnose and expensive to ignore. A build that takes four minutes instead of fifteen seconds is not just slow; it is a build you stop running before you push.