Docker is ubiquitous in modern software development, but using it correctly in production requires understanding several patterns that are not obvious from the introductory tutorials. Multi-stage builds, image security, and runtime configuration are where most teams that use Docker casually go wrong.
Multi-Stage Builds
The problem with single-stage Docker builds: every tool you need to build your application (compilers, build dependencies, test frameworks, development utilities) ends up in the final image — often adding hundreds of megabytes to an image that only needs the compiled output. Multi-stage builds solve this by separating the build environment from the runtime environment:
“`dockerfile
# Stage 1: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci –only=production=false
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM node:20-alpine AS runtime
WORKDIR /app
COPY –from=builder /app/dist ./dist
COPY –from=builder /app/node_modules ./node_modules
EXPOSE 3000
USER node
CMD [“node”, “dist/index.js”]
“`
The final image only contains what the runtime stage copies — build tools, test dependencies, and source files are not included. A typical Node.js app that might be 800MB as a single-stage build can be 150MB with multi-stage. The `–from=builder` directive copies from a named stage. You can also copy from arbitrary Docker images (`–from=nginx:latest`) to pull in specific files without inheriting the full image. The `.dockerignore` file: equivalent to `.gitignore` but for Docker build context — always include `node_modules`, `.git`, `*.md`, test files, and other build artifacts that should not be sent to the Docker daemon. Sending a large build context is the most common cause of slow Docker builds.
Container Security
Running as a non-root user: by default, containers run as root — a significant security risk if the container is compromised (root in the container may map to root on the host). Always switch to a non-root user in production images. For Node.js, the official image includes a `node` user:
“`dockerfile
USER node
“`
For custom users: `RUN addgroup -S appgroup && adduser -S appuser -G appgroup`. Minimal base images: prefer `alpine`-based images (5MB) or `distroless` images (Google’s images with no shell, package manager, or other tools — only the application and runtime). A distroless Python image can be under 50MB; standard Python images are 1GB+. Secrets and environment variables: never bake secrets into images with `ENV` or `ARG` — they are visible in the image layers and in `docker inspect`. Use Docker secrets (for Swarm), Kubernetes secrets, or secret injection at runtime via your orchestrator. Scanning images: `docker scout` (built into Docker Desktop), `trivy`, or `snyk` scan images for known CVEs in base images and dependencies. Run scans in CI pipelines before pushing to registries. Read-only file system: `docker run –read-only` prevents any writes to the container filesystem (mount tmpfs for directories that need write access like /tmp). Reduces attack surface significantly. Resource limits: always set CPU and memory limits in production — a container without limits can consume all host resources:
“`yaml
# Docker Compose
deploy:
resources:
limits:
cpus: ‘0.5’
memory: 512M
“`
Health checks: define `HEALTHCHECK` in your Dockerfile or `healthcheck` in Compose — orchestrators use this to determine when a container is ready and when to restart it:
“`dockerfile
HEALTHCHECK –interval=30s –timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1
“`




