Devops
Difficulty: Advanced
20 min read

Advanced Docker: Multi-stage Builds, Healthchecks and Security

Advanced Docker guide for production: multi-stage builds for images 10x lighter, reliable healthchecks, image and runtime security, production-ready Docker Compose, and a multi-arch CI/CD pipeline.

Back to tutorials
Prerequisites
This tutorial assumes basic knowledge of Docker (images, containers, Dockerfile, docker compose). If you are a beginner, first check out the Docker installation and configuration guide and the Docker installation guide.

Why "basic" Docker is not enough in production

The vast majority of teams adopting Docker start with a simple Dockerfile: an official image, a few RUN instructions, a CMD, and it goes to production. It works. Until the day it doesn't.

The typical problems with naive Dockerfiles in production are always the same. A 2 GB image that takes 8 minutes to build in CI. A container running as root and exposing useless system binaries. A service that declares itself "up" while it no longer responds to requests. Secrets passed as ARG visible in docker history. A docker compose up that starts the application before the database is ready.

These problems have proven and documented solutions. This guide covers them exhaustively:

  • Multi-stage builds: images 5 to 100x lighter depending on the language
  • Layer optimization: fast builds thanks to caching
  • Production-ready healthchecks: reliable service ordering
  • Image security: non-root user, minimal images, vulnerability scanning
  • Runtime security: capabilities, seccomp, network isolation
  • Production Docker Compose: override files, secrets, resource limits
  • Registry and CI/CD: multi-arch, signing, GitHub Actions pipeline

1. Multi-stage builds

The principle of multi-stage builds is simple: use several successive images in a single Dockerfile to separate the compilation phase from the execution phase. The final image contains only what is strictly necessary to run the application.

Complete example: Go application

Go is the perfect example to illustrate the gains: the Go SDK weighs around 850 MB. A statically compiled binary weighs a few megabytes.

# syntax=docker/dockerfile:1

# ── Stage 1: Build ────────────────────────────────────────────────────
FROM golang:1.22-alpine AS builder

WORKDIR /app

# Copy the dependency files first for caching
COPY go.mod go.sum ./
RUN go mod download

# Copy the source code and compile a static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -trimpath -o /app/server ./cmd/server

# ── Stage 2: Runtime ──────────────────────────────────────────────────
FROM scratch

# Copy the TLS certificates (required for HTTPS calls)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy only the compiled binary
COPY --from=builder /app/server /server

EXPOSE 8080

ENTRYPOINT ["/server"]

Result: the final image weighs between 8 and 15 MB instead of 850 MB. The -s -w ldflags options strip the debug symbols and DWARF information. -trimpath removes local paths from the binary for reproducibility.

Complete example: Node.js application

For Node.js, the challenge is to separate the devDependencies (build tools, TypeScript, etc.) from the runtime dependencies.

# syntax=docker/dockerfile:1

# ── Stage 1: Install all dependencies ──────────────────
FROM node:20-alpine AS deps

WORKDIR /app
COPY package*.json ./

# Install ALL dependencies (dev + prod) for the build
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# ── Stage 2: TypeScript build ─────────────────────────────────────────
FROM deps AS builder

COPY . .
RUN npm run build

# ── Stage 3: Minimal runtime ──────────────────────────────────────────
FROM node:20-alpine AS runner

RUN addgroup -S nodejs && adduser -S nextjs -G nodejs

WORKDIR /app

# Copy only the build artifacts and the production dependencies
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./

USER nextjs

EXPOSE 3000
ENV NODE_ENV=production

CMD ["node", "dist/index.js"]

Pattern with a specific target --target

A single Dockerfile can serve several environments thanks to targets. Each stage inherits from the previous one and adds its specific layer.

# syntax=docker/dockerfile:1

FROM python:3.12-slim AS base
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-compile -r requirements.txt

# ── Target dev: debug tools ───────────────────────────────────────
FROM base AS dev
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install debugpy pytest ipdb watchfiles
COPY . .
CMD ["python", "-m", "uvicorn", "main:app", "--reload", "--host", "0.0.0.0"]

# ── Target test: running the tests ──────────────────────────────────
FROM base AS test
COPY requirements-test.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements-test.txt
COPY . .
CMD ["pytest", "-v", "--cov=app", "--cov-report=xml"]

