Node.jsBackendFull-Stack

Docker Containerization for Node.js Apps

TT
TopicTrick Team
Docker Containerization for Node.js Apps

Docker Containerization for Node.js Apps

A working application on your laptop that fails on the server is a classic deployment problem. Docker solves it by packaging the application with its entire runtime environment — Node.js version, dependencies, environment — into an image that runs identically anywhere.

This module covers writing a production-ready Dockerfile, multi-stage builds, docker-compose for local development, and security best practices for containerised Node.js applications.

This is Module 30 of the Node.js Full‑Stack Developer course.


.dockerignore

Create this before the Dockerfile. It keeps the build context lean:

text
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
dist
coverage
.nyc_output
*.test.js
*.spec.js
tests/
docs/
README.md
docker-compose*.yml
Dockerfile*

Production Dockerfile — Multi-Stage Build

dockerfile
# Dockerfile

# ── Stage 1: Builder ──────────────────────────────────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app

# Copy package files first (layer caching — only reinstalls on package changes)
COPY package.json package-lock.json ./

# Install all dependencies (including devDependencies for build)
RUN npm ci

# Copy source code
COPY . .

# Build the React frontend
RUN npm run build:client

# ── Stage 2: Production ───────────────────────────────────────────────────────
FROM node:20-alpine AS production

# Security: run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy package files
COPY package.json package-lock.json ./

# Install production dependencies only
RUN npm ci --omit=dev && npm cache clean --force

# Copy built assets from builder stage
COPY --from=builder /app/dist ./dist

# Copy server source code
COPY src ./src

# Set ownership to non-root user
RUN chown -R appuser:appgroup /app

# Switch to non-root user
USER appuser

# Expose port (documentation only — actual port set at runtime)
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

# Start the server
CMD ["node", "src/server.js"]

Why alpine?

node:20-alpine is a minimal Linux distribution (~170MB vs ~900MB for node:20). Smaller image = faster pulls, smaller attack surface. Use node:20-alpine for production unless you need libraries that require a full OS.


Building and Running

bash
# Build the image
docker build -t myapp:latest .

# Run the container
docker run \
  -p 3000:3000 \
  -e MONGODB_URI=mongodb://host.docker.internal:27017/myapp \
  -e JWT_ACCESS_SECRET=your-secret \
  -e JWT_REFRESH_SECRET=your-refresh-secret \
  -e NODE_ENV=production \
  -e API_URL=http://localhost:3000 \
  -e FRONTEND_URL=http://localhost:3000 \
  myapp:latest

# Run detached
docker run -d --name myapp -p 3000:3000 --env-file .env.production myapp:latest

# View logs
docker logs -f myapp

# Stop and remove
docker stop myapp && docker rm myapp

docker-compose for Local Development

yaml
# docker-compose.yml
version: '3.9'

services:
  # ── Application ─────────────────────────────────────────────────────────────
  app:
    build:
      context: .
      target: builder        # use builder stage for dev (has devDependencies)
    command: npm run dev:server
    ports:
      - "3000:3000"
    volumes:
      - ./src:/app/src        # hot-reload: mount src directory
      - /app/node_modules     # don't override container's node_modules
    environment:
      NODE_ENV: development
    env_file:
      - .env
    depends_on:
      mongo:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - app-network

  # ── MongoDB ──────────────────────────────────────────────────────────────────
  mongo:
    image: mongo:7-jammy
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db
    environment:
      MONGO_INITDB_DATABASE: myapp
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app-network

  # ── Redis ─────────────────────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - app-network

  # ── Mongo Express (DB UI) ─────────────────────────────────────────────────────
  mongo-express:
    image: mongo-express:1
    ports:
      - "8081:8081"
    environment:
      ME_CONFIG_MONGODB_URL: mongodb://mongo:27017/
      ME_CONFIG_BASICAUTH: false
    depends_on:
      - mongo
    networks:
      - app-network
    profiles:
      - tools   # only started with: docker compose --profile tools up

