Well-crafted Docker images are smaller, faster, and more secure. This guide covers best practices for building production-ready containers.
Multi-Stage Builds#
Node.js Application#
1# Build stage
2FROM node:20-alpine AS builder
3
4WORKDIR /app
5
6# Copy package files first (better layer caching)
7COPY package.json pnpm-lock.yaml ./
8RUN corepack enable && pnpm install --frozen-lockfile
9
10# Copy source and build
11COPY . .
12RUN pnpm build
13
14# Prune dev dependencies
15RUN pnpm prune --prod
16
17# Production stage
18FROM node:20-alpine AS runner
19
20WORKDIR /app
21
22# Create non-root user
23RUN addgroup --system --gid 1001 nodejs && \
24 adduser --system --uid 1001 appuser
25
26# Copy only production artifacts
27COPY /app/dist ./dist
28COPY /app/node_modules ./node_modules
29COPY /app/package.json ./
30
31USER appuser
32
33EXPOSE 3000
34
35CMD ["node", "dist/index.js"]Go Application#
1# Build stage
2FROM golang:1.22-alpine AS builder
3
4WORKDIR /app
5
6# Download dependencies
7COPY go.mod go.sum ./
8RUN go mod download
9
10# Build binary
11COPY . .
12RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./cmd/server
13
14# Production stage - scratch for minimal image
15FROM scratch
16
17# Copy CA certificates for HTTPS
18COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
19
20# Copy binary
21COPY /app/server /server
22
23EXPOSE 8080
24
25ENTRYPOINT ["/server"]Layer Optimization#
Order Instructions by Change Frequency#
1# Base image changes rarely
2FROM node:20-alpine
3
4WORKDIR /app
5
6# System dependencies change rarely
7RUN apk add --no-cache tini
8
9# Package files change occasionally
10COPY package.json pnpm-lock.yaml ./
11RUN corepack enable && pnpm install --frozen-lockfile
12
13# Source code changes frequently
14COPY . .
15
16RUN pnpm build
17
18ENTRYPOINT ["/sbin/tini", "--"]
19CMD ["node", "dist/index.js"]Minimize Layer Count#
1# Bad: Multiple RUN commands
2RUN apt-get update
3RUN apt-get install -y curl
4RUN apt-get install -y git
5RUN rm -rf /var/lib/apt/lists/*
6
7# Good: Single RUN command
8RUN apt-get update && \
9 apt-get install -y --no-install-recommends \
10 curl \
11 git && \
12 rm -rf /var/lib/apt/lists/*Use .dockerignore#
1# .dockerignore
2node_modules
3npm-debug.log
4.git
5.gitignore
6.env
7.env.*
8Dockerfile
9docker-compose*.yml
10.dockerignore
11README.md
12.vscode
13coverage
14.nyc_output
15dist
16*.logSecurity Hardening#
Run as Non-Root User#
1FROM node:20-alpine
2
3# Create app user
4RUN addgroup -g 1001 -S appgroup && \
5 adduser -u 1001 -S appuser -G appgroup
6
7WORKDIR /app
8
9COPY . .
10
11# Switch to non-root user
12USER appuser
13
14CMD ["node", "index.js"]Use Minimal Base Images#
1# Instead of full images
2FROM node:20 # ~1GB
3FROM python:3.12 # ~1GB
4
5# Use slim or alpine variants
6FROM node:20-slim # ~200MB
7FROM node:20-alpine # ~130MB
8FROM python:3.12-slim # ~150MB
9
10# Or distroless for even smaller images
11FROM gcr.io/distroless/nodejs20-debian12Scan for Vulnerabilities#
1# Build with security scanning
2FROM node:20-alpine AS builder
3
4# ... build steps ...
5
6# Scan stage
7FROM aquasec/trivy:latest AS scanner
8COPY /app /app
9RUN trivy filesystem --exit-code 1 --severity HIGH,CRITICAL /app
10
11# Final stage
12FROM node:20-alpine
13COPY /app /appDon't Store Secrets in Images#
1# Bad: Secret in image
2ENV API_KEY=secret123
3
4# Good: Use runtime secrets
5# docker run -e API_KEY=secret123 myapp
6
7# Or use Docker secrets
8# docker run --secret source=api_key,target=/run/secrets/api_key myappHealth Checks#
1FROM node:20-alpine
2
3WORKDIR /app
4COPY . .
5RUN npm ci && npm run build
6
7EXPOSE 3000
8
9# Add health check
10HEALTHCHECK \
11 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
12
13CMD ["node", "dist/index.js"]Handling Signals#
1FROM node:20-alpine
2
3# Use tini as init system
4RUN apk add --no-cache tini
5
6WORKDIR /app
7COPY . .
8
9# Tini handles signals properly
10ENTRYPOINT ["/sbin/tini", "--"]
11CMD ["node", "index.js"]1// Handle signals in your app
2process.on('SIGTERM', () => {
3 console.log('SIGTERM received, shutting down gracefully');
4 server.close(() => {
5 console.log('Server closed');
6 process.exit(0);
7 });
8});
9
10process.on('SIGINT', () => {
11 console.log('SIGINT received, shutting down gracefully');
12 server.close(() => {
13 process.exit(0);
14 });
15});Caching Dependencies#
Node.js with BuildKit Cache#
1# syntax=docker/dockerfile:1
2FROM node:20-alpine
3
4WORKDIR /app
5
6# Use BuildKit cache mount
7RUN \
8 npm ci
9
10COPY . .
11RUN npm run buildPython with pip Cache#
1# syntax=docker/dockerfile:1
2FROM python:3.12-slim
3
4WORKDIR /app
5
6COPY requirements.txt .
7
8# Cache pip downloads
9RUN \
10 pip install -r requirements.txt
11
12COPY . .Docker Compose for Development#
1# docker-compose.yml
2version: '3.8'
3
4services:
5 app:
6 build:
7 context: .
8 dockerfile: Dockerfile.dev
9 volumes:
10 - .:/app
11 - /app/node_modules # Preserve node_modules
12 ports:
13 - "3000:3000"
14 environment:
15 - NODE_ENV=development
16 - DATABASE_URL=postgres://postgres:password@db:5432/app
17 depends_on:
18 db:
19 condition: service_healthy
20
21 db:
22 image: postgres:16-alpine
23 environment:
24 POSTGRES_PASSWORD: password
25 POSTGRES_DB: app
26 volumes:
27 - postgres_data:/var/lib/postgresql/data
28 healthcheck:
29 test: ["CMD-SHELL", "pg_isready -U postgres"]
30 interval: 5s
31 timeout: 5s
32 retries: 5
33
34volumes:
35 postgres_data:Development Dockerfile#
1# Dockerfile.dev
2FROM node:20-alpine
3
4WORKDIR /app
5
6# Install development dependencies
7RUN apk add --no-cache git
8
9COPY package.json pnpm-lock.yaml ./
10RUN corepack enable && pnpm install
11
12# Don't copy source - use volume mount
13EXPOSE 3000
14
15CMD ["pnpm", "dev"]Production Patterns#
Environment-Specific Builds#
1# Use build arguments
2ARG NODE_ENV=production
3
4FROM node:20-alpine
5
6ENV NODE_ENV=${NODE_ENV}
7
8WORKDIR /app
9
10COPY package.json pnpm-lock.yaml ./
11RUN corepack enable && \
12 if [ "$NODE_ENV" = "production" ]; then \
13 pnpm install --frozen-lockfile --prod; \
14 else \
15 pnpm install --frozen-lockfile; \
16 fi
17
18COPY . .
19
20RUN if [ "$NODE_ENV" = "production" ]; then pnpm build; fi
21
22CMD ["node", "dist/index.js"]Labels and Metadata#
1FROM node:20-alpine
2
3LABEL org.opencontainers.image.source="https://github.com/myorg/myapp"
4LABEL org.opencontainers.image.description="My Application"
5LABEL org.opencontainers.image.version="1.0.0"
6LABEL org.opencontainers.image.vendor="My Company"
7
8# ... rest of DockerfileDebugging#
1# Add debug tools to development image
2FROM node:20-alpine AS base
3
4FROM base AS development
5RUN apk add --no-cache \
6 curl \
7 vim \
8 htop
9
10FROM base AS production
11# Minimal production imageCommon Mistakes#
1. Using Latest Tag#
# Bad: Unpredictable builds
FROM node:latest
# Good: Pin specific version
FROM node:20.11.0-alpine3.192. Running as Root#
1# Bad: Default root user
2FROM node:20-alpine
3CMD ["node", "index.js"]
4
5# Good: Explicit non-root user
6FROM node:20-alpine
7USER node
8CMD ["node", "index.js"]3. Not Cleaning Up#
1# Bad: Leaves cache
2RUN apt-get update && apt-get install -y curl
3
4# Good: Clean up in same layer
5RUN apt-get update && \
6 apt-get install -y --no-install-recommends curl && \
7 rm -rf /var/lib/apt/lists/*Conclusion#
Good Docker practices lead to smaller, faster, more secure containers. Use multi-stage builds, optimize layers, run as non-root, and scan for vulnerabilities. Your production containers should be minimal and immutable.