Why Best Practices Matter
Docker makes containerization easy, but production-ready containers require careful planning. Following best practices ensures your images are:
- Secure - Protected from vulnerabilities
- Efficient - Small size, fast builds
- Maintainable - Easy to update and debug
- Reliable - Consistent across environments
Multi-Stage Builds
Multi-stage builds dramatically reduce image size by separating build and runtime dependencies:
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Remove devDependencies after build
RUN npm prune --production
# Runtime stage
FROM node:18-alpine
WORKDIR /app
# Use the built-in 'node' user (included in official Node images)
USER node
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]Pro Tip
Multi-stage builds can significantly reduce image sizes—often by 50% or more depending on your application. Only production dependencies end up in the final image.
Use Specific Tags
Never use latest in production:
# Bad - unpredictable
FROM node:latest
# Good - specific and reproducible
FROM node:18.19.0-alpine3.19Specific tags ensure:
- Reproducibility - Same image every build
- Predictability - No surprise updates
- Security - You control when to update
Minimize Layers
Each RUN, COPY, and ADD creates a layer. Combine commands to reduce layers:
# Bad - multiple layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
# Good - single layer
RUN apt-get update && \
apt-get install -y \
curl \
git \
&& rm -rf /var/lib/apt/lists/*Information
Clean up package manager caches in the same layer to avoid bloating the image.
Leverage Build Cache
Order Dockerfile instructions from least to most frequently changed:
FROM node:18-alpine
# Dependencies change rarely - cache these layers
COPY package*.json ./
RUN npm ci
# Source code changes often - do this last
COPY . .This ordering maximizes cache hits and speeds up builds.
Use .dockerignore
Just like .gitignore, use .dockerignore to exclude unnecessary files:
node_modules
npm-debug.log
.git
.env
*.md
tests/
Benefits:
- Smaller context - Faster builds
- Better security - Prevents
.envfiles and secrets from leaking into image layers - Cleaner images - Only production files
- No history leaks - Excluding
.gitprevents source history from being exposed
Run as Non-Root User
Never run containers as root in production:
FROM node:18-alpine
WORKDIR /app
COPY --chown=node:node . .
# Use the built-in 'node' user (included in official Node images)
USER node
CMD ["node", "server.js"]Pro Tip
Official Node.js images already include a node user (UID 1000). No need to create a new user—just use USER node.
Security Risk
Running as root exposes your host system if the container is compromised. Always use a non-root user.
Health Checks
Add health checks to ensure container reliability:
# Using wget (available in Alpine)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -q --spider http://localhost:3000/health || exit 1Or in docker-compose:
services:
web:
image: myapp:latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40sSecurity Scanning
Scan images for vulnerabilities regularly:
# Using Docker Scout
docker scout quickview myapp:latest
docker scout recommendations myapp:latest
# Using Trivy
trivy image myapp:latest
# Using Snyk
snyk container test myapp:latestPro Tip
Integrate security scanning into your CI/CD pipeline to catch vulnerabilities before production.
Optimize for Size
Consider Alpine-based or slim images for smaller footprint:
# Standard Debian image (larger, ~300-900MB depending on version)
FROM node:18
# Alpine image (smaller, typically under 200MB)
FROM node:18-alpine
# Slim image (good balance of size and compatibility)
FROM node:18-slimAlpine Compatibility
Alpine uses musl instead of glibc, which can cause issues with some native Node modules. If you encounter problems, try -slim images or verify your dependencies work with Alpine first.
Additional size optimization:
FROM node:18-alpine
# Remove unnecessary files
RUN npm ci --omit=dev && \
npm cache clean --force && \
rm -rf /tmp/*
# Use multi-stage builds
# Copy only necessary filesEnvironment Variables
Handle environment variables securely:
# Use ARG for build-time variables
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
# Never hardcode secrets
# Use runtime environment or secrets managementBetter approach with docker-compose:
services:
web:
image: myapp:latest
env_file:
- .env.production
environment:
- NODE_ENV=productionLogging Best Practices
Log to stdout/stderr, not files:
# Good - logs go to Docker logging drivers
CMD ["node", "server.js"]
# Bad - logs trapped in container (requires shell form for redirection)
CMD node server.js > /var/log/app.logInformation
The exec form (CMD ["node", "..."]) doesn't support shell features like redirection. That's actually a good thing—it encourages proper logging to stdout.
Configure log drivers:
services:
web:
image: myapp:latest
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"Complete Example
Here's a production-ready Dockerfile incorporating all best practices:
# Multi-stage build
FROM node:18-alpine AS builder
WORKDIR /app
# Copy dependency files
COPY package*.json ./
# Install ALL dependencies (including devDependencies for build)
RUN npm ci
# Copy source
COPY . .
# Build application
RUN npm run build
# Remove devDependencies after build
RUN npm prune --production
# Production stage
FROM node:18-alpine
# Install tini for proper signal handling (PID 1)
RUN apk add --no-cache tini
WORKDIR /app
# Copy from builder with correct ownership
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/package.json ./
# Use the built-in 'node' user
USER node
# Expose port
EXPOSE 3000
# Health check using wget (available in Alpine)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -q --spider http://localhost:3000/health || exit 1
# Use tini as entrypoint for proper signal handling
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]Why tini?
Node.js doesn't handle signals properly when running as PID 1. Without tini, docker stop may hang for 10 seconds before force-killing your container. Tini ensures graceful shutdowns.
Monitoring and Observability
Add labels for better organization:
LABEL maintainer="team@devopspath.io"
LABEL version="1.0.0"
LABEL description="Production API server"Use consistent labeling in docker-compose:
services:
web:
image: myapp:latest
labels:
- "io.devopspath.environment=production"
- "io.devopspath.version=1.0.0"Next Steps
Ready to apply these practices?
- Try our Docker lessons - Docker track for hands-on practice
- Build a project - Apply these to a real application
- Learn Kubernetes - Take containers to the next level
- Explore CI/CD - Automate your Docker workflows
Learn by Doing
The best way to master Docker is through practice. Our Docker learning track provides step-by-step lessons with real commands.
Conclusion
Following these best practices ensures your containers are production-ready, secure, and efficient. Start with the fundamentals—specific tags, multi-stage builds, non-root users—and build from there.
Remember: good practices today prevent problems tomorrow. Invest time in building containers correctly, and you'll save countless hours in production.

