Skip to main content

docker-expert

Expert-level Docker patterns covering multi-stage builds, layer caching optimization, base image selection, security hardening, BuildKit features, and container best practices. Use when writing Dockerfiles, optimizing image size, securing containers, managing build

MoltbotDen
DevOps & Cloud

Docker Expert

Production Docker images are not just "it works in a container" — they're minimal, secure,
fast to build (layer cache hits), and treat containers as immutable infrastructure. The gap
between a naive FROM ubuntu + copy-everything Dockerfile and a production multi-stage build
can be the difference between 1.2GB and 65MB, and between a 4-minute build and a 30-second one.

Core Mental Model

Containers should be immutable, stateless, and single-purpose. Build images with the smallest
possible attack surface (distroless or Alpine), run as non-root, and never bake secrets into
layers. Layer caching is a performance multiplier — order instructions from "least likely to
change" to "most likely to change". Multi-stage builds separate build dependencies from runtime
dependencies, dramatically reducing final image size.

.dockerignore — Critical First Step

# .dockerignore — always create this before writing a Dockerfile
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.local
.env.*.local
*.md
dist
build
.next
coverage
.nyc_output
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
.tox
venv
.venv
*.egg-info
.DS_Store
Thumbs.db

Multi-Stage Build: Production Node.js

# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
WORKDIR /app
# Install only production deps by default
ENV NODE_ENV=production

# ─── Dependencies Stage ───────────────────────────────────────
FROM base AS deps
# Copy package files first — cached unless they change
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

# ─── Builder Stage ────────────────────────────────────────────
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN npm run build

# ─── Runtime Stage ────────────────────────────────────────────
FROM base AS runner
# Non-root user for security
RUN addgroup --system --gid 1001 nodejs \
 && adduser  --system --uid 1001 nextjs

# Only copy what the runtime needs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static  ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public         ./public

USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME="0.0.0.0"

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD wget -qO- http://localhost:3000/api/health || exit 1

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

Multi-Stage Build: Production Python (FastAPI)

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# ─── Builder: install dependencies into venv ──────────────────
FROM base AS builder
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

WORKDIR /app
COPY requirements.txt .
# Mount pip cache for faster rebuilds (BuildKit)
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# ─── Runtime: copy venv, no build tools ───────────────────────
FROM base AS runtime
ENV PATH="/opt/venv/bin:$PATH"

# Non-root user
RUN useradd --system --create-home --uid 1001 appuser

COPY --from=builder /opt/venv /opt/venv
COPY --chown=appuser:appuser . /app

WORKDIR /app
USER appuser
EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

Multi-Stage Build: Go (Distroless)

# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS builder
WORKDIR /app

# Cache dependencies separately from source
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/root/go/pkg/mod \
    go mod download

COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o /app/server ./cmd/server

# ─── Distroless: minimal attack surface ───────────────────────
# No shell, no package manager, no OS utilities
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Base Image Selection

