Docker Best Practices: The Complete Guide to Professional Containerization
TL;DR: Docker is a containerization platform that packages applications with their dependencies into isolated, portable containers. Follow best practices like multi-stage builds, non-root users, and minimal base images to create secure, efficient production containers.
Docker is a containerization platform that enables developers to package applications and their dependencies into lightweight, portable containers that run consistently across any environment. Unlike virtual machines, Docker containers share the host OS kernel, making them faster to start and more resource-efficient while providing process-level isolation.
Docker has revolutionized modern software development by solving the “works on my machine” problem, enabling consistent deployments from development to production, and serving as the foundation for container orchestration systems like Kubernetes.
In this comprehensive guide, we’ll explore all the essential techniques for creating production-ready Docker images—from multi-stage builds and security hardening to health checks and resource optimization.
Docker Image Optimization
1. Choose the Right Base Image
Size matters: A smaller image means:
- Faster builds
- Faster deployments
- Smaller attack surface
- Lower storage costs
# ❌ AVOID - Too large (1.1GB)
FROM ubuntu:latest
RUN apt-get update && apt-get install -y python3 python3-pip
# ⚠️ BETTER - But still large (900MB)
FROM python:3.11
# ✅ GREAT - Much smaller (50MB)
FROM python:3.11-slim
# ✅ MINIMAL - Even smaller (15MB)
FROM python:3.11-alpine
Size comparison:
| Base image | Size | Use case |
|---|---|---|
| ubuntu:latest | ~77MB | Development, debugging |
| python:3.11 | ~900MB | When you need everything included |
| python:3.11-slim | ~120MB | Default choice |
| python:3.11-alpine | ~50MB | Production, microservices |
When to use Alpine:
✅ Microservices with few dependencies ✅ When every MB counts ❌ Applications with complex native dependencies (Alpine uses musl instead of glibc)
2. Multi-Stage Builds
Multi-stage builds drastically reduce the final image size:
# ❌ SINGLE-STAGE - Final image: 1.2GB
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
# ✅ MULTI-STAGE - Final image: 150MB
# Stage 1: Build
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:18-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
CMD ["node", "dist/index.js"]
A more complex example - Go application:
# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Static build (no C dependencies)
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Stage 2: Production
FROM alpine:latest
# Install CA certificates for HTTPS
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy only the binary
COPY --from=builder /app/main .
# Expose port
EXPOSE 8080
CMD ["./main"]
Result: From 800MB down to 15MB!
3. Layer Caching and Ordering
Docker creates a layer for every instruction. Order the instructions from least frequently to most frequently changed:
# ❌ INEFFICIENT - Invalidates cache on every code change
FROM node:18-slim
WORKDIR /app
COPY . . # Invalidates cache often
RUN npm install # Full reinstall every time
CMD ["npm", "start"]
# ✅ OPTIMIZED - Efficient cache
FROM node:18-slim
WORKDIR /app
# 1. Dependencies first (change rarely)
COPY package*.json ./
RUN npm install
# 2. Then the code (changes often)
COPY . .
CMD ["npm", "start"]
With this structure:
- If you only change the code:
npm installcache is reused ⚡ - Only if
package.jsonchanges: dependencies are reinstalled
4. .dockerignore
Like .gitignore, but for Docker:
# .dockerignore
# Dependencies
node_modules
npm-debug.log
vendor/
# Build outputs
dist
build
*.min.js
*.min.css
# Development
.git
.gitignore
.env.local
.env.development
*.md
Dockerfile
docker-compose.yml
# Testing
coverage
.nyc_output
*.test.js
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
Benefits:
- Faster builds (fewer files to copy)
- Smaller images
- No exposure of sensitive files
5. Minimize the Number of Layers
# ❌ TOO MANY LAYERS (3 RUN layers)
FROM python:3.11-slim
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean
# ✅ OPTIMIZED (1 RUN layer)
FROM python:3.11-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
A well-formatted complex command:
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
curl \
git \
vim && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
pip install --no-cache-dir \
numpy \
pandas \
scikit-learn
Security
1. Don’t Run as Root
# ❌ INSECURE - Runs as root
FROM node:18-slim
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]
# ✅ SECURE - Unprivileged user
FROM node:18-slim
# Create non-root user
RUN groupadd -r appuser && \
useradd -r -g appuser -s /bin/false appuser
WORKDIR /app
# Install as root (necessary)
COPY package*.json ./
RUN npm install
# Copy code
COPY . .
# Change ownership
RUN chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
CMD ["npm", "start"]
For Alpine Linux:
RUN addgroup -S appuser && \
adduser -S appuser -G appuser
USER appuser
2. Scan for Vulnerabilities
# Scan with Trivy
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image myapp:latest
# Scan with Snyk
snyk container test myapp:latest
# Docker Scout (built-in)
docker scout cves myapp:latest
Integrate into CI/CD:
# GitHub Actions
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Fail on HIGH/CRITICAL vulnerabilities
run: |
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:latest
3. Use Verified Images
# ✅ Specify exact versions
FROM node:18.17.1-slim
# ❌ AVOID latest and floating tags
FROM node:latest
FROM node:18
4. Secret Management
# ❌ NEVER hardcode secrets
FROM node:18-slim
ENV DATABASE_PASSWORD=supersecret123 # BAD!
ENV API_KEY=abc123xyz # BAD!
# ✅ Use build arguments (build-time only)
FROM node:18-slim
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc && \
npm install && \
rm -f ~/.npmrc
# ✅ Or Docker secrets (runtime)
# In docker-compose.yml:
# secrets:
# - db_password
# Then read from /run/secrets/db_password
Build with secrets (Docker BuildKit):
# syntax=docker/dockerfile:1
FROM node:18-slim
# Mount secret during build (not saved in the image!)
RUN --mount=type=secret,id=npm_token \
echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token)" > ~/.npmrc && \
npm install && \
rm -f ~/.npmrc
# Build command
docker build --secret id=npm_token,src=.npmrc -t myapp .
Health Checks
FROM node:18-slim
WORKDIR /app
COPY . .
RUN npm install
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node healthcheck.js || exit 1
CMD ["npm", "start"]
healthcheck.js:
const http = require('http');
const options = {
host: 'localhost',
port: process.env.PORT || 3000,
path: '/health',
timeout: 2000
};
const request = http.request(options, (res) => {
if (res.statusCode === 200) {
process.exit(0);
} else {
process.exit(1);
}
});
request.on('error', () => {
process.exit(1);
});
request.end();
Logging Best Practices
# ✅ Log to stdout/stderr
FROM node:18-slim
# Don't write logs to a file, use stdout
ENV NODE_ENV=production
CMD ["node", "server.js"] # Logs go to stdout
In the code:
// ✅ Log to stdout (Docker captures it automatically)
console.log('Server started on port 3000');
console.error('Database connection failed');
// ❌ AVOID file logs (problematic with ephemeral containers)
fs.appendFile('/var/log/app.log', 'message'); // BAD
Collecting logs:
# View logs
docker logs mycontainer
# Follow logs
docker logs -f mycontainer
# Last 100 lines
docker logs --tail 100 mycontainer
# With timestamps
docker logs -t mycontainer
Networking
Docker Compose Example
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
networks:
- frontend
- backend
environment:
- NODE_ENV=production
depends_on:
db:
condition: service_healthy
db:
image: postgres:15-alpine
networks:
- backend
volumes:
- db-data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
networks:
- frontend
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- app
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # Internal communication only
volumes:
db-data:
secrets:
db_password:
file: ./secrets/db_password.txt
Complete Production-Ready Dockerfile
# syntax=docker/dockerfile:1
#############
# Build Stage
#############
FROM node:18.17.1-alpine AS builder
# Metadata
LABEL maintainer="[email protected]"
LABEL version="1.0"
# Install build dependencies
RUN apk add --no-cache \
python3 \
make \
g++
WORKDIR /app
# Copy dependency files
COPY package*.json ./
COPY yarn.lock ./
# Install dependencies
RUN yarn install --frozen-lockfile --production=false
# Copy source
COPY . .
# Build application
RUN yarn build
# Prune dev dependencies
RUN yarn install --frozen-lockfile --production=true
################
# Production Stage
################
FROM node:18.17.1-alpine
# Install runtime dependencies
RUN apk add --no-cache \
dumb-init \
curl
# Create non-root user
RUN addgroup -S appuser && \
adduser -S appuser -G appuser
WORKDIR /app
# Copy built application
COPY --from=builder --chown=appuser:appuser /app/dist ./dist
COPY --from=builder --chown=appuser:appuser /app/node_modules ./node_modules
COPY --chown=appuser:appuser package*.json ./
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Start application
CMD ["node", "dist/index.js"]
Build Optimization Commands
# Build with BuildKit (faster)
DOCKER_BUILDKIT=1 docker build -t myapp:latest .
# Build with cache from registry
docker build \
--cache-from myregistry/myapp:latest \
-t myapp:latest .
# Build and push cache
docker build \
--cache-from myregistry/myapp:latest \
--cache-to type=registry,ref=myregistry/myapp:buildcache \
-t myapp:latest .
# Build for multiple platforms
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myapp:latest .
# Analyze layer sizes
docker history myapp:latest
dive myapp:latest # Tool: github.com/wagoodman/dive
Container Testing
# Test structure with Container Structure Test
container-structure-test test \
--image myapp:latest \
--config container-test.yaml
container-test.yaml:
schemaVersion: '2.0.0'
fileExistenceTests:
- name: 'Application files'
path: '/app/dist/index.js'
shouldExist: true
fileContentTests:
- name: 'Non-root user'
path: '/etc/passwd'
expectedContents: ['appuser']
commandTests:
- name: 'Node version'
command: 'node'
args: ['--version']
expectedOutput: ['v18\\.']
metadataTest:
exposedPorts: ['3000']
workdir: '/app'
user: 'appuser'
Monitoring and Observability
# Add labels for metadata
LABEL org.opencontainers.image.title="My App"
LABEL org.opencontainers.image.description="Production API"
LABEL org.opencontainers.image.version="1.0.0"
LABEL org.opencontainers.image.created="2026-08-01"
LABEL org.opencontainers.image.source="https://github.com/example/myapp"
Prometheus metrics:
// server.js
const promClient = require('prom-client');
// Default metrics
promClient.collectDefaultMetrics();
app.get('/metrics', async (req, res) => {
res.set('Content-Type', promClient.register.contentType);
res.send(await promClient.register.metrics());
});
Pre-Production Checklist
✅ Base image: Slim or Alpine
✅ Multi-stage build: Build and runtime separated
✅ Layer caching: Instruction order optimized
✅ .dockerignore: Present and complete
✅ Non-root user: Container doesn’t run as root
✅ Version pinning: No latest tag
✅ Health check: Implemented and tested
✅ Security scan: No HIGH/CRITICAL vulnerabilities
✅ Secrets: Not hardcoded in the image
✅ Logging: stdout/stderr only
✅ Resource limits: CPU and memory limited
✅ Labels: Complete metadata
✅ Testing: Container structure tests passing
Frequently Asked Questions
What’s the difference between Docker containers and virtual machines?
Containers share the host OS kernel and isolate processes, making them lightweight (MBs vs GBs) and fast to start (seconds vs minutes). Virtual machines run a complete guest OS on virtualized hardware, providing stronger isolation but consuming more resources. Containers are ideal for microservices and horizontal scaling; VMs suit legacy apps requiring specific OS versions or stronger security boundaries.
Should I use Alpine Linux or Debian-based images?
Alpine (5-50MB) is ideal for microservices, production environments where size matters, and simple applications. Use Debian/slim (120-150MB) when you need broader package compatibility, complex native dependencies, or easier debugging. Alpine uses musl libc instead of glibc, which can cause issues with some compiled binaries or Python packages with native extensions.
How do multi-stage builds reduce image size?
Multi-stage builds use separate stages for building and running. The first stage includes build tools, compilers, and dev dependencies (often 1GB+). The final stage copies only compiled artifacts and production dependencies into a minimal base image. This eliminates build tools from the final image, reducing size from ~1GB to ~50-150MB while maintaining the same functionality.
How do I securely pass secrets to Docker containers?
Never use ENV variables or ARG for secrets (they persist in image layers). Best options: (1) Docker secrets with Swarm (/run/secrets/secretname), (2) BuildKit secret mounts for build-time (--mount=type=secret), (3) external secret managers (AWS Secrets Manager, HashiCorp Vault), or (4) environment variables injected at runtime via orchestrators (Kubernetes secrets). Always use secret rotation.
Why should containers run as non-root users?
Running as root violates the principle of least privilege. If an attacker escapes the container through a vulnerability, root access gives them maximum damage potential on the host system. Non-root users limit blast radius—even if compromised, the attacker has restricted permissions. Kubernetes Pod Security Standards enforce non-root as a baseline requirement.
Conclusion
Docker is powerful but requires attention to detail. By following these best practices, you’ll achieve:
🚀 Performance: Fast builds, lightweight images 🔒 Security: Hardened, secure containers 📈 Scalability: Ready for orchestration 💰 Cost savings: Less storage, less bandwidth, less time
Remember: a well-built container is the foundation of a successful deployment!
Happy containerizing! 🐳
Published August 1, 2026 · Reading time: 15 minutes