Writing a good Dockerfile is more than just getting your application to run in a container. Production-ready Dockerfiles need to balance image size, build speed, security, and maintainability. A poorly written Dockerfile can result in massive images, slow CI/CD pipelines, and security vulnerabilities that linger in your containers.
This guide covers the practices that separate production-grade Dockerfiles from ones that "just work." Whether you're containerizing a Node.js service, Python application, or Go binary, these principles apply across all tech stacks.
Use Specific Base Image Tags
The most common mistake is using generic tags like node:latest or ubuntu:latest. These tags change unpredictably, causing your builds to behave differently over time.
# Bad - tag changes unexpectedly
FROM node:latest
# Acceptable - more specific but still drifts
FROM node:20
# Good - pinned to exact patch version
FROM node:20.10.0-alpine
Always pin to a specific version. For most applications, pinning to the major.minor version (like node:20) is a reasonable middle ground that gives you security patches without breaking changes.
The alpine variant deserves special mention. Alpine images are based on Alpine Linux, a minimal distribution that produces images 10-30x smaller than the full debian or ubuntu variants. For most backend services, Alpine provides everything you need at a fraction of the size.
# ~900MB image
FROM ubuntu:22.04
# ~50MB image with same functionality
FROM alpine:3.18
The trade-off is that some packages may not be available in Alpine's package manager, and debugging can be trickier without standard tools. For most cases, the size reduction justifies this trade-off.
Implement Multi-Stage Builds
Multi-stage builds are one of the most powerful tools for creating small, secure production images. The idea is simple: build your application in one stage with all development dependencies, then copy only the compiled artifacts into a lightweight runtime stage.
# Stage 1: Build
FROM node:20.10.0-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM node:20.10.0-alpine
WORKDIR /app
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
COPY package.json .
EXPOSE 3000
CMD ["node", "dist/index.js"]
This pattern reduces the final image size dramatically. Build tools, test files, TypeScript source, and dev dependencies never make it into the final image. A typical Node.js application can shrink from 500MB to 150MB with multi-stage builds.
For compiled languages like Go, multi-stage builds are even more powerful:
FROM golang:1.21-alpine AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app .
FROM alpine:3.18
COPY --from=builder /build/app /app
CMD ["/app"]
A Go binary built this way might be just 20-30MB total, because Go produces statically-linked binaries that don't need the build toolchain.
Minimize Layers and Order Instructions for Caching
Every instruction in a Dockerfile creates a layer. Docker caches these layers, so if nothing changed, Docker reuses the cached layer instead of rebuilding. Understanding this is critical for fast local development and CI/CD.
The caching strategy is simple: put instructions that change frequently (like COPY) near the end, and instructions that change rarely (like base image, package manager updates) near the top.
# Bad - rebuilds npm install every time source code changes
FROM node:20.10.0-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/index.js"]
# Good - only rebuilds when package.json changes
FROM node:20.10.0-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
This pattern saves significant time during development. If you only changed source code, the npm install layer is reused from cache. Only the actual source copy and build steps run.
You can also combine multiple RUN commands to reduce layer count:
# Creates 3 layers
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*
# Better - creates 1 layer
RUN apt-get update && \
apt-get install -y curl git && \
rm -rf /var/lib/apt/lists/*
The cleanup step is important: removing apt caches, package manager artifacts, and build logs keeps layer sizes small.
Create a .dockerignore File
Just like .gitignore prevents unnecessary files from going into version control, .dockerignore prevents unnecessary files from being sent to the Docker daemon during build.
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
README.md
.DS_Store
coverage
dist
build
.next
.nuxt
This is especially important for large directories. Excluding node_modules or dist from the Docker build context saves bandwidth and build time.
Never Run as Root
By default, Docker containers run as the root user. This is a significant security risk: if your application is compromised, an attacker has full system access inside the container.
The fix is simple: create a non-root user and run your application as that user.
FROM node:20.10.0-alpine
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
USER nodejs
CMD ["node", "dist/index.js"]
The specific UID/GID (1001) is arbitrary but predictable, which is better than letting Docker auto-assign. This user has no special privileges and can't modify system files.
Use COPY Instead of ADD
Both COPY and ADD copy files, but ADD has extra features that make it less predictable: it can automatically extract tar archives and download from URLs.
# Good - simple, predictable
COPY package.json .
# Also good for clarity
COPY ./src ./src
# Avoid - magic behavior
ADD https://example.com/file.tar.gz .
ADD archive.tar.gz .
Stick with COPY for clarity. If you need to extract an archive, do it explicitly with RUN commands, making your intent clear.
Use EXPOSE and Health Checks
EXPOSE documents which ports your application listens on, and HEALTHCHECK tells Docker how to verify your application is actually working.
FROM node:20.10.0-alpine
WORKDIR /app
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
COPY package.json .
# Document the listening port
EXPOSE 3000
# Tell Docker how to check if the app is healthy
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node healthcheck.js || exit 1
USER nodejs
CMD ["node", "dist/index.js"]
Health checks are used by orchestrators like Kubernetes and Docker Compose to determine if your container should be restarted or removed from load balancers.
Use Environment Variables for Configuration
Avoid baking configuration values into your image. Instead, use environment variables that can be set at runtime.
FROM node:20.10.0-alpine
WORKDIR /app
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules ./node_modules
# Set defaults, but allow override at runtime
ENV NODE_ENV=production
ENV PORT=3000
ENV LOG_LEVEL=info
USER nodejs
EXPOSE ${PORT}
CMD ["node", "dist/index.js"]
This allows the same image to be used in development, staging, and production by simply passing different environment variables at container start time.
Complete Example: Production Node.js Application
Here's a complete, production-ready Dockerfile that incorporates all these practices:
# Stage 1: Dependencies and build
FROM node:20.10.0-alpine AS builder
WORKDIR /build
# Copy package files first to leverage caching
COPY package*.json ./
RUN npm ci --only=production && \
npm cache clean --force
# Copy source and build
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM node:20.10.0-alpine
# Install dumb-init to handle signals properly
RUN apk add --no-cache dumb-init
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
# Copy built application from builder
COPY --from=builder --chown=nodejs:nodejs /build/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /build/dist ./dist
COPY --chown=nodejs:nodejs package.json .
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Document the port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
# Switch to non-root user
USER nodejs
# Use dumb-init to handle signals
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/index.js"]
This image will typically be 150-200MB, build quickly on repeated builds, and run securely without unnecessary privileges.
Key Takeaways
Start small: pick the most impactful practices (specific tags, multi-stage builds, non-root user) and implement those first. As your containerization matures, add health checks, optimize caching, and tune for your specific workload. Well-written Dockerfiles pay dividends across your entire deployment pipeline.