# ── Target prod: minimal secure image ─────────────────────────────
FROM python:3.12-slim AS prod
COPY --from=base /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY . .
RUN groupadd -r appuser && useradd -r -g appuser -s /sbin/nologin appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"]
# Build a specific target
docker buildx build --target dev -t myapp:dev .
docker buildx build --target test -t myapp:test .
docker buildx build --target prod -t myapp:prod .

# In CI/CD: build only the prod target
docker buildx build --target prod --push -t registry.example.com/myapp:1.2.3 .
Typical gains per language
Go: from 850 MB to 8-15 MB (static binary on scratch). Node.js: from 1.1 GB to 150-250 MB. Python: from 900 MB to 180-250 MB. Java: from 700 MB to 80-120 MB with jlink. The gains are proportionally larger for compiled languages.

Premium Content

This advanced tutorial is reserved for premium members.

9,90€ / month
  • All advanced tutorials
  • New content every week
  • Progress tracking
  • Cancel anytime

Written by

Morgann Riu

Cybersecurity and Linux administration expert. I share my knowledge through free tutorials and training to help system administrators and developers secure their infrastructures.

Frequently asked questions

What is the difference between CMD and ENTRYPOINT in a Dockerfile?
ENTRYPOINT defines the container's main process, the one launched at startup and whose termination stops the container. CMD provides the default arguments for that process. Together, they combine: ENTRYPOINT ["/app/server"] CMD ["--port", "8080"] runs /app/server --port 8080. If you override CMD at launch (docker run myimage --port 9000), only the arguments change. If you use CMD alone, the entire command can be replaced. In practice: ENTRYPOINT for the main binary, CMD for its default arguments.
How do you minimize the size of a Docker image as much as possible?
Several cumulative techniques can drastically reduce the size. Use multi-stage builds to separate compilation and execution: copy only the final artifact. Choose a minimal base image: Alpine (5 MB), Distroless (no shell or package manager) or scratch (0 bytes, for static binaries). Combine RUN instructions to reduce the number of layers and remove caches within the same instruction (rm -rf /var/lib/apt/lists/* after apt-get). Use .dockerignore to exclude unnecessary files from the build context. Analyze with docker history to identify the heaviest layers.
Do multi-stage builds work with all languages?
Yes, multi-stage builds are universal and especially effective with compiled languages. For Go and Rust, you go from an 800 MB image to under 10 MB by copying the static binary onto scratch. For Java, you use jlink to create a minimal JRE and go from 700 MB to 100 MB. For Node.js, you separate build dependencies (devDependencies) from runtime dependencies. For Python, you install dependencies into a virtualenv that you copy onto a slim image. Interpreted languages benefit less from the size reduction but still gain from separating the build tools.
How do you handle secrets in production with Docker?
Never use ARG or ENV for secrets: these values are visible in docker history and stored in the image metadata. For builds, use BuildKit secret mounts (--secret id=mysecret,src=./secret.txt) which inject the secret without storing it in any layer. For runtime, prefer environment variables injected by the orchestration system (Docker Compose secrets:, Kubernetes Secrets, HashiCorp Vault). With Docker Compose, define the secrets in a dedicated section and mount them as files in /run/secrets/ rather than as environment variables.
Distroless vs Alpine, which one should you choose as a base image?
The choice depends on the use case. Alpine is ideal for development and for teams that need to debug containers in production: it has a shell (sh), a package manager (apk) and many utilities. Its size is around 5 MB. Distroless (Google) is optimal for high-security production: no shell, no package manager, no command interpreter, minimal attack surface. It comes in variants for Python, Java, Node.js and in a debug version (with busybox) for troubleshooting. As a rule of thumb: Alpine in development and staging, Distroless in production for critical applications. For fully static Go or Rust binaries, scratch offers a zero attack surface.
How does the Docker healthcheck work and how do you configure it correctly?
The HEALTHCHECK in the Dockerfile defines a command that Docker runs periodically to verify that the container is working correctly. Four parameters control its behavior: --interval (frequency, default 30s), --timeout (maximum duration of the command, default 30s), --start-period (grace period at startup, default 0s) and --retries (consecutive failures before switching to unhealthy, default 3). A container can be in three states: starting (during start-period), healthy or unhealthy. In Docker Compose, the service_healthy condition in depends_on waits for the service to become healthy before starting the dependent service. Always set a start-period suited to the application's actual startup time to avoid false positives.

Share this tutorial

Did you enjoy this article?

Comments

Checklist Sécurité Linux

30 points essentiels pour sécuriser un serveur Linux. Recevez aussi les nouveaux tutoriels par email.

Pas de spam. Désabonnement en 1 clic.