Docker cache is my new best friend


Oh boy nobody told me build times could be this fast.

My main gripe with Docker is that it’s so bad at caching. Yes yes yes, snip it, I know that Docker caches the build layers. And I know about tool-specific optimizations like caching APT in Docker builds. But every build tool (i.e not just package managers) has their own level of caching, and hosting cache proxies for everything sucks. But most imporantly, I want build cache! Then as soon as you do make builds via Docker you lose all that!

Hah! not so much more many much more moo thank thanks tha the to Docker cache mounts (and yes Podman supports it too):

FROM golang:1.23
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build

I ran docker build with that Dockerfile on my dinkur project. First run took 24 seconds on the RUN step. I then added an empty line to the main.go to invalidate the cache in COPY. Then the second run took 1.5 seconds smirk emoji

How it works

Simply put:

  • --mount=type=cache creates a new Docker volume, identified by the target=... field.
  • As long as target=... field is the same, then the cache will be reused.
  • Alternatively, you can add id=..., which defaults to the same value as target=...
  • It does not care about the path of the Dockerfile. Only the ID.
  • Skip cache with docker build --no-cache
  • Clear cache with docker buildx prune
  • The cache is stored in the same place as other Docker image layers/caches. I.e /var/lib/docker/overlay2/...

Go build cache

Above was a minimal example. This is how you want a proper Go Dockerfile to look like:

FROM golang:1.23 AS build
WORKDIR /go/src/app

# First cache dependencies
# This allows distributed caches to still work
# (such as Docker builds in GitHub Actions)
# as they still primarily only cache by Docker image layers.
COPY go.mod go.sum .
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

# Build the rest of the files
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go install

FROM cgr.dev/chainguard/static
COPY --from=build /go/bin/my-app /bin/my-app
ENTRYPOINT ["/bin/my-app"]

Sources:

Debian/Ubuntu apt dependency cache

Apt and apt-get does not support multiple processes to run at the same time. It requires some locking to make sure only 1 instance touches the cache at a time.

Luckily, Docker thought of that and added sharing=locked:

FROM ubuntu:latest
RUN --mount=target=/var/lib/apt/lists,type=cache,sharing=locked \
    --mount=target=/var/cache/apt,type=cache,sharing=locked \
    rm -f /etc/apt/apt.conf.d/docker-clean \
    && apt-get update \
    && apt-get -y --no-install-recommends install ruby ruby-dev gcc

Successive runs will still need to install the packages. So it’s not a huge big time win. However it is a nice network win as you don’t need to pull the packages again.

Source: https://stackoverflow.com/a/72851168

Node/NPM/JavaScript

Because NPM also removes packages when doing npm install/npm ci, then you want to tell NPM that this is only a cache and not the resulting node_modules directory.

FROM node

WORKDIR /usr/src/app
COPY package.json package-lock.json .
RUN --mount=type=cache,target=/usr/src/app/.npm \
    npm set cache /usr/src/app/.npm \
    && npm ci

This means you can reuse the cache across projects without messing up your cache.

For Deno, Bun, pnpm, Yarn, and others alike you might need some more digging in how to use it properly.

Source: https://stackoverflow.com/a/64192857


That’s the stuff I currently have a use-case for. But it’s probably easy enough to extrapolate how to use this in other tools like Java or .NET builds for build and package cache.