The starting point#
Every client site deployed through the same managed pipeline. Each build installed its dependencies from scratch, compiled, and pushed a fresh image, so a push to one site took around 15 minutes and held up the queue behind it. When a build failed, the logs were somewhere we couldn't easily get to.
None of that is unusual. It just adds up: a few deploys a day across [N] sites is a lot of people waiting.
One base image for every site#
Most of each build was installing the same packages. The sites share a framework, a UI package and most of their tooling, so the first change was a base image that holds all of it. Each site now starts from that image and only installs and builds what is actually its own.
Pin the base image by digest, not by tag. Otherwise a rebuilt base can quietly change every site at once.
# Shared deps live in the base; the site adds only its ownFROM ghcr.io/umi/base:node22@sha256:… AS depsWORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN pnpm install --frozen-lockfile --offline
FROM deps AS buildCOPY . .RUN pnpm buildCaching in CI#
The second half was making sure CI never repeats work it has already done.
Dependency cache#
The pnpm store is cached and keyed on the lockfile hash, so an unchanged lockfile means no network installs at all.
Layer cache#
Docker's build cache is exported between runs, so most pushes only rebuild the last couple of layers.
- Only sites whose files changed rebuild on push
- Images are tagged with the commit SHA, so a rollback is a redeploy of the previous tag
- Failed builds post to Slack with a link straight to the log
What it cost#
About [N] days of work spread over [N] weeks, plus servers we now look after ourselves. The trade is that we own the boring parts: updates, backups, monitoring. Those are written up as runbooks so they don't depend on me.
What I'd do differently#
Start with the cache, not the base image. Caching alone would have won back much of the time with a fraction of the change, and made the case for the rest.