Key takeaways
- A multi-stage build separates compilation from runtime, with measured gains per language: Go drops from 850 MB to 8-15 MB (a static binary built with
CGO_ENABLED=0 go build -ldflags="-s -w" -trimpathand copied ontoscratch), 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. HEALTHCHECKis tuned with four parameters —--interval(30s by default),--timeout(30s),--start-period(0s) and--retries(3) — across three possible states: starting, healthy, unhealthy. The trap is leaving start-period at 0: it must exceed the real startup time, otherwise the container flips to unhealthy while it is still initialising. Check it withdocker inspect --format='{{.State.Health.Status}}' my-container.- Docker grants about fifteen capabilities by default: enforce least privilege with
--cap-drop ALL, then--cap-add NET_BIND_SERVICEonly if the service binds a port below 1024, together with--security-opt no-new-privileges:true, a read-only filesystem (--read-only --tmpfs /tmp:size=100M,noexec) and a seccomp profile set todefaultAction: SCMP_ACT_ERRNO. - Declare the user as a numeric UID (
USER 1001:1001) so the image does not depend on/etc/passwd. For secrets,ARGleaks throughdocker historyandENVthrough the image metadata: at build time,RUN --mount=type=secret,id=github_tokenreads/run/secrets/github_token; at runtime, the Composesecrets:section mounts files under/run/secrets/instead of environment variables. - For production, split the Compose files:
docker-compose.ymlplusdocker-compose.override.yml(loaded automatically in dev), anddocker compose -f docker-compose.yml -f docker-compose.prod.yml up -din production, checking the merge withdocker compose config. On the CI/CD side: multi-arch builds withdocker buildx --platform linux/amd64,linux/arm64,cosignsigning, and thetype=ghacache that brings a 5-10 minute build down to 1-2 minutes.
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 .
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.
- All advanced tutorials
- New content every week
- Progress tracking
- Cancel anytime