Why Dockerfiles go wrong
Most Dockerfiles begin life as a copy from a tutorial. They install the compiler, build the app, run tests, and ship all of it. They run as root because that was the default. They copy the entire repository before installing dependencies, so every code change reinstalls every package. Nobody notices until builds take twenty minutes, an image scan returns hundreds of findings from tools that never needed to be in production, or a node autoscales and spends a minute pulling a bloated image.
This work is part of our Docker containerisation service. The goal is not clever Dockerfiles. It is boring, predictable ones that any engineer on your team can read and change safely.
What a good production Dockerfile looks like
- Two or more stages: one to build, one to run.
- A small base image, pinned to a specific version (and ideally a digest), chosen deliberately.
- Dependency manifests copied and installed before application source, so the expensive layer is cached.
- A non-root user set with
USER. - No secrets in any layer, no build tools in the final stage, no package manager caches left behind.
- An
ENTRYPOINTorCMDin exec form so signals reach the process and shutdowns are clean.
A worked example
This is a typical Node.js service after rework. The pattern is the same for Go, Java, Python or .NET: only the commands change.
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Three details matter here. The deps stage only changes when the lockfile changes, so source edits reuse the cached install. The cache mount speeds up installs without writing the cache into a layer. And the runtime stage contains no source, no dev dependencies and no build tooling.
For compiled languages the gain is larger. A Go service can be built in a full toolchain stage and copied into a distroless or even empty scratch image as a static binary. The trade-off is debuggability, which we address below.
Choosing a base image
| Base | Good for | Watch out for |
|---|---|---|
| Debian slim | Safe default for most languages | Larger than the alternatives |
| Alpine | Small images, static or simple apps | musl libc can break native modules and some Python wheels |
| Distroless | Strong security posture, static binaries, JVM | No shell, so debugging needs ephemeral containers or a debug variant |
| scratch | Static Go or Rust binaries | No CA certificates or timezone data unless you copy them in |
We do not chase the smallest possible image. We choose the smallest image your team can still operate at 3am. A distroless image that nobody knows how to debug is a worse outcome than a slim image everyone understands. The wider policy on versions and updates is covered in base image standardisation.
Targeting stages for tests and debugging
Stages are also useful beyond the final image. Because each FROM can be named, you can build up to a chosen stage with docker build --target test . and run unit tests inside the exact toolchain image, without those tests or their dependencies ever reaching the runtime stage. The same trick gives a dev stage with extra tooling for local work. One Dockerfile then serves tests, development and production, and the production stages stay the source of truth.
Decisions and trade-offs
Pin by tag or by digest? Tags such as 22-bookworm-slim move as patches are released, which is good for security and bad for reproducibility. Digests are exact, but someone has to bump them. We usually pin to a tag in the Dockerfile and let a tool such as Renovate propose digest updates through pull requests.
One Dockerfile or many? One per deployable service, with shared base images. Giant monorepo Dockerfiles that build everything are slow and hide ownership.
Build in Docker or build outside and copy? Building inside the container gives you reproducibility. Building outside can be faster when you already have a strong CI cache. We prefer inside, unless there is a measured reason.
When not to optimise. If you run three internal tools that deploy twice a year, do not spend a sprint on this. The investment pays back when you deploy often, scale on demand, or must satisfy a security review.
Common failure modes we avoid
- Secrets baked into layers. An
ARGtoken or a copied.envfile stays in image history. We use BuildKit secret mounts instead. - Shell-form
CMD. The process runs undersh -cand never receivesSIGTERM, so rolling deploys hang until they are killed. - Unpinned
latest. Two builds a week apart produce different images from the same commit. - Missing certificates or timezone data after moving to a minimal image. We test outbound HTTPS in the image before sign-off.
- Root-owned writable directories that break after dropping privileges. We set ownership explicitly with
--chown.
Our review also lints with hadolint and inspects the result with dive, which shows exactly which layer adds what. Size results are covered in more depth in Docker image size reduction, and cache behaviour in layer caching optimisation.
How it is delivered and handed over
We work in a pull request against your repository. Each Dockerfile change comes with a before and after table of size, build time and scan findings, plus a note on any behaviour we deliberately changed. Your CI builds the image on every merge using the same instructions as a developer laptop, which is described in our build pipeline integration page.
You keep a commented template per language and a short standards document, so the next service starts correctly rather than copying the nearest old one.
How to tell it is working
Look at a handful of signals: a no-change rebuild finishes in seconds, a one-line code change rebuilds only the last layers, the runtime image contains no compiler or package manager, the container runs as a non-root user, and an image scan returns findings only for packages your application genuinely uses. If you want a second pair of eyes on your current Dockerfiles, book a consultation.