DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

Docker for Web Developers: Compose, Multi-Stage Builds, and When It's Actually Worth It

Docker solves "works on my machine" by packaging your app and its dependencies into a container that runs the same everywhere — your laptop, CI, and production. For web developers, the most useful applications are local development environments and ensuring consistent deployments.

What Docker Actually Does

Docker runs containers — isolated processes that share the host kernel but have their own filesystem, networking, and process space. A container is created from an image, which is a layered snapshot of a filesystem.

Image (read-only template)
  ↓
Container (running instance with writable layer)
Enter fullscreen mode Exit fullscreen mode

The Dockerfile describes how to build the image.

A Dockerfile for a Node.js App

# Use a specific version tag, not 'latest'
FROM node:20-alpine

# Set working directory
WORKDIR /app

# Copy package files first (better layer caching)
COPY package.json package-lock.json ./

# Install dependencies
RUN npm ci --production

# Copy the rest of the source
COPY . .

# Build step (if needed)
RUN npm run build

# Don't run as root
USER node

# Document the port
EXPOSE 3000

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

Layer caching: Docker caches each instruction. Copying package.json before the full source means npm ci only re-runs when dependencies change, not on every code change.

npm ci vs npm install: npm ci is faster, more strict, and reproducible — it uses the lockfile exactly, failing if package-lock.json is out of sync with package.json.

.dockerignore

Exclude everything that shouldn't go into the image:

node_modules
.git
.env
dist
coverage
*.log
.DS_Store
Enter fullscreen mode Exit fullscreen mode

Without this, COPY . . copies node_modules (often gigabytes) into the image build context.

Development with Docker Compose

For local development, Docker Compose orchestrates multiple services (app, database, Redis, etc.):

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - .:/app              # mount source code (enables hot reload)
      - /app/node_modules   # keep container's node_modules, not host's
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgresql://postgres:password@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"  # expose for local DB tools

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:
Enter fullscreen mode Exit fullscreen mode

Commands:

docker-compose up       # start all services
docker-compose up -d    # start in background
docker-compose down     # stop and remove containers
docker-compose logs -f  # tail logs
docker-compose exec app bash  # shell into running container
Enter fullscreen mode Exit fullscreen mode

The volume mount - .:/app syncs your local source code into the container — changes you make locally reflect immediately in the running app.

Multi-Stage Builds

Use multi-stage builds to keep production images small:

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production  # only production deps
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode

The final image contains only the built output and production dependencies — no source files, no dev dependencies, no build tools. This can reduce image size from 500MB to under 100MB.

Environment Variables and Secrets

Never bake secrets into images. Use environment variables:

# docker-compose.yml
services:
  app:
    env_file:
      - .env  # load from file (don't commit .env)
    environment:
      - NODE_ENV=production
Enter fullscreen mode Exit fullscreen mode

For production, use your platform's secrets management (AWS Secrets Manager, GitHub Secrets, Fly.io secrets) — not environment variables baked into the image.

Docker Networks

Services in the same Compose file can talk to each other by service name:

# app can connect to 'db' and 'redis' by name
services:
  app:
    # ...
    environment:
      DATABASE_URL: postgresql://postgres:password@db:5432/myapp
      REDIS_URL: redis://redis:6379
  db:
    image: postgres:15-alpine
  redis:
    image: redis:7-alpine
Enter fullscreen mode Exit fullscreen mode

Docker creates a private network. Ports you expose (ports: - "5432:5432") are only for external access (your host machine, database tools).

Useful Commands

# Build
docker build -t myapp:latest .
docker build -t myapp:v1.2 --no-cache .

# Run
docker run -p 3000:3000 myapp:latest
docker run -it myapp:latest bash  # interactive shell

# Inspect
docker ps                          # running containers
docker ps -a                       # all containers
docker images                      # list images
docker logs container_name -f      # tail logs
docker exec -it container_name bash  # shell into running container

# Cleanup
docker system prune                # remove unused containers, images, networks
docker volume prune                # remove unused volumes
docker image prune -a              # remove all unused images

# Compose shortcuts
docker-compose up --build          # rebuild before starting
docker-compose run app npm test    # run one-off command
Enter fullscreen mode Exit fullscreen mode

Building for Production

For a basic production deployment to a VPS:

# Build and tag
docker build -t myapp:$(git rev-parse --short HEAD) .
docker tag myapp:abc1234 myapp:latest

# Push to registry
docker push registry.example.com/myapp:latest

# On the server
docker pull registry.example.com/myapp:latest
docker-compose -f docker-compose.prod.yml up -d
Enter fullscreen mode Exit fullscreen mode

Use a registry (Docker Hub, GitHub Container Registry, AWS ECR) to store and share images.

Health Checks

Add health checks so orchestrators know when a container is ready:

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

Or in Compose:

services:
  app:
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 40s  # give app time to start before checking
Enter fullscreen mode Exit fullscreen mode

When Docker Is Worth It

Worth it:

  • Team has multiple devs with different OS/tooling
  • App requires specific runtime versions
  • Local dev needs multiple services (app + DB + cache + queue)
  • Deploying to environments you don't fully control

Might be overkill:

  • Solo project with consistent tooling
  • Serverless or PaaS deployment (Vercel, Railway, Render) that handles containerization
  • Very simple apps with no external services

Docker adds overhead in setup and mental model. For a Next.js app deploying to Vercel, you probably don't need it. For a backend service with Postgres, Redis, and background workers, it's worth it from day one.

Top comments (0)