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.
Top comments (0)