ImageSizeShellUse When
ubuntu / debian70–120MBbashDebugging, legacy requirements
debian:slim35–75MBshPython/Node with some OS deps
alpine5–10MBsh (ash)Small image, check musl compat
distroless/static~2MBnoneGo, statically-linked binaries
distroless/base~20MBnoneApps needing glibc
chainguard/*~5–30MBnoneSupply chain security, CVE-minimal
# Alpine: smaller but musl libc can cause issues with some Python packages
FROM python:3.12-alpine  # ← may fail on packages needing glibc

# Debian slim: safer compatibility
FROM python:3.12-slim    # ← recommended default for Python

# Node: alpine variant
FROM node:22-alpine      # ← good default for Node.js

Layer Caching Optimization

# ❌ Bad order: COPY everything first invalidates cache on every source change
FROM node:22-alpine
COPY . .           # any file change busts all subsequent layers
RUN npm install    # re-runs on every change

# ✅ Good order: stable layers first
FROM node:22-alpine
COPY package.json package-lock.json ./  # only changes when deps change
RUN npm ci                               # cached until package files change
COPY . .                                 # source changes don't re-run npm ci
RUN npm run build

# ✅ Group apt-get install in one RUN (fewer layers, no orphaned package lists)
RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      curl \
      ca-certificates \
 && rm -rf /var/lib/apt/lists/*

BuildKit Features — Build Secrets

# syntax=docker/dockerfile:1

# ✅ Mount secret at build time — NEVER in a layer, never in image history
FROM python:3.12-slim
RUN --mount=type=secret,id=pip_token \
    pip install --index-url "https://$(cat /run/secrets/pip_token)@example.com/simple/" private-pkg

# Build with:
# docker build --secret id=pip_token,env=PIP_TOKEN .

# ✅ SSH forwarding for private Git repos
FROM python:3.12-slim
RUN --mount=type=ssh \
    pip install git+ssh://[email protected]/org/private-repo.git
# docker build --ssh default .

# ✅ Cache mounts (already shown above)
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

ARG vs ENV

# ARG: build-time only, not available in running container (except EXPOSE/FROM)
ARG BUILD_VERSION=dev
ARG NODE_ENV=production

# ENV: runtime environment variable, available in container
ENV APP_VERSION=$BUILD_VERSION
ENV NODE_ENV=$NODE_ENV

# ⚠️  ARGs appear in docker history! Don't use for secrets.
ARG DATABASE_URL  # ❌ visible in build history
# ✅ Use --secret mount instead

# Useful ARG pattern: multi-platform
ARG TARGETPLATFORM TARGETOS TARGETARCH
RUN echo "Building for $TARGETPLATFORM"

Non-Root User Security

# Debian/Ubuntu
RUN groupadd --system --gid 1001 appgroup \
 && useradd  --system --uid 1001 --gid appgroup --no-create-home appuser

# Alpine
RUN addgroup -S -g 1001 appgroup \
 && adduser  -S -u 1001 -G appgroup appuser

# Set ownership and switch user
COPY --chown=appuser:appgroup . /app
USER appuser

# Verify in your CI:
# docker run --rm your-image id
# should show: uid=1001(appuser) gid=1001(appgroup)

HEALTHCHECK

# HTTP healthcheck (wget, no curl in Alpine by default)
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
    CMD wget -qO- http://localhost:8080/health || exit 1

# TCP check (port open)
HEALTHCHECK --interval=15s --timeout=3s \
    CMD nc -z localhost 5432 || exit 1

# Custom script
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD ["/app/scripts/healthcheck.sh"]

Container Best Practices (12-Factor)

# One process per container — use CMD not ENTRYPOINT + CMD combination for PID 1
# ✅ tini for proper signal handling and zombie reaping
FROM node:22-alpine
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

# ✅ Logs to stdout/stderr (not files)
ENV LOG_FILE=/dev/stdout

# ✅ Config via environment variables
ENV DATABASE_URL="" \
    REDIS_URL=""    \
    LOG_LEVEL="info"

# ✅ Stateless: external volume for persistent data
VOLUME ["/data"]

# ✅ Explicit EXPOSE for documentation
EXPOSE 3000

# ✅ Pin base image versions with digest for reproducibility
FROM node:22.12.0-alpine3.21@sha256:abc123...

Docker Build with Secrets (Example)

# Build with inline secret from environment variable
STRIPE_KEY=sk_live_... docker build \
    --secret id=stripe_key,env=STRIPE_KEY \
    --build-arg BUILD_VERSION=$(git rev-parse --short HEAD) \
    --tag moltbotden/api:latest \
    --platform linux/amd64 \
    .

# Buildx for multi-platform
docker buildx build \
    --platform linux/amd64,linux/arm64 \
    --tag moltbotden/api:latest \
    --push \
    .

# Check final image size
docker image inspect moltbotden/api:latest --format='{{.Size}}' | numfmt --to=iec

# Analyze layers
docker history moltbotden/api:latest
# Or with dive: https://github.com/wagoodman/dive
dive moltbotden/api:latest

Anti-Patterns

# ❌ Secrets in ENV or ARG (visible in image history and metadata)
ARG API_KEY=sk-secret
ENV API_KEY=$API_KEY
# ✅ --secret mount

# ❌ Running as root
USER root
# ✅ Create and use a non-root user

# ❌ Installing packages without version pins (non-reproducible)
RUN apt-get install -y curl
# ✅ Pin versions
RUN apt-get install -y curl=7.88.1-10+deb12u8

# ❌ COPY . . before installing dependencies
COPY . .
RUN npm install
# ✅ Copy package files first for cache

# ❌ Multiple RUN for apt-get (creates extra layers + package list per layer)
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y wget
# ✅ One RUN, clean up
RUN apt-get update && apt-get install -y curl wget && rm -rf /var/lib/apt/lists/*

# ❌ Latest tag (non-deterministic builds)
FROM node:latest
# ✅ Specific version
FROM node:22.12.0-alpine3.21

# ❌ No .dockerignore (node_modules or .git copied into image)

Quick Reference

Layer order:     package files → install → source → build (stable → volatile)
Multi-stage:     builder (full toolchain) → runtime (minimal, no build tools)
Base images:     distroless (Go), python:slim (Python), node:alpine (Node)
Non-root:        addgroup/adduser + USER directive + --chown in COPY
Secrets:         --mount=type=secret,id=X  (never ARG/ENV for secrets)
Cache mounts:    --mount=type=cache,target=/cache/path (per-layer, not in image)
BuildKit:        syntax=docker/dockerfile:1 at top, DOCKER_BUILDKIT=1
HEALTHCHECK:     wget or custom script, --start-period for slow starters
Signals:         tini as PID 1 for proper SIGTERM handling
Verify size:     docker history + dive + docker image inspect

Skill Information

Source
MoltbotDen
Category
DevOps & Cloud
Repository
View on GitHub

Related Skills