Why Docker for Node.js?
Docker solves the oldest problem in software deployment: environment inconsistency. "It works on my machine" stops being an excuse when the machine is a container image that runs identically on your laptop, in CI, and in production. Docker also enables immutable deployments — the image that passed your test suite is the exact image that runs in production, byte for byte.
For Node.js specifically, Docker gives you control over the exact Node version (no more NVM version mismatches across the team), isolated per-service environments, reproducible dependency installs via lockfiles, and a single artifact that your entire deployment pipeline can track by SHA digest.
The complete production Dockerfile
Here's the full, production-ready pattern. Three stages: dependency installation, build, and the lean runtime image.
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
# npm ci installs exact versions from lockfile — never npm install in CI/prod
RUN npm ci --omit=dev
# Stage 2: Build (TypeScript compile, asset bundling, etc.)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # Include devDependencies for the build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build # Compile TypeScript, bundle, etc.
# Stage 3: Lean runtime image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 --ingroup nodejs appuser
# Copy only what's needed at runtime
COPY --from=deps --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --chown=appuser:nodejs package.json .
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "--max-old-space-size=400", "dist/index.js"]Why three stages?
The three-stage pattern keeps image size minimal by preventing build tools from ending up in the final image. Stage 1 installs production dependencies. Stage 2 installs all dependencies (including devDependencies) and runs the build. Stage 3 starts fresh and copies only the compiled output and production node_modules. Your TypeScript compiler, test frameworks, and build tools don't ship to production.
A typical Node.js API with this pattern produces a final image around 120–180MB. Without multi-stage builds, including devDependencies, the same app might be 800MB+.
The .dockerignore file — don't skip this
Without a .dockerignore, Docker copies your entire project directory into the build context — including node_modules (hundreds of MB), .git history, local .env files, and test coverage. This bloats your build context, slows down every build, and risks leaking secrets.
# .dockerignore
node_modules
dist
.git
.gitignore
.env
.env.*
*.test.ts
*.spec.ts
coverage
.nyc_output
*.md
Dockerfile*
docker-compose*
.eslintrc*
.prettierrc*
tsconfig*.json # Only needed for build, not runtimeAlpine vs Debian: when to use which
node:20-alpine produces images 3–10x smaller than node:20 (Debian). Use Alpine unless:
- You have npm packages with native C++ bindings (bcrypt, sharp, canvas) that require build tools. Fix: add
RUN apk add --no-cache python3 make g++in the build stage only. - Your app uses system libraries only available on Debian/Ubuntu. In this case, use
node:20-slimas a compromise — Debian-based but stripped down to ~200MB.
For pure JavaScript/TypeScript Node.js APIs with no native modules, Alpine is always the right choice.
Environment variable injection — never bake secrets
Secrets committed to a Docker image are exposed to anyone who can pull that image. The correct pattern: inject at runtime, never at build time.
# ✗ Never do this — the secret is burned into every layer of the image
ENV DATABASE_URL=postgres://user:secret@prod-host/db
# ✓ Inject from a .env file at runtime
docker run --env-file .env.production myapp:latest
# ✓ Inject individually
docker run -e DATABASE_URL="$DATABASE_URL" -e JWT_SECRET="$JWT_SECRET" myapp:latest
# ✓ In production: use your platform's secret injection
# (Cloudoku, ECS task definitions, Kubernetes Secrets, etc.)
# The container gets the env vars without you managing them in Docker commandsLayer caching: make your builds fast
Docker rebuilds from the first changed instruction. Structure your Dockerfile so slow, rarely-changing steps come first:
# SLOW: npm install runs on every code change
COPY . .
RUN npm ci # ← always invalidated
# FAST: npm install only runs when package.json changes
COPY package*.json ./
RUN npm ci # ← cached unless packages changed
COPY . . # ← only this layer and below rebuild on code changesWith proper ordering, a typical build where only source code changed takes 10–15 seconds (only the COPY + compile layers rebuild). Without it, every build reinstalls all npm packages, taking 60–90 seconds.
Health checks in Docker
The HEALTHCHECK instruction tells Docker how to test whether the container is working. Orchestrators use this to delay routing traffic to new containers and to restart unhealthy ones.
# Using wget (built into Alpine)
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget -qO- http://localhost:3000/health || exit 1
# Options explained:
# --interval=30s Check every 30 seconds
# --timeout=5s Fail if check takes more than 5 seconds
# --start-period=15s Don't count failures for the first 15s (startup time)
# --retries=3 Mark unhealthy after 3 consecutive failuresBuilding, tagging, and pushing images
# Build with a specific tag (use git SHA for immutable versioning)
docker build -t myapp:$(git rev-parse --short HEAD) .
# Also tag as latest for convenience
docker tag myapp:$(git rev-parse --short HEAD) myapp:latest
# Build for multiple platforms (Apple Silicon + Linux AMD64)
docker buildx build --platform linux/amd64,linux/arm64 -t registry.example.com/myapp:$(git rev-parse --short HEAD) --push .
# Push to a registry
docker push registry.example.com/myapp:latestBest practice: Always tag images with the git commit SHA in your CI pipeline. :latest is ambiguous — you can't roll back to a specific version with it. The SHA is immutable and traceable.
Security scanning before push
Scan your image for known CVEs before pushing to production. A Node 20 Alpine image typically starts with zero high-severity vulnerabilities.
# Docker Scout (built into Docker Desktop)
docker scout cves myapp:latest
# Trivy (free, comprehensive, CI-friendly)
trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:latest
# In GitHub Actions:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:latest
exit-code: '1'
severity: 'HIGH,CRITICAL'Running the container locally
# Run with env file, expose port 3000, auto-remove on stop
docker run --rm --env-file .env.local -p 3000:3000 --name myapp-dev myapp:latest
# View logs
docker logs -f myapp-dev
# Run a shell inside the container for debugging
docker exec -it myapp-dev sh
# Inspect resource usage
docker stats myapp-devCommon mistakes and how to avoid them
- Running as root — One RCE exploit gives an attacker root access to your container and potentially the host. Always create and use a non-root user.
- No .dockerignore — Your image includes the full git history, local env files, and test coverage. Add the file.
- NODE_ENV not set to production — Express disables several performance optimizations, enables verbose error output, and skips view caching in non-production mode. Always set
ENV NODE_ENV=production. - Not setting --max-old-space-size — Node will try to use more memory than the container has, triggering an OOM kill with no useful log message.
- Hardcoded PORT — Always read from
process.env.PORT. Container platforms assign ports dynamically. - Copying package-lock.json but using npm install — Use
npm ci. It respects the lockfile and is 2x faster thannpm installin CI.
Production deployment
Most teams don't manage Docker directly in production. Instead, they push images to a container registry (Docker Hub, ECR, GCR) and let a platform handle orchestration — whether that's Kubernetes, ECS, or a PaaS like Cloudoku. The Dockerfile and image are identical regardless of the platform; only the deployment target changes. This means you can run the exact same image locally with docker run and in production with zero modifications.