Docker has revolutionized how we build, ship, and run applications. But with great power comes great responsibility—poorly configured containers can lead to security vulnerabilities, bloated images, and production headaches. Here’s how to do Docker right.
Use Multi-Stage Builds
Multi-stage builds are the single most impactful optimization for your Docker images.
# Bad - includes build tools in final image
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
# Final image: ~1GB
# Good - multi-stage build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
# Final image: ~150MBBenefits:
- Smaller images (10x reduction is common)
- No build tools or dev dependencies in production
- Faster deployments and lower storage costs
Use Specific Base Image Tags
Never use latest—it’s a recipe for non-reproducible builds.
# Bad - unpredictable
FROM node:latest
# Better - specific version
FROM node:20
# Best - specific version with digest
FROM node:20.11.0-alpine3.19@sha256:abc123...Minimize Layers and Leverage Caching
Order your Dockerfile instructions from least to most frequently changing.
# Bad - cache invalidated on any code change
FROM node:20-alpine
COPY . .
RUN npm install
# Good - dependencies cached separately
FROM node:20-alpine
WORKDIR /app
# Dependencies change less frequently
COPY package*.json ./
RUN npm ci --only=production
# Code changes more frequently
COPY . .Run as Non-Root User
Running as root inside containers is a security risk.
FROM node:20-alpine
# Create app user
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
CMD ["node", "index.js"]Use .dockerignore
Just like .gitignore, a .dockerignore keeps unnecessary files out of your image.
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
*.md
!README.md
Dockerfile*
docker-compose*
.dockerignore
coverage
.nyc_output
tests
__tests__
*.test.js
*.spec.jsThis:
- Reduces build context size
- Speeds up builds
- Prevents secrets from being copied
Health Checks
Let Docker know when your application is actually ready.
FROM node:20-alpine
WORKDIR /app
COPY . .
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "index.js"]Health checks enable:
- Automatic container restarts on failure
- Load balancer integration
- Proper rolling deployments
Security Best Practices
Scan for Vulnerabilities
# Docker Scout
docker scout cves myimage:latest
# Trivy
trivy image myimage:latest
# Snyk
snyk container test myimage:latestDon’t Store Secrets in Images
# Never do this
ENV DATABASE_PASSWORD=supersecret
# Use runtime secrets instead
# Docker Swarm: docker secret
# Kubernetes: Secrets/ConfigMaps
# Runtime: environment variables from secure sourcesUse Read-Only File System
docker run --read-only --tmpfs /tmp myimageOptimize for Production
Use Alpine-Based Images
# Standard image: ~1GB
FROM node:20
# Alpine image: ~180MB
FROM node:20-alpineCopy Only What’s Needed
# Bad - copies everything
COPY . .
# Good - explicit about what's needed
COPY package*.json ./
COPY src ./src
COPY config ./configSet Resource Limits
# docker-compose.yml
services:
app:
image: myapp
deploy:
resources:
limits:
cpus: '0.50'
memory: 512M
reservations:
cpus: '0.25'
memory: 256MLogging Best Practices
Write logs to stdout/stderr, not files.
// Good - logs to stdout
console.log('Application started');
// Bad - logs to file inside container
fs.writeFileSync('/var/log/app.log', 'Application started');Let the container runtime handle log aggregation.
Example Production Dockerfile
Here’s a complete example putting it all together:
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS base
RUN apk add --no-cache tini
WORKDIR /app
FROM base AS dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
FROM base AS build
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM base AS production
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs && \
adduser -u 1001 -S nodejs -G nodejs
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=build --chown=nodejs:nodejs /app/dist ./dist
USER nodejs
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
EXPOSE 3000
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]Conclusion
Production-ready Docker images require attention to:
- Size - Multi-stage builds, Alpine base images
- Security - Non-root users, vulnerability scanning
- Reliability - Health checks, resource limits
- Reproducibility - Pinned versions,
.dockerignore
Take the time to optimize your Dockerfiles—your future deployments will thank you.
What Docker practices have made the biggest difference in your production deployments?