Dockerfile Best Practices
Dockerfile Best Practices
Multi-Stage Builds for Production
Multi-stage builds drastically reduce final image size by separating build-time and run-time dependencies. The build stage contains compilers, dev tools, and source code, while the production stage only includes compiled artifacts.
# Stage 1: Install dependencies only
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user for security
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
# Copy only what is needed
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
This approach produces a final image that is typically 60-80% smaller than a single-stage build. The deps stage caches npm ci so rebuilds only happen when package.json or package-lock.json change.
Layer Caching Optimization
Docker builds each instruction as a layer. Layers that change infrequently should come earlier in the Dockerfile so they are cached across builds.
# BAD: source code change invalidates npm ci cache
COPY . .
RUN npm ci
# GOOD: copy dependency manifests first
COPY package.json package-lock.json ./
RUN npm ci
# Now source changes only invalidate the COPY . layer
COPY . .
.dockerignore
A .dockerignore file prevents unnecessary files from being sent to the Docker daemon, reducing build context size and avoiding secrets leaking into images.
node_modules
.git
.env
.env.*
*.md
test
e2e
.github
docker-compose*.yml
Dockerfile*
.dockerignore
npm-debug.log
coverage
.nyc_output
Layer Consolidation
Each RUN instruction creates a new layer. Consolidate related commands to reduce layers and clean up in the same layer that installs packages.
# BAD: three layers, apt cache persists
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean
# GOOD: single layer, apt cache cleaned
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
Docker Compose for Multi-Service Apps
Docker Compose for Multi-Service Apps
Development Environment Setup
Docker Compose defines and runs multi-container applications with a single YAML file. Each service runs in its own container with its own image, volumes, and network.
# docker-compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
target: builder
ports:
- '3000:3000'
volumes:
- .:/app
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
- REDIS_URL=redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: npm run dev
networks:
- backend
db:
image: postgres:15-alpine
ports:
- '5432:5432'
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 5
networks:
- backend
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
- redis_data:/data
networks:
- backend
volumes:
postgres_data:
redis_data:
networks:
backend:
driver: bridge
Development vs Production Overrides
Use separate compose files to override settings per environment without modifying the base file.
# docker-compose.dev.yml
services:
app:
environment:
- NODE_ENV=development
- DEBUG=app:*
volumes:
- ./src:/app/src
# docker-compose.prod.yml
services:
app:
build:
target: runner
environment:
- NODE_ENV=production
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Common Compose Commands
docker compose up -d # start in detached mode
docker compose logs -f app # follow logs for a service
docker compose exec app sh # shell into a running container
docker compose down -v # stop and remove volumes
docker compose ps # list running services
docker compose build --no-cache # rebuild without cache
docker compose config # validate and display resolved config
Health Checks and Container Reliability
Health Checks and Container Reliability
Application Health Endpoints
Expose health and readiness endpoints in your application so Docker and orchestrators can determine container status.
// health.ts
import express from 'express';
import { db } from './database';
import { redis } from './cache';
const router = express.Router();
router.get('/health', async (_req, res) => {
try {
await db.query('SELECT 1');
await redis.ping();
res.json({
status: 'healthy',
uptime: process.uptime(),
timestamp: new Date().toISOString()
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
});
router.get('/ready', async (_req, res) => {
const checks = await Promise.allSettled([
db.query('SELECT 1'),
redis.ping(),
]);
const healthy = checks.every(c => c.status === 'fulfilled');
res.status(healthy ? 200 : 503).json({ ready: healthy });
});
export default router;
Dockerfile Health Checks
The HEALTHCHECK instruction tells Docker how to test whether a container is still working. Docker marks the container as unhealthy if the check fails consecutively.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
interval: Time between checks (default 30s)timeout: Max time for a single check (default 30s)start-period: Grace period before checks count (default 0s)retries: Consecutive failures before unhealthy (default 3)
Graceful Shutdown
Handle SIGTERM to drain connections and finish in-flight requests before the container stops.
import { server } from './app';
function gracefulShutdown(signal: string) {
console.log(`Received ${signal}. Starting graceful shutdown...`);
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
// Force close after 10 seconds
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Resource Limits
Prevent a single container from consuming all host resources.
services:
app:
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
Centralized Logging
Use the JSON log driver and collect logs with a centralized solution.
services:
app:
logging:
driver: json-file
options:
max-size: '10m'
max-file: '5'
For production, consider using the fluentd or gelf driver to ship logs to ELK, Datadog, or CloudWatch.
Docker Security Hardening
Docker Security Hardening
Non-Root Users
Running containers as root gives the process full access to the container filesystem and potential kernel exploits. Always create and switch to a non-root user.
FROM node:20-alpine
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Read-Only Filesystem
Mount the container filesystem as read-only to prevent tampering. Use tmpfs for writable directories like /tmp.
docker run --read-only --tmpfs /tmp:rw,noexec,nosuid myapp:latest
Secret Management
Never bake secrets into images or store them in environment variables committed to version control. Use Docker secrets or a runtime vault.
# docker-compose.yml with secrets
services:
app:
image: myapp:latest
secrets:
- db_password
- jwt_key
secrets:
db_password:
file: ./secrets/db_password.txt
jwt_key:
file: ./secrets/jwt_key.txt
# Access secrets at /run/secrets/<name>
RUN --mount=type=secret,id=db_password \
DB_PASS=$(cat /run/secrets/db_password) && \
echo "DB configured"
Image Scanning
Scan images for known vulnerabilities before deploying to production.
# Docker Scout (built into Docker Desktop)
docker scout cves myapp:latest
# Trivy (open-source)
trivy image myapp:latest
# Grype
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
anchore/grype myapp:latest
Security Checklist
# Use specific base image versions, never :latest
FROM node:20.9.0-alpine
# Remove unnecessary packages
RUN apk add --no-cache curl && \
apk del --purge build-deps
# Set restrictive permissions
RUN chmod 400 /app/config/secrets.json
# Use COPY instead of ADD (ADD has auto-extraction features)
COPY package.json ./
# Set filesystem ownership
COPY --chown=appuser:appgroup . .
Network Isolation
Use Docker networks to isolate service communication. Services that do not need to communicate should be on separate networks.
networks:
frontend:
driver: bridge
backend:
driver: bridge
services:
nginx:
networks:
- frontend
app:
networks:
- frontend
- backend
db:
networks:
- backend
Volumes and Data Persistence
Volumes and Data Persistence
Types of Storage
Docker offers three volume mounting options. Named volumes are managed by Docker and are the recommended approach for persistent data.
# Named volumes (Docker-managed)
docker volume create postgres_data
docker run -v postgres_data:/var/lib/postgresql/data postgres:15
# Bind mounts (host directory)
docker run -v $(pwd)/src:/app/src myapp:latest
# tmpfs mounts (in-memory, for sensitive data)
docker run --tmpfs /tmp:rw,noexec,nosuid myapp:latest
Docker Compose Volume Configuration
services:
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d:ro
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
app:
build: .
volumes:
- ./src:/app/src
- app_uploads:/app/uploads
depends_on:
- db
volumes:
postgres_data:
driver: local
app_uploads:
driver: local
Database Persistence Example
For PostgreSQL, ensure the data directory is always mounted to a named volume to survive container restarts and removals.
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
Redis Persistence
services:
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
volumes:
redis_data:
Backup and Restore
# Backup a named volume
docker run --rm \
-v postgres_data:/source:ro \
-v $(pwd):/backup \
alpine tar czf /backup/postgres_backup.tar.gz -C /source .
# Restore from backup
docker run --rm \
-v postgres_data:/target \
-v $(pwd):/backup:ro \
alpine tar xzf /backup/postgres_backup.tar.gz -C /target
Volume Permissions
When using bind mounts, ensure the container user has write access to the mounted directory.
# Create the directory and set ownership
RUN mkdir -p /app/uploads && \
chown -R appuser:appgroup /app/uploads
USER appuser
services:
app:
volumes:
- ./uploads:/app/uploads
user: "1001:1001"
Quiz
1. What is the primary benefit of multi-stage Docker builds?
2. Why should you copy package.json before running npm ci in a Dockerfile?
3. How do you prevent a Docker container from running as root?
Flashcards
Question
What is Docker Deployment?
Click to reveal answer
Answer
Docker Deployment covers important concepts and best practices.
Question
What is Docker Deployment?
Click to reveal answer
Answer
Docker Deployment covers important concepts and best practices.
Question
What is Docker Deployment?
Click to reveal answer
Answer
Docker Deployment covers important concepts and best practices.
Revision Notes
Key Takeaways
- 1. Multi-stage builds separate build-time and run-time dependencies, producing smaller and more secure production images
- 2. Copy dependency manifests before source code to leverage Docker layer caching and speed up rebuilds
- 3. Always create non-root users in Dockerfiles and use the USER instruction to run processes with limited privileges
- 4. Use Docker Compose with override files to manage environment-specific configurations without modifying the base file
- 5. Implement health checks at both the Docker and application levels to enable automatic container health monitoring
- 6. Store persistent data in named volumes, not bind mounts, to survive container removal and enable backup strategies
- 7. Never bake secrets into images; use Docker secrets, environment injection at runtime, or vault solutions
Interview Tips
- • Explain how Docker layer caching works and how to structure Dockerfiles to maximize cache hits
- • Describe the difference between ADD and COPY instructions and when to use each
- • Discuss strategies for reducing Docker image size including multi-stage builds, Alpine base images, and .dockerignore
- • Explain how to handle database migrations and persistent storage in Docker environments
- • Describe container orchestration concepts and how Docker Compose fits into a production deployment pipeline
- • Discuss Docker security best practices including image scanning, network isolation, and secret management
Cheat Sheet
Dockerfile Instructions
FROM
Docker Compose Commands
docker compose up -d | docker compose down -v | docker compose logs -f
Image Management
docker build -t name:tag . | docker images | docker rmi name:tag | docker push name:tag
Volume Commands
docker volume create
Container Debugging
docker logs
Security
docker scout cves | trivy image
| --read-only flag | --tmpfs /tmp | --cap-drop ALL