Docker makes it easy to containerize applications. Docker makes it equally easy to create bloated, insecure, slow-to-build images. The difference is discipline.
These practices come from running containers in production—where image size affects deployment speed, security vulnerabilities get exploited, and build times multiply across teams.
Start With the Right Base Image# Your base image choice cascades through everything else.
The options:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Full OS - 900MB+
FROM ubuntu:22.04
# Slim OS - 80MB
FROM debian:bookworm-slim
# Minimal - 5MB
FROM alpine:3.19
# Language-specific slim - varies
FROM python:3.12-slim
FROM node:20-alpine
# Distroless - minimal runtime only
FROM gcr.io/distroless/python3
General guidance:
Start with slim or alpine variants Use distroless for production when possible Full OS images only when you need specific tools 1
2
3
4
5
6
7
8
9
# Instead of
FROM python:3.12
# Use
FROM python:3.12-slim
# Instead of
FROM node:20
# Use
FROM node:20-alpine
Multi-Stage Builds# Build in one stage, run in another. Keep build tools out of production images.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY --from= builder /app/dist ./dist
COPY --from= builder /app/node_modules ./node_modules
USER node
CMD [ "node" , "dist/server.js" ]
Results:
Build image: ~800MB (has dev dependencies, source) Production image: ~150MB (only runtime needs) For compiled languages, even better:
1
2
3
4
5
6
7
8
9
10
11
12
# Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED = 0 go build -o server .
# Production - just the binary
FROM scratch
COPY --from= builder /app/server /server
CMD [ "/server" ]
Final image: ~10MB (just a static binary).
Layer Caching# Docker caches layers. Order your Dockerfile to maximize cache hits.
Bad order (cache invalidated on every code change):
1
2
3
4
5
FROM node:20-alpine
WORKDIR /app
COPY . . # Everything copied first
RUN npm ci # Reinstalls every time
CMD [ "node" , "server.js" ]
Good order (dependencies cached separately):
1
2
3
4
5
6
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./ # Dependencies first
RUN npm ci # Cached unless package.json changes
COPY . . # Code changes don't bust dep cache
CMD [ "node" , "server.js" ]
Layer caching rules:
Put things that change rarely at the top Put things that change often at the bottom Combine related commands to reduce layers Reduce Image Size# Smaller images = faster pulls, smaller attack surface, cheaper storage.
Combine RUN commands:
1
2
3
4
5
6
7
8
9
# Bad - 3 layers, apt cache retained
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Good - 1 layer, clean in same command
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
Use .dockerignore:
# . n . t d . g o . e e o g . i d m n s c i d t e d v t s t o _ * s h c m u k o b e d / r u i l g e n s o r e Remove unnecessary files:
1
2
RUN pip install --no-cache-dir -r requirements.txt
RUN npm ci --omit= dev
Check what’s taking space:
1
2
3
4
5
# Analyze image layers
docker history myimage:latest
# Detailed breakdown
docker run --rm -it wagoodman/dive myimage:latest
Security Hardening# Don’t Run as Root# 1
2
3
4
5
6
7
8
9
10
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Set ownership
COPY --chown= appuser:appgroup . .
# Switch to non-root
USER appuser
CMD [ "./app" ]
1
2
3
4
5
6
7
8
# Bad - "latest" changes unpredictably
FROM python:latest
# Better - major version
FROM python:3.12
# Best - specific digest
FROM python:3.12-slim@sha256:abc123...
Scan for Vulnerabilities# 1
2
3
4
5
6
7
8
# Docker Scout (built-in)
docker scout cves myimage:latest
# Trivy
trivy image myimage:latest
# Snyk
snyk container test myimage:latest
Build scanning into CI:
1
2
3
4
5
6
7
# GitHub Actions
- name : Scan image
uses : aquasecurity/trivy-action@master
with :
image-ref : myimage:${{ github.sha }}
exit-code : 1
severity : CRITICAL,HIGH
Don’t Store Secrets in Images# 1
2
3
4
5
6
# NEVER do this
ENV DATABASE_PASSWORD = secret123
COPY .env /app/.env
# Instead, pass at runtime
# docker run -e DATABASE_PASSWORD=$SECRET myimage
For build-time secrets:
1
2
3
# Docker BuildKit secrets
RUN --mount= type = secret,id= npm_token \
NPM_TOKEN = $( cat /run/secrets/npm_token) npm ci
1
docker build --secret id = npm_token,src= .npmrc .
Health Checks# Let Docker know if your container is healthy:
1
2
HEALTHCHECK --interval=30s --timeout=3s --start-period= 5s --retries= 3 \
CMD curl -f http://localhost:8080/health || exit 1
For containers without curl:
1
2
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries= 1 --spider http://localhost:8080/health || exit 1
Or use a dedicated health check binary:
1
2
COPY healthcheck /usr/local/bin/
HEALTHCHECK CMD [ "healthcheck" ]
Proper Signal Handling# Containers receive SIGTERM on shutdown. Your app must handle it.
The problem:
1
2
3
# Shell form - runs through /bin/sh
CMD npm start
# SIGTERM goes to sh, not your app
The solution:
1
2
3
# Exec form - runs directly
CMD [ "node" , "server.js" ]
# SIGTERM goes to node
For scripts that must run through shell:
1
2
# Use exec to replace shell with your process
CMD [ "sh" , "-c" , "exec node server.js" ]
Or use tini as init:
1
2
3
RUN apk add --no-cache tini
ENTRYPOINT [ "/sbin/tini" , "--" ]
CMD [ "node" , "server.js" ]
Build Arguments vs Environment Variables# ARG : Build-time only, not in final image
ENV : Build-time and runtime, persists in image
1
2
3
4
5
6
7
8
9
10
11
12
13
# Build configuration
ARG NODE_ENV = production
ARG BUILD_VERSION
# Runtime configuration
ENV NODE_ENV = ${ NODE_ENV }
ENV PORT = 8080
# ARG values don't persist
RUN echo "Building version: ${ BUILD_VERSION } "
# ENV values do persist
# PORT will be 8080 in running container
1
2
docker build --build-arg BUILD_VERSION = 1.2.3 .
docker run -e PORT = 3000 myimage # Override ENV at runtime
Logging# Write to stdout/stderr, not files:
1
2
3
4
5
# Bad - logs trapped in container
CMD [ "./app" , "--log-file=/var/log/app.log" ]
# Good - logs accessible via docker logs
CMD [ "./app" ]
1
2
3
4
5
6
7
8
9
# In your app
import sys
import logging
logging . basicConfig (
stream = sys . stdout ,
level = logging . INFO ,
format = ' %(asctime)s %(levelname)s %(message)s '
)
Docker captures stdout/stderr and makes them available via docker logs and logging drivers.
Complete Example# Putting it all together:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# syntax=docker/dockerfile:1
# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libpq-dev && \
rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# Production stage
FROM python:3.12-slim
# Security: Create non-root user
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
WORKDIR /app
# Install runtime dependencies only
RUN apt-get update && \
apt-get install -y --no-install-recommends libpq5 curl && \
rm -rf /var/lib/apt/lists/*
# Install Python packages from wheels
COPY --from= builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
# Copy application
COPY --chown= appuser:appgroup . .
# Security: Switch to non-root user
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period= 10s --retries= 3 \
CMD curl -f http://localhost:8080/health || exit 1
# Runtime configuration
ENV PORT = 8080
EXPOSE 8080
# Run with exec form for proper signal handling
CMD [ "python" , "-m" , "uvicorn" , "app:app" , "--host" , "0.0.0.0" , "--port" , "8080" ]
Quick Reference# Practice Why Use slim/alpine base Smaller images Multi-stage builds Exclude build tools Order for cache Faster builds .dockerignore Smaller context Non-root user Security Specific tags Reproducibility Health checks Orchestration Exec form CMD Signal handling Stdout logging Log aggregation
Build images like they’re going to production—because eventually, they will.
A container is only as good as its Dockerfile. Make it count.