Docker Best Practices
Multi-Stage Builds
Build artifacts in a full image; copy only the runtime output into a minimal final image. This removes build tools, source code, and dev dependencies from production containers.
# ❌ WRONG — single stage: build tools, source, and node_modules all end up in the image
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
# Final image: ~1.2 GB
# ✅ CORRECT — multi-stage: final image contains only the compiled output
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
# Final image: ~180 MB
Minimal Base Images
Choose the smallest image that satisfies your runtime requirements.
| Base | Typical Size | Use When |
|---|---|---|
ubuntu:24.04 |
~80 MB | You need apt packages and broad compatibility |
node:20-slim |
~80 MB | Node apps that need a few system libs |
node:20-alpine |
~45 MB | Node apps with no native add-ons |
gcr.io/distroless/nodejs20 |
~20 MB | Production Node — no shell, no package manager |
# ❌ WRONG — full Ubuntu for a compiled Go binary: 900 MB image
FROM ubuntu:24.04
COPY myapp /usr/local/bin/myapp
CMD ["myapp"]
# ✅ CORRECT — scratch for a statically linked binary: ~10 MB image
FROM scratch
COPY myapp /myapp
CMD ["/myapp"]
# ✅ CORRECT — distroless for a Node.js app
FROM gcr.io/distroless/nodejs20-debian12
COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/node_modules /app/node_modules
CMD ["/app/dist/server.js"]
Non-Root User
Containers run as root by default. If the application is compromised, the attacker has root inside the container — and potentially a path to the host.
# ❌ WRONG — running as root
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
# If exploited: attacker is root in container
# ✅ CORRECT — create and switch to a dedicated user
FROM node:20-slim
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs appuser
COPY --chown=appuser:nodejs --from=builder /app/dist ./dist
COPY --chown=appuser:nodejs --from=builder /app/node_modules ./node_modules
USER appuser
CMD ["node", "dist/server.js"]
.dockerignore
Without .dockerignore, every file in your project directory is sent to the Docker build context — including .env files, node_modules, .git history, and test fixtures.
# .dockerignore
.git
.gitignore
node_modules
npm-debug.log
.env
.env.*
*.local
dist
coverage
.nyc_output
.next
.turbo
Dockerfile*
docker-compose*.yml
README.md
tests
__tests__
*.test.ts
*.spec.ts
Why it matters:
- Build context size directly affects
docker buildtime and remote build transfer size .envfiles containing secrets must never reach the image layer, even if notCOPYd — they're in the build context and can be accessed by malicious build steps
Layer Caching Optimization
Docker rebuilds every layer after the first changed layer. Copy dependency manifests first, install, then copy source.
# ❌ WRONG — source copied before npm install; any code change invalidates the cache
FROM node:20-slim
WORKDIR /app
COPY . . # ← cache miss on any file change
RUN npm ci
RUN npm run build
# ✅ CORRECT — dependency install cached separately from source
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./ # ← cache miss only when deps change
RUN npm ci
COPY . . # ← source copied after deps are installed
RUN npm run build
Rule: Order Dockerfile instructions from least-frequently-changed to most-frequently-changed.
Secrets Management: Never Use ENV for Secrets
ENV values are baked into image layers and visible with docker inspect. Anyone who can pull the image can read the secrets.
# ❌ WRONG — secret visible in image history and docker inspect
# Setting DATABASE_URL or API_KEY as ENV bakes it into every image layer.
# Anyone who runs `docker inspect` or `docker history` can extract the value.
# ❌ WRONG — build ARG also leaks into image metadata
ARG API_KEY
RUN curl -H "Authorization: $API_KEY" https://api.example.com/config > config.json
# ✅ CORRECT — mount secret at build time (BuildKit); never stored in any layer
# syntax=docker/dockerfile:1
FROM node:20-slim AS builder
RUN --mount=type=secret,id=npm_token \
npm config set //registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token) \
&& npm ci
# ✅ CORRECT — inject secrets at runtime via environment, not baked into image
# docker run --env-file .env myapp
# or in docker-compose: env_file: [.env]
# docker-compose.yml — runtime env injection, not baked into the image
services:
api:
image: myapp:latest
env_file:
- .env # loaded at container start, never written into an image layer
Health Checks
Without a HEALTHCHECK, orchestrators (Kubernetes, ECS, Compose) can't tell if your container is actually ready to serve traffic — they just check that the process is running.
# ❌ WRONG — no health check; orchestrator routes traffic immediately on process start
FROM node:20-slim
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
# ✅ CORRECT — HTTP health check with sensible retries
FROM node:20-slim
COPY --from=builder /app/dist ./dist
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
// Minimal /health endpoint in your app
app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok', uptime: process.uptime() });
});
docker-compose Patterns
# ✅ CORRECT — production-safe docker-compose.yml
services:
api:
build:
context: .
target: runner # stop at the 'runner' stage in multi-stage build
image: myapp:${TAG:-latest}
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- .env # secrets injected at runtime
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
env_file:
- .env # POSTGRES_PASSWORD sourced from .env — not inline
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
Checklist
- Multi-stage build used — no build tools or source in the final image
- Minimal base image chosen for the runtime (slim, alpine, or distroless)
- Non-root user created and set with
USERdirective -
.dockerignorepresent — excludes.env,node_modules,.git, test files -
package*.jsoncopied and deps installed before source is copied (cache optimization) - No secrets in
ENVorARGinstructions — runtime injection via env_file or secrets mount -
HEALTHCHECKdefined with interval, timeout, and retries -
depends_onusescondition: service_healthywhen ordering matters - Image tagged explicitly — no bare
latesttag in production deployments -
restart: unless-stoppedset on long-running services