Skip to main content
Cloud DevOps Solutions
Get a quote Talk to an engineer

Docker & Containerization

Writing Production Dockerfiles: Multi-Stage Builds and Minimal Base Images

A production Dockerfile separates build tools from the runtime image using multi-stage builds, starts from a small, pinned base image, runs as a non-root user and orders instructions for caching. We write or rewrite Dockerfiles to that standard and test them in your CI.

Key takeaways

  • Multi-stage builds keep compilers, package managers and test tooling out of the image you ship.
  • A pinned, minimal base image is the single biggest lever on size, attack surface and reproducibility.
  • Instruction order decides whether a one-line code change rebuilds in seconds or minutes.
  • Every Dockerfile we deliver runs as a non-root user and has a health or startup check where the platform supports it.
  • A Dockerfile is only finished when it builds the same way on a laptop and in CI.

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 ENTRYPOINT or CMD in 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

BaseGood forWatch out for
Debian slimSafe default for most languagesLarger than the alternatives
AlpineSmall images, static or simple appsmusl libc can break native modules and some Python wheels
DistrolessStrong security posture, static binaries, JVMNo shell, so debugging needs ephemeral containers or a debug variant
scratchStatic Go or Rust binariesNo 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 ARG token or a copied .env file stays in image history. We use BuildKit secret mounts instead.
  • Shell-form CMD. The process runs under sh -c and never receives SIGTERM, 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.

References

How we deliver it

  1. 01

    Review what you have

    We read every Dockerfile, build it, and record size, build time, layer count and which tools leak into the final image.

  2. 02

    Choose the base image

    We pick a runtime base per language (slim, distroless or Alpine where it is safe) and agree how it will be pinned and updated.

  3. 03

    Split build and runtime stages

    Dependencies and compilation happen in a builder stage, and only the artefacts are copied into a clean runtime stage.

  4. 04

    Order for caching

    Dependency manifests are copied before source code, and cache mounts are used for package managers.

  5. 05

    Harden and validate

    A non-root user, a health check and a scan are added, then the image is run under the same limits it will see in production.

  6. 06

    Hand over with a template

    You receive working Dockerfiles, a commented template per language and the CI job that builds them.

Tools and technologies

DockerBuildKitDocker BuildxhadolintdiveTrivydistrolessAlpine LinuxDebian slimGitHub ActionsGitLab CI

Frequently asked questions

What is a multi-stage Docker build?
A multi-stage build uses several FROM instructions in one Dockerfile. Early stages install tools and compile the application, and the final stage copies only the built output into a small runtime image. The result contains no compilers or package caches, so it is smaller, faster to pull and has fewer things to patch.
Should we use Alpine, slim or distroless?
It depends on the language and your debugging habits. Debian slim is the safest default because it uses glibc and is widely compatible. Alpine is smaller but uses musl, which can break some native dependencies. Distroless has no shell, which is excellent for security but needs a debug strategy.
Can you fix Dockerfiles we already have rather than rewrite them?
Yes. Most of the value comes from targeted changes: splitting stages, reordering for cache, changing the base image and dropping root. We keep behaviour identical, prove it with your tests and a side-by-side comparison, and explain each change in the pull request so your team learns from it.
Does a smaller image make the application faster?
Not at run time. The application runs the same. The gains are elsewhere: quicker pulls during deploys and autoscaling, less storage, faster CI, and fewer packages that can carry vulnerabilities. For short-lived or frequently scaled workloads those pull times matter a great deal.
How do you keep Dockerfiles from drifting apart across services?
We provide a template per language, lint every Dockerfile with hadolint in CI, and agree one base image policy. New services start from the template, and a shared standards page explains the choices, so differences are deliberate rather than accidental.

Talk to an engineer about dockerfile multi-stage build

Book a free 30-minute call. We review your current setup and tell you honestly whether and how we can help.

Written by Saddique Khan, DevOps / SRE / Cloud Consultant Engineer. Last updated 2026-10-08.