Why Most Dockerfiles Are Wrong
I've reviewed hundreds of production Dockerfiles. 80% of them have the same problems: running as root, 1GB+ images, no health checks, secrets baked in, and slow builds. Here are 15 practices that separate production-grade containers from tutorial-level ones.
1. Use Multi-Stage Builds
Never ship build tools in your production image.
# BAD: 1.2GB image with compilers, dev deps, source code
FROM node:20
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
# GOOD: 150MB image with only what's needed to run
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
2. Never Run as Root
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# Change ownership of app files
COPY --chown=appuser:appgroup . .
# Switch to non-root
USER appuser
If your container gets compromised, the attacker has root access to the container (and potentially the host through vulnerabilities). Always run as non-root.
3. Use Specific Image Tags
# BAD: could change at any time
FROM node:latest
FROM python:3
# GOOD: pinned, reproducible
FROM node:20.11.1-alpine3.19
FROM python:3.12.1-slim-bookworm
latest today isn't latest tomorrow. Pin your base images.
4. Add Health Checks
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
Without health checks, your orchestrator doesn't know if the app is actually working. It only knows the process is running.
5. Set Resource Limits
Always set memory and CPU limits in your orchestrator (not Dockerfile), but design your app to respect them:
# docker-compose.yml
services:
api:
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
6. Order Layers by Change Frequency
Docker caches layers. Put things that change least at the top:
FROM node:20-alpine
# These rarely change — cached
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# This changes often — not cached but only rebuilds from here
COPY src/ ./src/
RUN npm run build
7. Use .dockerignore
node_modules
.git
.env
*.log
dist
coverage
.github
Without .dockerignore, you're sending your entire repo (including .git history) to the Docker daemon. This slows builds and can leak secrets.
8. Scan for Vulnerabilities
# Scan before pushing
docker scout cves myimage:latest
# Or use Trivy
trivy image myimage:latest
Run this in CI. Block deployments if HIGH or CRITICAL vulnerabilities are found.
9. Don't Store Secrets in Images
# BAD: secret is baked into a layer forever
ENV API_KEY=sk-12345
COPY .env .
# GOOD: pass at runtime
# (no secrets in Dockerfile at all)
Even if you delete the ENV in a later layer, it's still in the image history. Use runtime environment variables or secrets managers.
10. Use COPY, Not ADD
# BAD: ADD has magic behavior (auto-extracts archives, fetches URLs)
ADD . .
# GOOD: explicit, predictable
COPY . .
Only use ADD when you specifically need tar extraction.
11. One Process Per Container
# BAD: running nginx + app in one container
CMD nginx && node server.js
# GOOD: separate containers, orchestrated together
# Container 1: node server.js
# Container 2: nginx (reverse proxy)
One process per container = independent scaling, cleaner logs, easier debugging.
12. Set Proper Signal Handling
# Use exec form (not shell form) for CMD
# GOOD: PID 1, receives signals directly
CMD ["node", "server.js"]
# BAD: runs under /bin/sh, signals don't reach your app
CMD node server.js
With shell form, SIGTERM goes to the shell, not your app. Your app never gets a chance to gracefully shut down.
13. Use Alpine or Distroless
# Full image: 950MB
FROM node:20
# Alpine: 150MB
FROM node:20-alpine
# Distroless: 120MB, no shell (most secure)
FROM gcr.io/distroless/nodejs20
Smaller image = smaller attack surface = faster pulls.
14. Label Your Images
LABEL org.opencontainers.image.source="https://github.com/myorg/myapp"
LABEL org.opencontainers.image.version="1.2.3"
LABEL org.opencontainers.image.created="2026-06-22"
Labels help with tracking, auditing, and cleanup.
15. Set a Proper ENTRYPOINT
# For CLI tools — allow passing arguments
ENTRYPOINT ["python", "manage.py"]
CMD ["runserver", "0.0.0.0:8000"]
# Usage: docker run myapp migrate (overrides CMD, keeps ENTRYPOINT)
Quick Checklist
Before pushing any image to production, verify:
- Running as non-root user
- Image under 300MB
- No secrets in any layer
- Health check defined
- Pinned base image tag
- .dockerignore in place
- Vulnerability scan passes
- Uses exec form CMD
Follow these 15 practices and your containers will be secure, fast, and production-ready.
---
Frequently Asked Questions
What is the most important Docker security best practice?
Never run containers as root. Add USER nonroot in your Dockerfile and ensure the application files are owned by that user. Running as root means a container escape gives the attacker root on the host. Also use read-only filesystem (--read-only), drop all capabilities, and scan images for vulnerabilities in CI.
How do I reduce Docker image vulnerabilities?
Use minimal base images (Alpine, distroless), keep images updated with the latest security patches, remove unnecessary packages and tools, and scan images with tools like Trivy or Snyk in your CI pipeline. Multi-stage builds help by excluding build tools from the final image, reducing the attack surface significantly.
What is the correct way to handle Docker container health checks?
Define HEALTHCHECK in your Dockerfile with a command that verifies the application is truly ready to serve traffic, not just that the process is running. Example: HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/health || exit 1. Orchestrators use health checks to restart unhealthy containers and route traffic only to healthy ones.
How do I manage Docker image tags in production?
Never use latest in production — it's mutable and makes rollbacks impossible. Use immutable tags based on Git SHA, semantic version, or build number (e.g., myapp:v1.2.3-abc1234). Implement an image promotion workflow where images are tagged with environment labels as they pass through staging to production.
Should I use Docker Compose in production?
Docker Compose is designed for local development and testing, not production orchestration. For production, use Kubernetes, ECS, or Docker Swarm which provide health checking, rolling updates, auto-scaling, and self-healing. Docker Compose lacks production essentials like node failure recovery, load balancing across hosts, and zero-downtime deployments.
---
Related Resources
- YAML/JSON Converter — YAML to JSON converter for Docker Compose files
- Kubernetes Pod Troubleshooting — K8s troubleshooting after containerization
- Docker Multi-Stage Build Guide — Optimizing Docker images with multi-stage builds
- Docker Compose Environment Variables — Managing environment configs in Compose
- Docker Container Logs Debugging — Debugging running containers with logs