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) 

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"] 

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 

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: 

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 

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"] 

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 

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 

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 

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 

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 

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 

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.