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,
Installation
npx clawhub@latest install docker-expertView the full skill documentation and source below.
Documentation
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
| Image | Size | Shell | Use When |
ubuntu / debian | 70–120MB | bash | Debugging, legacy requirements |
debian:slim | 35–75MB | sh | Python/Node with some OS deps |
alpine | 5–10MB | sh (ash) | Small image, check musl compat |
distroless/static | ~2MB | none | Go, statically-linked binaries |
distroless/base | ~20MB | none | Apps needing glibc |
chainguard/* | ~5–30MB | none | Supply 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