Production Dockerfile Patterns
Copy-paste is not a strategy—but these opinionated, production-grade Dockerfiles are a solid starting point. Every recipe uses multi-stage builds, pinned bases, non-root users, and layer ordering that survives real CI. Adapt paths and ports; keep the structure.
Spring Boot / Java
Spring Boot fat JARs are convenient locally and expensive in production—one layer change rebuilds everything. Split the JAR with layertools, ship only the runtime slices, and run on distroless java21 as a non-root user with container-aware JVM flags and actuator health checks.
Why layered JAR extraction matters
A standard COPY target/*.jar puts dependencies, framework, and your 200 KB of code in one layer. Change one controller and Docker rebuilds 80 MB of dependencies. Spring Boot's -Djarmode=layertools splits the JAR into four directories—each becomes its own COPY layer with independent cache keys.
Production Dockerfile
# syntax=docker/dockerfile:1.6
# ── Stage 1: Maven build ─────────────────────────────────────
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build
# Cache dependency resolution separately from source
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN chmod +x mvnw && ./mvnw -B dependency:go-offline -DskipTests
COPY src/ src/
RUN ./mvnw -B package -DskipTests
# ── Stage 2: Extract layered JAR ─────────────────────────────
FROM eclipse-temurin:21-jre-alpine AS extractor
WORKDIR /app
ARG JAR_FILE=app.jar
COPY --from=builder /build/target/*.jar ${JAR_FILE}
RUN java -Djarmode=layertools -jar ${JAR_FILE} extract
# ── Stage 3: Distroless runtime ──────────────────────────────
FROM gcr.io/distroless/java21-debian12:nonroot AS runtime
WORKDIR /app
# Separate COPY layers — dependency changes don't bust app layer
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
USER nonroot:nonroot
ENV JAVA_TOOL_OPTIONS="\
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:+ExitOnOutOfMemoryError \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp \
-Djava.security.egd=file:/dev/./urandom"
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Actuator HEALTHCHECK (Docker-native deploys)
Distroless images have no curl or shell—prefer Kubernetes livenessProbe/readinessProbe hitting /actuator/health/liveness. For plain Docker or Compose, swap the runtime stage to JRE Alpine and add a probe binary:
# Replace distroless runtime stage with:
FROM eclipse-temurin:21-jre-alpine AS runtime
RUN apk add --no-cache curl && \
addgroup -g 1001 -S app && adduser -u 1001 -S app -G app
WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
USER app:app
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health/liveness || exit 1
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Required application.properties
# Expose health endpoints for probes (Spring Boot 3+)
management.endpoints.web.exposure.include=health,info,prometheus
management.endpoint.health.probes.enabled=true
management.endpoint.health.show-details=never
management.server.port=8080
Enable layered JARs in pom.xml with <layers><enabled>true</enabled></layers> under the Spring Boot Maven plugin. Without it, layertools extract still works but layer boundaries are less predictable.
gcr.io/distroless/java21-debian12:nonroot runs as UID 65532 with no package manager, no shell, and minimal attack surface. Never add a shell "for debugging" in production—use the :debug tag locally only.
Node.js
Node containers fail in production for three predictable reasons: running as root, shell-form CMD that swallows signals, and copying node_modules from the host. Fix all three with npm ci, tini, and a proper .dockerignore.
.dockerignore
node_modules
npm-debug.log
dist
.git
.gitignore
.env
.env.*
Dockerfile*
docker-compose*
coverage
.nyc_output
*.md
.vscode
.idea
test
__tests__
*.test.js
*.spec.js
Production Dockerfile
# syntax=docker/dockerfile:1.6
# ── Stage 1: Build ───────────────────────────────────────────
FROM node:20.11.1-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Prune devDependencies for production node_modules
RUN npm prune --omit=dev
# ── Stage 2: Runtime ─────────────────────────────────────────
FROM node:20.11.1-alpine AS runtime
# tini reaps zombie child processes and forwards SIGTERM correctly
RUN apk add --no-cache tini
WORKDIR /app
ENV NODE_ENV=production
# Built artifacts + production deps only
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/package.json ./package.json
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]
Why tini? Node doesn't reap orphaned child processes. Without an init, zombie processes accumulate until the container hits PID limits. tini becomes PID 1, forwards SIGTERM to node, and reaps zombies. The exec-form ENTRYPOINT + CMD ensures signals reach the process tree.
Never use npm install in CI builds—it's non-deterministic. npm ci requires package-lock.json and fails if it's out of sync with package.json, which is exactly what you want.
Python
Python images bloat fast—pip caches, compiler toolchains left in runtime, and uvicorn running as PID 1 without signal handling. Build in a venv, copy only the venv to a slim runtime, and let gunicorn or uvicorn own PID 1 in exec form.
Production Dockerfile (FastAPI + uvicorn via gunicorn)
# syntax=docker/dockerfile:1.6
# ── Stage 1: Build venv ──────────────────────────────────────
FROM python:3.12-slim-bookworm AS builder
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ── Stage 2: Runtime ─────────────────────────────────────────
FROM python:3.12-slim-bookworm AS runtime
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
# Copy only the venv — no pip, no gcc, no build artifacts
COPY --from=builder /opt/venv /opt/venv
RUN groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app && \
chown -R app:app /app
COPY --chown=app:app app/ ./app/
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"
# gunicorn as PID 1 — manages worker processes and signal handling
CMD ["gunicorn", "app.main:app", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--timeout", "60", \
"--graceful-timeout", "30", \
"--keep-alive", "5"]
Alternative: uvicorn as PID 1 (single-worker dev/small services)
# Exec-form CMD — uvicorn receives SIGTERM directly (no shell wrapper)
CMD ["uvicorn", "app.main:app", \
"--host", "0.0.0.0", \
"--port", "8000", \
"--proxy-headers", \
"--forwarded-allow-ips", "*"]
requirements.txt (pinned)
fastapi==0.115.6
uvicorn[standard]==0.34.0
gunicorn==23.0.0
pydantic==2.10.4
gunicorn + uvicorn workers scales across CPU cores with proper pre-fork signal handling. uvicorn alone is simpler but single-process—fine for low-traffic services or sidecars. Never wrap either in sh -c; the shell becomes PID 1 and breaks graceful shutdown.
Go
Go's superpower is static binaries. With CGO_ENABLED=0 and a pinned GOPROXY, you compile once and copy a single binary to scratch—no libc, no shell, no CVE surface. Add a non-root user via /etc/passwd because scratch has nothing else.
Production Dockerfile
# syntax=docker/dockerfile:1.6
# ── Stage 1: Build static binary ─────────────────────────────
FROM golang:1.22.5-alpine AS builder
WORKDIR /src
ENV CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
GOPROXY=https://proxy.golang.org,direct
# Cache module downloads
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
# -s -w strips debug symbols; -trimpath removes host paths from binary
RUN go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server
# Create passwd/group for non-root scratch user
RUN echo "appuser:x:65532:65532:appuser:/:/sbin/nologin" > /out/passwd && \
echo "appuser:x:65532:" > /out/group
# ── Stage 2: Scratch runtime ─────────────────────────────────
FROM scratch AS runtime
COPY --from=builder /out/passwd /etc/passwd
COPY --from=builder /out/group /etc/group
COPY --from=builder /out/app /app
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]
Multi-arch build command
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag myregistry/myapp:v1.2.3 \
--push .
Scratch images cannot run HEALTHCHECK commands—use Kubernetes gRPC/HTTP probes or an external health checker. If you need CA certificates for HTTPS outbound calls, copy /etc/ssl/certs/ca-certificates.crt from the builder stage.
"Why CGO_ENABLED=0?" — Produces a fully static binary with no glibc/musl dependency, enabling scratch/distroless runtimes. Trade-off: no cgo-based libraries (some DB drivers, image codecs). For pure HTTP/gRPC services, this is the gold standard.
Nginx static site
SPAs belong in Nginx, not Node. Build with Node (Vite/React/Vue), serve with nginx:alpine and a hardened nginx.conf—security headers, gzip, SPA fallback, and a non-root worker process.
Custom nginx.conf
worker_processes auto;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
# Cache static assets aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — all routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
location /health {
access_log off;
return 200 "ok";
add_header Content-Type text/plain;
}
}
}
Production Dockerfile
# syntax=docker/dockerfile:1.6
# ── Stage 1: Frontend build ──────────────────────────────────
FROM node:20.11.1-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Stage 2: Nginx runtime ───────────────────────────────────
FROM nginx:1.25.4-alpine AS runtime
# Remove default config; use hardened custom config
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/dist /usr/share/nginx/html
# Prepare writable dirs for non-root nginx (listen on 8080, not 80)
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
touch /tmp/nginx.pid && chown nginx:nginx /tmp/nginx.pid && \
mkdir -p /var/run/nginx && chown nginx:nginx /var/run/nginx
USER nginx
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health || exit 1
CMD ["nginx", "-g", "daemon off;"]
In Kubernetes, map container port 8080 to Service port 80 via targetPort: 8080. Non-root nginx cannot bind port 80 (< 1024). The custom nginx.conf above listens on 8080 and writes PID to /tmp/nginx.pid—both required for unprivileged operation.
Anti-patterns to avoid
These patterns appear in tutorials, copy-paste from Stack Overflow, and survive code review because "it works locally." Each one has a production failure mode—security breach, cache miss storm, or graceful-shutdown hang.
- ❌ Running as root (USER root or no USER directive) — Container escape or RCE gives host-level privileges. Always set USER to a dedicated non-root account. Bind-mounted volumes inherit UID mismatches—plan ownership at deploy time.
- ❌ Using the latest tag — node:latest today is Node 20; tomorrow it might be Node 22 with breaking changes. Pin major.minor.patch: node:20.11.1-alpine. Deploy by digest in production: myapp@sha256:abc123….
- ❌ Secrets baked into image layers — ENV API_KEY=sk-live-… or ARG for credentials persists in image history forever. Use BuildKit --mount=type=secret at build time and runtime secret managers (Vault, AWS Secrets Manager) at deploy. Verify with docker history --no-trunc.
- ❌ Fat single layer (RUN apt update && apt install … && npm install && build) — One instruction change rebuilds everything; deleted files still bloat the layer. Split into logical layers: OS packages → dependency manifests → dependency install → source → build.
- ❌ COPY . . before dependency install — Any source file change invalidates the dependency cache. Copy package-lock.json, pom.xml, or go.sum first; install deps; copy source last.
- ❌ Shell-form CMD (CMD npm start) — Spawns /bin/sh as PID 1, which does not forward SIGTERM to your app. Containers hang for 10s then get SIGKILL. Use exec form: CMD ["node", "server.js"] or wrap with tini.
- ❌ No .dockerignore — Sends node_modules, .git, test fixtures, and .env files into the build context. Slow builds, cache busts, and accidental secret leakage into layers.
- ❌ No resource limits at runtime — A memory leak or traffic spike takes down the host. Set --memory, --cpus in Docker; deploy.resources.limits in Compose; resources in Kubernetes. Pair with JVM MaxRAMPercentage or app-level limits.
- ❌ Leaving apt/apk cache in layers (RUN apt-get install without cleanup) — Package index and /var/cache/apt add 50–100 MB permanently. Combine install + cleanup in one RUN: apt-get update && apt-get install -y pkg && rm -rf /var/lib/apt/lists/*. Better: use multi-stage and don't install package managers in the runtime stage at all.
Anti-pattern Dockerfile (do not ship this)
# ❌ Everything wrong — for contrast only
FROM node:latest
WORKDIR /app
COPY . .
ENV API_KEY=sk-live-do-not-commit
RUN apt-get update && apt-get install -y build-essential
RUN npm install
RUN npm run build
CMD npm start
Running hadolint in CI catches many of these automatically. Add hadolint --failure-threshold error Dockerfile to your pipeline alongside docker build.
When asked "how do you optimize Docker builds," lead with layer ordering and multi-stage, then mention BuildKit cache mounts and .dockerignore. Close with security: non-root, pinned tags, no secrets in layers. That sequence shows you've shipped real containers, not just read docs.