Why Container Hygiene Matters
A bloated, insecure Dockerfile might work fine in development but causes slow deploys, larger attack surfaces, and higher registry costs in production. A few consistent habits fix most of these problems.
Use Multi-Stage Builds
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Multi-stage builds keep build tools out of your final image, often cutting image size by more than half.
Run as a Non-Root User
Most base images run as root by default. Explicitly switching to a non-root user (as above with USER node) limits the blast radius if a container is ever compromised.
Order Layers for Cache Efficiency
Copy dependency manifests and install dependencies before copying the rest of your source code. This way, code changes don’t invalidate the dependency install layer:
COPY package*.json ./
RUN npm ci
COPY . .
Pin Your Base Image Versions
Avoid latest tags in production Dockerfiles. Pin to a specific version (node:20.11-slim) so builds are reproducible and you’re not surprised by an upstream breaking change.
Use a .dockerignore File
node_modules
.git
.env
*.md
dist
Health Checks
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:3000/health || exit 1
Scanning for Vulnerabilities
docker scout cves my-image:latest
Integrate image scanning into CI so vulnerable base images or dependencies are caught before they reach production.
Conclusion
Small, non-root, well-cached, version-pinned images aren’t glamorous, but they’re the difference between fast, predictable deploys and slow, fragile ones. Apply these patterns as defaults across every service, not just the ones that have already caused problems.