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.
