Okay, confession time: until about eight months ago, my Dockerfiles were embarrassing. The kind of thing I’d write, ship to staging, and quietly pray nobody from the platform team would cat them. They worked. They were also bloated, slow to build, and running as root.
What changed wasn’t a new tool. It was a long Tuesday afternoon where a colleague pulled up one of my images, ran docker history on it, and asked, very politely, why a Node.js API needed 1.4 GB of base image and a full apt cache. I had no answer. I went home and read the Docker BuildKit docs that night. This post is what I should have done years ago.
If you ship containers and you’ve been copy-pasting the same Dockerfile shape since 2021, this is for you. None of this is exotic. It’s the stuff I should have been doing the whole time.
Picking the right base image (stop reaching for ubuntu:latest)
The first habit I killed was using full distro base images out of inertia. ubuntu:latest is over 75 MB compressed before you install anything. Add Node, your app, npm cache leftovers, and you’re looking at 1 GB+ images for what is essentially a 50-line HTTP server.
What I do now: start from the smallest base that actually works for the runtime. For Node, that’s node:22-alpine (about 50 MB) or, if I want glibc, node:22-slim. For Go binaries, gcr.io/distroless/static-debian12 is around 2 MB. For Python, python:3.13-slim.
Here’s the before:
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y nodejs npm
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
After:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
That swap alone took one of my images from 1.2 GB to about 180 MB. The Docker docs have a good page on choosing a base image that I wish I’d read sooner.
One caveat I had to learn the hard way: Alpine uses musl, not glibc. If your app pulls in a native module compiled against glibc (looking at you, certain Node native deps), you’ll get cryptic runtime errors. When that bites, fall back to -slim rather than fighting it.
Multi-stage builds for anything compiled
The second habit was shipping build tools into production. If you have a step like npm install, cargo build, or go build in the same stage as your final image, you’re paying for compilers and source files at runtime forever.
Multi-stage builds fix this in maybe ten extra lines. Build everything in a fat builder stage, copy the artifact into a clean final stage, ship the final stage. Here’s a Go example I use:
# Builder
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/app ./cmd/api
# Runtime
FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
The final image is about 8 MB. No shell, no package manager, no compiler. That last bit also matters for security, not only size. If there’s no sh, an attacker who pops your container can’t drop into a shell. The distroless project is run by Google and is what I reach for whenever I’m shipping a compiled binary.
I held off on distroless for a while because I was scared of debugging. The trick: there’s a :debug variant of each distroless image that includes a busybox shell. Use it for triage, then switch back.
Cache mounts and .dockerignore (the boring CI wins)
Once builds got smaller, I noticed they were still slow on CI. The culprit was usually the same: I was copying my entire repo into the build context, which busted layer caching every time a README changed.
Two fixes, both boring, both huge.
First, write a real .dockerignore. Mine for a typical Node project now looks like:
node_modules
.git
.env
.env.*
*.log
coverage/
.next/cache
.vscode
.idea
README.md
That alone shaved 40 seconds off my cold builds because Docker stopped uploading my local node_modules into the context.
Second, use BuildKit cache mounts for package managers. This is the single feature I wish I’d known about two years earlier:
# syntax=docker/dockerfile:1.7
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
The --mount=type=cache line tells BuildKit to keep npm’s cache between builds, even across CI runners that share the cache backend. My CI install step went from 90 seconds to about 12. There’s a BuildKit cache optimisation page that covers this and the related --mount=type=bind for read-only sources.
Stop running as root
This was the habit I felt worst about once I noticed it. By default, processes in a container run as root. If your app gets popped, the attacker has root inside the container, which is not as bad as root on the host but still much worse than necessary.
Switch to a non-root user. Most official images already give you one:
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]
The node user ships in the Node Alpine images. For your own images, add a user explicitly:
RUN addgroup -S app && adduser -S app -G app
USER app
This is a one-line change that closes a real security gap, and I cannot defend why I waited so long to make it standard. The same rule I follow when I’m picking infrastructure defaults in my work on backend systems applies here: the secure default beats the convenient one almost every time.
Pin versions, pin digests for things you care about
FROM node:latest is convenient and wrong. Latest tags drift. A reproducible build means the same Dockerfile produces the same image six months from now.
At minimum, pin to a major version:
FROM node:22-alpine
For anything sensitive, pin the image digest:
FROM node:22-alpine@sha256:abc123...
Yes, it’s ugly. Yes, it means you have to update digests when you bump versions. Renovate and Dependabot both handle this automatically now. The payoff is that a CVE landing in a base image doesn’t silently make its way into your prod build on the next deploy.
CMD vs ENTRYPOINT (and why I now mostly use ENTRYPOINT)
For a long time I used CMD for everything and never thought about it. Then a colleague ran docker run myimage --debug and was surprised when --debug replaced the whole command. That’s because CMD is the default command and gets clobbered by anything you pass to docker run.
ENTRYPOINT is the binary you always want to run. CMD is the default arguments. If you write:
ENTRYPOINT ["node", "server.js"]
CMD ["--port=3000"]
then docker run myimage runs node server.js --port=3000, and docker run myimage --port=4000 runs node server.js --port=4000. That’s almost always what I actually want. There’s a decent summary in the official Dockerfile reference if you want the full interaction table.
Healthchecks that actually mean something
I used to skip healthchecks because they felt like ceremony. Then I had an outage where my container was running but the app was deadlocked on a database connection, and the orchestrator happily kept routing traffic to it. A HEALTHCHECK would have caught it.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget --quiet --tries=1 --spider http://localhost:3000/healthz || exit 1
The trick is that the healthcheck endpoint should actually exercise the thing you care about. A /healthz that returns 200 OK whether the database is connected or not is lying to your orchestrator. Mine does a cheap query against the primary connection pool and a Redis ping.
What’s still on my list
I’m not done. Two things I’m working on next.
First, moving more of my Compose files toward production-grade defaults. I covered some of this thinking in my post on when I finally switched from pgbouncer to pgcat, and the same instinct applies: stop trusting defaults that were written for the easy case.
Second, image signing with cosign. I have it set up on one project and it works fine, but I haven’t made it standard yet. That’s the next habit to add, not kill.
What to do this week
Pick one image you maintain. Run:
docker history <image>
docker images <image> --format "{{.Size}}"
If the image is over 200 MB for a typical web app, switch to a smaller base. If the final stage contains compilers, add a multi-stage split. If your CI install step takes longer than 30 seconds on a warm cache, add a --mount=type=cache. Pick one. Ship it Friday. That’s how I worked through the list above, and it’s the only way I’d recommend to anyone else.