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.

developer devops BuildKit Docker 24+

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

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:

dockerfile
# 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

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
💡 Pro Tip

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.

🔒 Security

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

text
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

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"]
🔬 Under the Hood

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.

⚠️ Pitfall

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)

dockerfile
# 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)

dockerfile
# 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)

text
fastapi==0.115.6
uvicorn[standard]==0.34.0
gunicorn==23.0.0
pydantic==2.10.4
⚖️ Trade-off

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

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

bash
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag myregistry/myapp:v1.2.3 \
  --push .
💡 Pro Tip

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.

🎯 Interview Tip

"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

nginx
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

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;"]
📦 Real World

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 tagnode: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 layersENV 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)

dockerfile
# ❌ 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
⚠️ Pitfall

Running hadolint in CI catches many of these automatically. Add hadolint --failure-threshold error Dockerfile to your pipeline alongside docker build.

🎯 Interview Tip

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.