volumes:
  mongo_data:
  redis_data:

networks:
  app-network:
    driver: bridge

docker-compose for Production

yaml
# docker-compose.prod.yml
version: '3.9'

services:
  app:
    build:
      context: .
      target: production     # production stage — lean image
    restart: unless-stopped
    ports:
      - "3000:3000"
    env_file:
      - .env.production
    depends_on:
      - mongo
      - redis
    networks:
      - prod-network

  mongo:
    image: mongo:7-jammy
    restart: unless-stopped
    volumes:
      - mongo_prod_data:/data/db
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
    networks:
      - prod-network
    # No port mapping — not exposed to host

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis_prod_data:/data
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    networks:
      - prod-network

volumes:
  mongo_prod_data:
  redis_prod_data:

networks:
  prod-network:
    driver: bridge

Common Docker Commands

bash
# Local development
docker compose up -d              # start all services (detached)
docker compose up -d --build      # rebuild and start
docker compose down               # stop and remove containers
docker compose down -v            # also remove volumes (wipes data)
docker compose logs -f app        # follow app logs
docker compose exec app sh        # shell into running container

# With tools profile
docker compose --profile tools up -d   # also starts mongo-express

# Production
docker compose -f docker-compose.prod.yml up -d

# Image management
docker images                     # list images
docker rmi myapp:latest           # remove image
docker system prune               # remove all unused resources
docker system prune -a            # remove all unused images too

# Debugging
docker inspect myapp              # container metadata
docker stats                      # live resource usage (CPU, memory)
docker exec -it myapp sh          # shell into running container

Layer Caching — Making Builds Fast

Docker caches each layer. If a layer's input does not change, Docker reuses the cache. Optimise your Dockerfile to maximise cache hits:

dockerfile
# ✅ Good — package files change rarely, source changes often
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

# ❌ Bad — any source change invalidates the npm ci layer
COPY . .
RUN npm ci

The rule: copy things that change rarely before things that change often.


Environment Variables in Docker

Never bake secrets into the image. Pass them at runtime:

bash
# Via --env-file (recommended for many variables)
docker run --env-file .env.production myapp:latest

# Via -e for individual variables
docker run -e JWT_SECRET=mysecret myapp:latest

For production, use your platform's secrets mechanism (Railway vars, AWS SSM, Doppler) and inject at runtime — not from a file on the server.


Container Security Checklist

text
✅ Run as non-root user (USER appuser)
✅ Use official minimal base image (node:20-alpine)
✅ No secrets baked into the image — passed at runtime
✅ node_modules owned by the app user
✅ Health check defined (HEALTHCHECK)
✅ Production stage has no devDependencies
✅ .dockerignore excludes .env, .git, tests, node_modules
✅ restart: unless-stopped in production compose
✅ Internal services (mongo, redis) not exposed to host in production
✅ Use specific image tags (node:20-alpine, not node:latest)

Node.js Full‑Stack Course — Module 30 of 32

Your app runs identically in any environment with Docker. Continue to Module 31 to deploy it to the cloud.


    Summary

    Docker gives Node.js applications environment consistency and deployment simplicity:

    • Use multi-stage builds: a builder stage for compilation and a lean production stage with only what the app needs at runtime
    • Always use non-root users (USER appuser) — never run Node.js as root in a container
    • Use node:20-alpine for production — it is ~170MB vs ~900MB for the Debian variant
    • Layer caching: copy package.json and run npm ci before copying source code
    • docker-compose.yml for local development includes app, MongoDB, and Redis with health checks
    • Pass secrets at runtime via --env-file or platform variables — never bake them into the image
    • HEALTHCHECK lets Docker (and orchestrators like Kubernetes) detect unhealthy containers automatically
    • .dockerignore prevents node_modules, .env, and test files from bloating the build context

    Continue to Module 31: Deploying Node.js to the Cloud →