Docker Production Best Practices: Security, Performance, and Reliability
Docker has revolutionized how we deploy applications, but running containers in production requires more than just docker run. In this guide, we’ll cover the essential practices for secure, performant, and reliable container deployments.
Image Optimization: Smaller is Safer
Every container image you deploy should be as small as possible. Smaller images mean faster deployments, reduced attack surface, and lower resource usage.
Multi-Stage Builds
Multi-stage builds are the most effective way to reduce image size. Here’s a production-ready example for a Go application:
# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o main .
# Production stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]
This approach reduces a typical Go image from ~1GB to ~15MB.
Base Image Selection
Choose your base image carefully:
- Alpine: Minimal, secure, but musl libc can cause compatibility issues
- Distroless: Google’s minimal images, excellent for production
- Scratch: Empty image, only for statically compiled binaries
For most applications, alpine or distroless provides the best balance.
Security Hardening
Run as Non-Root
By default, containers run as root — a significant security risk. Always specify a non-root user:
FROM alpine:latest
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
COPY --chown=appuser:appgroup ./app /app
WORKDIR /app
Read-Only Filesystems
Mount the root filesystem as read-only to prevent runtime modifications:
docker run --read-only --tmpfs /tmp myapp
For Kubernetes, use the securityContext:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
Resource Limits
Prevent resource exhaustion with limits:
docker run \
--memory="512m" \
--cpus="1.0" \
--pids-limit 100 \
myapp
Secrets Management
Never bake secrets into images. Use Docker secrets or environment variables injected at runtime:
# docker-compose.yml
version: '3.8'
services:
app:
image: myapp
secrets:
- db_password
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
db_password:
external: true
For production orchestration, use HashiCorp Vault or cloud provider secret managers.
Health Checks and Restart Policies
Every production container needs health checks:
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
Configure restart policies for automatic recovery:
docker run --restart unless-stopped myapp
Logging and Monitoring
Centralize container logs:
docker run --log-driver=syslog --log-opt syslog-address=tcp://logserver:514 myapp
For production, use a proper logging stack:
- Fluentd/Fluent Bit: Log collection and forwarding
- Loki: Lightweight log aggregation
- ELK Stack: Full-featured logging and analysis
Network Security
Isolate containers with custom networks:
# Create isolated network
docker network create --internal backend
# Frontend can reach internet, backend cannot
docker run --network frontend myapp-frontend
docker run --network backend myapp-backend
Use network policies in Kubernetes for fine-grained control:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-policy
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
Production Checklist
Before deploying to production:
- Images scanned for vulnerabilities (Trivy, Snyk)
- Non-root user configured
- Resource limits set
- Health checks implemented
- Secrets managed externally
- Logging configured
- Monitoring/alerting in place
- Backup strategy defined
- Rollback plan tested
Vulnerability Scanning in the Pipeline
Shipping an unscanned image to production is asking for trouble. Base images accumulate CVEs over time, and your dependencies do too. Integrate scanning into CI so a vulnerable image never reaches the registry.
# Scan with Trivy (fast, free)
trivy image --severity HIGH,CRITICAL myapp:latest
# Fail the build on critical findings
trivy image --exit-code 1 --severity CRITICAL myapp:latest
In GitHub Actions, run the scan right after the build:
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image --exit-code 1 --severity CRITICAL \
myapp:${{ github.sha }}
Re-scan running images on a schedule. A base image that was clean last month may have a critical CVE today. We re-scan all production images weekly and alert on new critical findings. This catches the case where a deployed image becomes vulnerable after deployment.
Layer Caching and Build Speed
Slow builds kill developer velocity. Order your Dockerfile from least-frequently-changed to most-frequently-changed so Docker’s layer cache does the work.
FROM node:20-alpine
# Dependencies change rarely - cached across builds
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Source changes often - only this layer rebuilds
COPY . .
CMD ["node", "server.js"]
Use BuildKit’s cache mounts for even faster dependency installs:
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
On a typical Node service, this cut our CI build time from four minutes to forty seconds. The cache mount persists the npm cache between builds, so npm ci only downloads what changed.
Graceful Shutdown and Signal Handling
Containers that ignore SIGTERM leave connections dangling and corrupt data. Node and other runtimes don’t forward signals to your app by default when it runs as PID 1.
Use tini or dumb-init as the entrypoint:
FROM node:20-alpine
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
Then handle SIGTERM in your app so it drains connections before exiting:
process.on('SIGTERM', async () => {
console.log('SIGTERM received, draining connections');
server.close(async () => {
await db.end();
process.exit(0);
});
// Force exit after 10s if drain hangs
setTimeout(() => process.exit(1), 10000);
});
Without this, a rolling update drops in-flight requests. With it, Kubernetes can terminate a pod cleanly and traffic shifts without errors. If you’re also running web-facing services, pair this with the load balancer and health-check patterns in our Go microservices guide.
Image Provenance and Supply Chain
Pulling base images from public registries means trusting whoever published them. A compromised base image compromises every container built on it. Pin base images by digest, not just tag, so you get exactly the bits you reviewed:
# Pinned by digest - immutable
FROM node:20-alpine@sha256:1a2b3c4d5e6f...
Tags are mutable — node:20-alpine today may differ from last month. Digests are not. Combine pinning with a private registry mirror for images you depend on, so an upstream outage or a pulled image can’t break your builds. Verify signatures where available with cosign verify, and keep a record of which digests are deployed so a rollback returns to a known-good image, not just a known-good tag.
Conclusion
Docker in production requires attention to security, performance, and reliability. The practices outlined here — minimal images, non-root execution, proper secrets management, and comprehensive monitoring — form the foundation of a robust container platform.
Start with these fundamentals, measure everything, and iterate based on your specific workload requirements. Containers are a powerful tool, but only when deployed with the same rigor as traditional infrastructure.