DEV Community

Alex Chen
Alex Chen

Posted on

Dockerizing a Node.js App in 2026: The Practical Guide

Dockerizing a Node.js App in 2026: The Practical Guide

Not another "what is Docker" post. This is the exact setup I use for every production Node.js service.

The Goal

Take a typical Node.js app and make it:

  • Reproducible — same environment everywhere
  • Portable — runs on any cloud provider
  • Efficient — small images, fast builds
  • Production-ready — health checks, logging, security

Starting Point

// server.js
const express = require('express');
const app = express();

app.get('/health', (req, res) => res.json({ status: 'ok', time: Date.now() }));
app.get('/api/hello', (req, res) => res.json({ message: 'Hello World' }));

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Running on :${PORT}`));
Enter fullscreen mode Exit fullscreen mode
{
  "name": "my-app",
  "version": "1.0.0",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "dependencies": {
    "express": "^4.21.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

The Dockerfile (Final Version)

I'll show the final version first, then break it down:

# ---- Build Stage ----
FROM node:22-alpine AS builder

WORKDIR /app

# Install dependencies first (layer caching)
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force

# Copy source code
COPY . .

# ---- Production Stage ----
FROM node:22-alpine AS runner

# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
    adduser -S myuser -u 1001 -G nodejs

WORKDIR /app

# Copy built files from builder stage
COPY --from=builder --chown=myuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=myuser:nodejs /app/server.js .

# Switch to non-root user
USER myuser

EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Image size: ~180MB (vs ~1GB without optimization)

Line-by-Line Breakdown

Why Multi-Stage?

Stage 1 (builder): Installs all deps + copies source → produces /app with node_modules
Stage 2 (runner):  Copies only what's needed → final image is tiny
Enter fullscreen mode Exit fullscreen mode

The builder stage's npm install, package-lock.json, even npm itself — none of it ends up in the final image.

Why Alpine?

node:22          → 1.05 GB (full Debian)
node:22-slim     → 220 MB (stripped Debian)  
node:22-alpine   → 180 MB (musl libc + busybox)
Enter fullscreen mode Exit fullscreen mode

Alpine uses musl libc instead of glibc. Smaller, but has some compatibility quirks with native modules.

Why Non-Root User?

# If your container is compromised...
# Running as root = attacker has root access to the container
# Running as myuser = attacker only has user-level access
Enter fullscreen mode Exit fullscreen mode

This is a requirement for many Kubernetes deployments and a security best practice everywhere.

The HEALTHCHECK

This is the most overlooked part:

HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
Enter fullscreen mode Exit fullscreen mode

Without this:

  • Docker doesn't know if your app crashed
  • Kubernetes won't restart unhealthy pods
  • Load balancers will send traffic to dead containers

Always expose a /health endpoint.

docker-compose.yml for Local Development

version: '3.8'

services:
  app:
    build: .
    ports:
      - '${PORT:-3000}:3000'
    environment:
      - NODE_ENV=production
      - PORT=3000
    restart: unless-stopped
    healthcheck:
      test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3000/health']
      interval: 30s
      timeout: 3s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M

  # Optional: Redis for sessions/cache
  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 10s

volumes:
  redis_data:
Enter fullscreen mode Exit fullscreen mode

Building and Running

# Build the image
docker build -t my-app:latest .

# Run it
docker run -d \
  --name my-app \
  -p 3000:3000 \
  -e NODE_ENV=production \
  --restart unless-stopped \
  my-app:latest

# Check logs
docker logs -f my-app

# Check health
docker inspect --format='{{json .State.Health.Status}}' my-app

# Enter running container for debugging
docker exec -it my-app sh
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls (I Made All of These)

1. Forgetting .dockerignore

# .dockerignore
node_modules
npm-debug.log
.git
.env
coverage
.DS_Store
*.md
Enter fullscreen mode Exit fullscreen mode

Without this, COPY . . copies EVERYTHING including node_modules you just installed. Your builds will be slow and images huge.

2. Using COPY Before RUN npm install

# ❌ BAD: Changes code → invalidates npm install cache
COPY . .
RUN npm install

# ✅ GOOD: Only re-runs npm install when deps change
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Enter fullscreen mode Exit fullscreen mode

Docker caches each layer. If package.json doesn't change, it skips npm install entirely.

3. Not Pinning Node Version

# ❌ Could break when 23 releases
FROM node:alpine

# ✅ Predictable builds
FROM node:22-alpine
Enter fullscreen mode Exit fullscreen mode

4. Exposing Secrets via ENV in Image

# ❌ Secret baked into image layers
ENV DATABASE_URL=postgres://user:pass@host/db

# ✅ Pass at runtime
# docker run -e DATABASE_URL=... my-app
# Or use docker secrets / Kubernetes secrets
Enter fullscreen mode Exit fullscreen mode

Use docker-compose.yml or runtime env vars. Never put secrets in the Dockerfile.

Optimizing for CI/CD

# .github/workflows/docker.yml
name: Build & Push Docker Image

on:
  push:
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.ref_name }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
Enter fullscreen mode Exit fullscreen mode

Monitoring Resource Usage

# Real-time stats
docker stats my-app

# One-time snapshot
docker stats --no-stream my-app

# Expected output for our app:
CONTAINER   CPU %   MEM USAGE / LIMIT   MEM %   NET I/O         BLOCK I/O   PIDS
my-app      0.15%   45.2MiB / 512MiB   8.83%   1.2kB / 0.8kB   0B / 0B     11
Enter fullscreen mode Exit fullscreen mode

A well-behaved Node.js container should use < 100MB RAM at idle.

When Things Go Wrong

# Container keeps restarting?
docker logs --tail 50 my-app
# Usually: missing env vars, wrong port, dependency crash

# Can't access from host?
docker port my-app
# Check port mapping matches

# Image too big?
docker history my-app:latest
# Find which layer is bloated

# Permission denied errors?
# You're probably running as root inside the container
# Fix: add USER directive to Dockerfile
Enter fullscreen mode Exit fullscreen mode

The Complete Checklist

Before shipping to production:

  • [ ] .dockerignore exists and covers node_modules, .git, .env
  • [ ] Multi-stage build (separate builder and runner)
  • [ ] Non-root user in final stage
  • [ ] HEALTHCHECK configured
  • [ ] No secrets in Dockerfile or image layers
  • [ ] Node version pinned (node:22-alpine)
  • [ ] npm ci instead of npm install
  • [ ] Memory limits set in compose/deployment
  • [ ] Logs go to stdout/stderr (not files)
  • [ ] Graceful shutdown handled (SIGTERM)

Docker doesn't have to be scary. Start with this template, adapt as needed.

Follow @armorbreak for more practical DevOps guides.

Top comments (0)