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.
Comments