Docker Containerization

You are containerizing or reviewing the Docker setup for: $ARGUMENTS

Containerization is not just "making it run in Docker." A production container must be minimal, secure, reproducible, and observable. Apply every rule below.


Principles

  1. Minimal surface area. Every layer you add is an attack surface. The final image should contain only what the application needs to run.
  2. Reproducibility. The same docker build on any machine at any time produces the same image. No apt-get pulling latest packages, no dynamic downloads.
  3. Security by default. Non-root user, read-only filesystem, dropped capabilities — in the Dockerfile, not added later.
  4. Observable. Health check endpoints built in. Logs to stdout/stderr. Structured where possible.

1. Multi-Stage Dockerfile

Use multi-stage builds to keep build tools out of the production image:

# ─── Stage 1: Build ──────────────────────────────────────────────
FROM node:22.3.0-alpine3.20 AS builder

WORKDIR /app

# Copy dependency manifests first (maximise layer cache)
COPY package*.json ./
RUN npm ci

# Copy source and build
COPY . .
RUN npm run build

# ─── Stage 2: Production ─────────────────────────────────────────
FROM node:22.3.0-alpine3.20

# Non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy only production artifacts from builder
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules

USER appuser

EXPOSE 8080

# Health check built into the image
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:8080/healthz || exit 1

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

Language-specific starting points:

# Python (FastAPI / Flask)
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

FROM python:3.12-slim
RUN useradd -m appuser
WORKDIR /app
COPY --from=builder /root/.local /home/appuser/.local
COPY --chown=appuser . .
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
# PHP (Laravel / Symfony)
FROM composer:2.7 AS composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction

FROM php:8.3-fpm-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /var/www
COPY --from=composer --chown=appuser:appgroup /app/vendor ./vendor
COPY --chown=appuser:appgroup . .
USER appuser

2. Security Hardening

Non-Root User

# Alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# Debian/Ubuntu
RUN useradd --uid 1001 --gid 1001 --no-create-home appuser
USER appuser

Read-Only Root Filesystem (Kubernetes)

# In your pod spec — makes the container's filesystem immutable
securityContext:
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false

# Mount writable paths explicitly
volumeMounts:
  - name: tmp
    mountPath: /tmp
  - name: cache
    mountPath: /app/cache
volumes:
  - name: tmp
    emptyDir: {}
  - name: cache
    emptyDir: {}

Image Scanning

# Scan before push
trivy image myapp:1.0.0

# Fix CRITICALs — usually means updating the base image tag
# Check what the alpine version fixes: https://pkg.alpinelinux.org/packages

# Scan in CI (fail build on CRITICAL)
trivy image --exit-code 1 --severity CRITICAL myapp:1.0.0

3. Layer Cache Optimisation

# BAD: copies everything first — any source change invalidates npm install cache
COPY . .
RUN npm ci

# GOOD: copy manifests first — npm install only re-runs if package.json changes
COPY package*.json ./
RUN npm ci
COPY . .

Keep layers that change rarely (OS packages, system deps) near the top, and layers that change often (application code) near the bottom.


4. docker-compose for Local Development

# docker-compose.yml
services:
  app:
    build:
      context: .
      target: builder        # use the build stage for hot-reload
    ports:
      - "8080:8080"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
    volumes:
      - .:/app               # mount source for hot reload
      - /app/node_modules    # exclude node_modules from mount
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pgdata:

5. .dockerignore

Always include a .dockerignore to keep the build context small and secrets out of images:

.git
.env*
node_modules
*.log
dist
coverage
.DS_Store
*.test.*
docs
README.md

6. Checklist

  • [ ] Multi-stage build — no build tools in production image
  • [ ] Base image pinned to a specific version (not latest)
  • [ ] Non-root user set
  • [ ] .dockerignore excludes dev files, secrets, .git
  • [ ] HEALTHCHECK instruction defined
  • [ ] No secrets in ENV, ARG, or layers
  • [ ] Image scanned with Trivy — no CRITICAL CVEs
  • [ ] Layer order is cache-optimised (deps before source)
  • [ ] docker-compose.yml provided for local development
  • [ ] Logs go to stdout/stderr (not files inside container)