From Docker to Production: Multi-Stage Builds, Slim Images, and Security Best Practices
Take a Dockerfile that merely "works on my machine" and rewrite it into one you can ship to production with confidence, covering multi-stage builds, image slimming, and the most common security traps.
The Starting Point: A Typical Dockerfile That Works but Isn't Production-Ready
Let's start with a common bad example:
FROM node:latest
COPY . .
RUN npm install
EXPOSE 3000
CMD ["npm", "start"]It's short, but it hides at least five problems. node:latest isn't pinned, so a build today and a build tomorrow can ship completely different Node.js versions. COPY . . pulls in the whole project directory, including tests, .git, and your local .env. npm install installs devDependencies too, which production never uses. No user is specified, so the container runs as root. And nothing checks whether the container is actually still alive. Let's fix them one by one.
Step 1: Multi-Stage Builds Keep Build Tools in the Build Stage
What production really needs is usually just the compiled or bundled output, not the toolchain that produced it. A multi-stage build lets you put multiple FROM lines in one Dockerfile: the early stages install and compile, and the final stage takes only the files that actually need to run.
# stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# stage 2: runtime
FROM node:20-alpine AS runner
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]We use npm ci instead of npm install here because npm ci follows package-lock.json strictly. Versions are fully locked, so a stale lockfile can't quietly give you different package versions than you expected. In production, that detail matters.
The source code, the TypeScript compiler, and the test tools in the builder stage never make it into the runner stage. Only /app/dist, which COPY --from=builder names explicitly, gets copied over. The final image ends up with only what the app needs to run.
Step 2: Pick the Right Base Image to Slim Down from the Start
Your base image sets the floor for your image size. For the same Node.js environment, different base images can differ by 10x or more:
node:20: Based on Debian with a full system toolchain, and often over 1 GB. Unless you really need those tools to compile native modules, you rarely want it as a runtime image.node:20-slim: Still Debian, but with most non-essential system packages stripped out, so it's noticeably smaller.node:20-alpine: Based on Alpine Linux, which uses musl libc instead of glibc. It's usually only a few dozen MB and is the most common choice today, but some native modules that depend on glibc may need extra work.gcr.io/distroless/nodejs20: No shell and no package manager, just the minimal runtime needed to run a Node.js app. It has the smallest attack surface, but you also can'tdocker execin to debug, so you'll need other ways to troubleshoot.
For most projects, starting with node:20-alpine is the pragmatic choice. Once you have stricter security requirements, consider switching to distroless.
Step 3: Don't Run Containers as root
In the bad example above, the process inside the container runs as root by default. If the app has a vulnerability (say, arbitrary file write or command injection) and someone exploits it, the attacker gets root inside the container. Namespaces do separate the container's view from the host's, but root still greatly raises the risk of privilege escalation and container escape.
The fix is to create a dedicated unprivileged user and run the app as that user:
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
USER appuser
CMD ["node", "dist/index.js"]--chown=appuser:appgroup makes the copied files belong to that user right away, so you don't have to deal with file permission problems at runtime. After the USER appuser line, every command and the final running process use that unprivileged identity. Even if the app is compromised, the attacker is limited to what that user can do.
Step 4: Use .dockerignore and Explicit COPY to Prevent Secret Leaks
The biggest risk of COPY . . isn't just a bigger image. It's that you may pack things into the image that don't belong there: your local .env, the full version history in .git (which may contain secrets from earlier commits), or node_modules, which the container should install itself rather than inherit from your machine.
node_modules
.git
.env
.env.*
*.pem
*.key
npm-debug.log
Going one step beyond .dockerignore, you can list only the files and directories you actually need in COPY, instead of copying everything and then excluding things:
COPY src ./src
COPY public ./public
COPY package.json package-lock.json ./This makes it obvious what goes into the image. You don't have to rely on .dockerignore being complete, and you're less likely to miss an exclusion rule when the project structure changes.
If the build itself needs a secret (such as a token for a private package registry), don't pass it in with ARG. The value of an ARG is recorded in the image's build history, and docker history can dig it right out. The right way is BuildKit's --mount=type=secret:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ciThe secret is visible only while this one RUN instruction executes, and it isn't written into any image layer.
Step 5: Pin Your Versions, Don't Use latest for Everything
FROM node:latest looks convenient, but it means every build can produce something different. A version that passed your tests today might behave differently tomorrow because the base image was updated. That kind of uncertainty is the last thing you want in production.
A more rigorous approach is to pin the base image to an exact version, or even to a digest:
FROM node:20.11.1-alpine
# or pin to a digest so even the base image contents are identical
FROM node:20.11.1-alpine@sha256:1234567890abcdef...Pinning a tag (like 20.11.1-alpine) protects you from surprises when a major version moves. A digest goes further: even if someone overwrites the same tag to point at different content, you still build the exact same image. That matters most when you need strict reproducibility.
Step 6: Add HEALTHCHECK So the Image Can Report Its Own Health
A running container doesn't mean the app inside is healthy. A service can get stuck in some state, with the process still alive but unable to respond to requests at all. HEALTHCHECK makes Docker actively probe the container's health. With plain docker run, Docker Compose, or Docker Swarm, it's the only way a container can report its own health.
But keep in mind that this is an image-level health report, which is not the same thing as an orchestrator-level health check:
- Kubernetes: The kubelet never reads the
HEALTHCHECKin your image. It sends its own probes based on thelivenessProbe,readinessProbe, andstartupProbedefined separately in the Pod spec. Even if your Dockerfile has aHEALTHCHECK, it's ignored on K8s. - ECS: If the Task Definition doesn't specify a
healthCheck, ECS falls back to the image's built-inHEALTHCHECKas the default. But as soon as the Task Definition defines one, the Task Definition wins.
In other words, HEALTHCHECK is essential for standalone Docker and Compose, a useful default on ECS, and has almost no practical effect on K8s. What actually triggers a restart or replacement there is the probe config in the Pod spec. Even so, keeping HEALTHCHECK in the image is still a good habit, because you never know for sure where the image will end up running.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1--interval sets how often to check. --start-period keeps a container that's just starting up and still warming up from being wrongly marked unhealthy. --retries is how many consecutive failures it takes before the container is declared unhealthy. Combined with a /health endpoint in the app, the result shows up in the STATUS column of docker ps, or ECS uses it as its default health signal.
The Full Version: Putting All Six Steps Together
# syntax=docker/dockerfile:1
FROM node:20.11.1-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
COPY tsconfig.json ./
RUN npm run build
FROM node:20.11.1-alpine AS runner
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]Compared to the three-line bad example we started with, this Dockerfile is a lot longer. But every section answers a question you'll really face in production: how big should the image be, what should be in it, who should it run as, can you reproduce the build, and who finds out when the container dies.
Summary
Between a Dockerfile that runs on your machine and one you can ship to production with confidence, there are several layers of concerns. Multi-stage builds separate build tools from the final artifact, so the image carries only what it needs to run. A lean base image and an explicit list of files to copy shrink both size and attack surface. Not running as root, keeping secrets out of build history, and pinning versions to an exact tag or digest take care of security and reproducibility. And HEALTHCHECK gives the image its own health reporting: on standalone Docker and Compose it directly drives restarts and replacement, while on K8s you'll need to pair it with the probes in your Pod spec.