Docker — Practical
Docker — Practical patterns
Section titled “Docker — Practical patterns”Optimal Node.js Dockerfile
Section titled “Optimal Node.js Dockerfile”FROM node:20-alpine AS depsWORKDIR /appCOPY package*.json ./RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:20-alpine AS buildWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run build && npm prune --omit=dev
FROM node:20-alpineWORKDIR /appENV NODE_ENV=productionCOPY --from=build /app/dist ./distCOPY --from=build /app/node_modules ./node_modulesCOPY package.json ./USER nodeEXPOSE 3000HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/healthz || exit 1CMD ["node", "--enable-source-maps", "dist/server.js"]Optimal Go Dockerfile (scratch)
Section titled “Optimal Go Dockerfile (scratch)”FROM golang:1.22 AS buildWORKDIR /srcCOPY go.* ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12COPY --from=build /out/app /appUSER 65532:65532EXPOSE 8080ENTRYPOINT ["/app"]Python Dockerfile
Section titled “Python Dockerfile”FROM python:3.12-slim AS depsWORKDIR /appCOPY pyproject.toml uv.lock ./RUN pip install --no-cache-dir uv && uv sync --frozen --no-dev
FROM python:3.12-slimWORKDIR /appCOPY --from=deps /app/.venv /app/.venvCOPY . .ENV PATH="/app/.venv/bin:$PATH"ENV PYTHONUNBUFFERED=1USER 1000:1000EXPOSE 8000CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"].dockerignore
Section titled “.dockerignore”node_modules.git.github.env*distbuild*.logcoverage.vscode*.test.*README.mdCompose with multiple services
Section titled “Compose with multiple services”services: api: build: { context: ., target: dev } ports: [ "3000:3000" ] volumes: [ ".:/app", "/app/node_modules" ] command: npm run dev env_file: .env.local depends_on: pg: { condition: service_healthy } redis: { condition: service_started }
pg: image: postgres:16-alpine environment: { POSTGRES_PASSWORD: x } ports: [ "5432:5432" ] volumes: [ pgdata:/var/lib/postgresql/data ] healthcheck: test: ["CMD","pg_isready","-U","postgres"] interval: 5s
redis: image: redis:7-alpine ports: [ "6379:6379" ]
volumes: pgdata:Buildx multi-arch
Section titled “Buildx multi-arch”docker buildx create --name multi --usedocker buildx build \ --platform linux/amd64,linux/arm64 \ -t ghcr.io/org/app:1.2.3 \ --push .Resource limits at runtime
Section titled “Resource limits at runtime”docker run --rm \ --memory=512m --memory-swap=512m \ --cpus=1.5 \ --pids-limit=200 \ --read-only --tmpfs /tmp \ --user 1000:1000 \ --cap-drop=ALL --cap-add=NET_BIND_SERVICE \ --security-opt=no-new-privileges \ myimgImage size analysis
Section titled “Image size analysis”docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}'docker history myimgdocker save myimg | tar -t | head -50dive myimg # interactive layer browserVuln scan
Section titled “Vuln scan”trivy image myimg:1.2.3trivy fs . # scan project deps toogrype myimgCI build sketch (GitHub Actions)
Section titled “CI build sketch (GitHub Actions)”- uses: docker/setup-buildx-action@v3- uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}- uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: | ghcr.io/${{ github.repository }}:${{ github.sha }} ghcr.io/${{ github.repository }}:latest cache-from: type=gha cache-to: type=gha,mode=maxHealthcheck + signals
Section titled “Healthcheck + signals”// Node graceful shutdownprocess.on('SIGTERM', async () => { await server.close(); await db.end(); process.exit(0);});HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD curl -fsS http://localhost:3000/healthz || exit 1Init handling
Section titled “Init handling”# option 1: dumb-initRUN apk add --no-cache dumb-initENTRYPOINT ["dumb-init", "--"]CMD ["node", "server.js"]
# option 2: --init at runtimedocker run --init myimgRemoving cruft
Section titled “Removing cruft”# clean dangling images, stopped containers, unused networks/volumesdocker system prune -af --volumes
# full disk auditdocker system dfCommon tools
Section titled “Common tools”- dive — image layer explorer.
- hadolint — Dockerfile linter.
- trivy / grype / snyk — vuln scan.
- cosign — image signing.
- dockle — best-practice linter.
- kaniko / buildah — daemonless builds (CI).
- podman — daemonless drop-in for docker CLI.
- slim / docker-slim — image diet automation.
Deep — multi-stage Node.js + distroless
Section titled “Deep — multi-stage Node.js + distroless”FROM node:20-bookworm-slim AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm \ npm ci
FROM deps AS buildCOPY tsconfig.json ./COPY src ./srcRUN npm run build # produces /app/distRUN npm prune --omit=dev # strip devDependencies
FROM gcr.io/distroless/nodejs20-debian12 AS runtimeWORKDIR /appCOPY --from=build /app/node_modules ./node_modulesCOPY --from=build /app/dist ./distUSER 1000EXPOSE 3000CMD ["dist/server.js"]Deep — alternative: distroless from builder
Section titled “Deep — alternative: distroless from builder”FROM node:20-bookworm-slim AS buildWORKDIR /appCOPY package*.json ./RUN npm ci --omit=devCOPY . .
# Distroless runtime — no shell, no apt, no curlFROM gcr.io/distroless/nodejs20-debian12WORKDIR /appCOPY --from=build /app /appUSER nonroot # distroless ships UID 65532 'nonroot'CMD ["server.js"] # NOTE: no shell form allowedDeep — K8s “restricted” pod baseline
Section titled “Deep — K8s “restricted” pod baseline”apiVersion: v1kind: Podmetadata: { name: api }spec: securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 seccompProfile: { type: RuntimeDefault } containers: - name: api image: registry/api:1.4.2 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: { drop: [ALL] } volumeMounts: - { name: tmp, mountPath: /tmp } - { name: cache, mountPath: /app/.cache } volumes: - { name: tmp, emptyDir: {} } - { name: cache, emptyDir: {} }Deep — cache-optimised Dockerfile + .dockerignore + multi-arch
Section titled “Deep — cache-optimised Dockerfile + .dockerignore + multi-arch”FROM node:20-bookworm-slim AS depsWORKDIR /appCOPY package.json package-lock.json ./ # ← changes rarelyRUN --mount=type=cache,target=/root/.npm npm ciCOPY tsconfig.json ./COPY src ./src # ← changes often, AFTER depsRUN npm run build && npm prune --omit=dev# multi-arch build & push (single command)docker buildx build \ --platform linux/amd64,linux/arm64 \ --cache-from type=gha --cache-to type=gha,mode=max \ -t $REGISTRY/api:$SHA --push .node_modules.git.env*coverage**/*.test.tsDockerfile*Deep — production-grade Deployment skeleton
Section titled “Deep — production-grade Deployment skeleton”apiVersion: apps/v1kind: Deploymentmetadata: { name: api }spec: replicas: 3 strategy: { type: RollingUpdate, rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } } template: spec: terminationGracePeriodSeconds: 30 securityContext: { runAsNonRoot: true, runAsUser: 1000, seccompProfile: { type: RuntimeDefault } } containers: - name: api image: registry/api@sha256:abc123... ports: [{ containerPort: 8080 }] resources: requests: { cpu: 200m, memory: 256Mi } limits: { cpu: 1, memory: 512Mi } livenessProbe: { httpGet: { path: /healthz, port: 8080 }, periodSeconds: 10, failureThreshold: 3 } readinessProbe: { httpGet: { path: /ready, port: 8080 }, periodSeconds: 5, failureThreshold: 2 } startupProbe: { httpGet: { path: /healthz, port: 8080 }, periodSeconds: 5, failureThreshold: 30 } lifecycle: # Distroless-safe: no /bin/sh. This endpoint should mark readiness false and start draining. preStop: { httpGet: { path: /drain, port: 8080 } } securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: { drop: [ALL] } volumeMounts: - { name: tmp, mountPath: /tmp } volumes: - { name: tmp, emptyDir: {} }