Docker has fundamentally changed how software is developed, tested, and deployed. “It works on my machine” is no longer an excuse—containers make environment consistency a reality. This article will take you from zero to mastering Docker containerization.
Why Do We Need Docker?
Traditional Deployment Pain Points
Traditional Deployment Problems:
┌─────────────────────────────────────────────────────┐
│ Development Env Production Env │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Node 18.x │ → │ Node 16.x │ ❌ │
│ │ Ubuntu 22 │ │ CentOS 7 │ ❌ │
│ │ npm 9.x │ │ npm 8.x │ ❌ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Common Issues: │
│ • Dependency version mismatches │
│ • System library differences │
│ • Configuration file differences │
│ • Port conflicts between apps │
└─────────────────────────────────────────────────────┘
Docker’s Solution
Docker Containerization:
┌─────────────────────────────────────────────────────┐
│ Development Env Production Env │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Container │ = │ Container │ ✅ │
│ │ ┌─────────┐ │ │ ┌─────────┐ │ │
│ │ │ App │ │ │ │ App │ │ │
│ │ │ Node 18 │ │ │ │ Node 18 │ │ │
│ │ │ Alpine │ │ │ │ Alpine │ │ │
│ │ └─────────┘ │ │ └─────────┘ │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ Advantage: Identical environments, build once │
│ run anywhere │
└─────────────────────────────────────────────────────┘
Core Concepts
Images vs Containers
Relationship Between Images and Containers:
┌─────────────────────────────────────────────────────┐
│ │
│ Image (Read-only) Container (Instance) │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Layer 4 │ docker run │ Layer 4 │ │
│ │ Layer 3 │ ─────────→ │ Layer 3 │ │
│ │ Layer 2 │ │ Layer 2 │ │
│ │ Layer 1 │ │ Layer 1 │ │
│ │ Base Image │ │ Base Image │ │
│ └─────────────┘ ├─────────────┤ │
│ │ Writable (R/W)│ │
│ Analogy: Class └─────────────┘ │
│ Analogy: Object │
│ │
│ One image can create multiple containers │
└─────────────────────────────────────────────────────┘
Layered Filesystem
# Each instruction creates a layer
FROM node:18-alpine # Base layer
WORKDIR /app # New layer
COPY package*.json ./ # New layer
RUN npm install # New layer (largest)
COPY . . # New layer
CMD ["npm", "start"] # Metadata, no layer
Layer Reuse Mechanism:
┌─────────────────────────────────────────────────────┐
│ Building App A Building App B │
│ ┌─────────┐ ┌─────────┐ │
│ │ App A │ │ App B │ ← Different │
│ ├─────────┤ ├─────────┤ │
│ │npm deps │ │npm deps │ ← Different │
│ ├─────────┤ ├─────────┤ │
│ │node:18 │ ════════ │node:18 │ ← Reused! │
│ └─────────┘ └─────────┘ │
│ │
│ Same layers stored once, saving space and time │
└─────────────────────────────────────────────────────┘
Writing Dockerfiles
Basic Structure
# syntax=docker/dockerfile:1
# Base image
FROM node:18-alpine
# Metadata
LABEL maintainer="dev@example.com"
LABEL version="1.0"
# Set working directory
WORKDIR /app
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Copy dependency files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1
# Startup command
CMD ["node", "server.js"]
Multi-stage Builds
Multi-stage builds can significantly reduce final image size:
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:18-alpine AS production
WORKDIR /app
# Copy only necessary files
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Use non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
USER nextjs
EXPOSE 3000
CMD ["node", "dist/server.js"]
Optimization Tips
# ✅ Leverage caching: less changing layers first
COPY package*.json ./
RUN npm ci
COPY . . # Source changes often, put last
# ✅ Merge RUN commands to reduce layers
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# ✅ Use .dockerignore to exclude unnecessary files
# .dockerignore
node_modules
.git
*.md
.env
dist
# ✅ Use specific version tags
FROM node:18.19.0-alpine3.19 # Don't use latest
# ✅ Minimize base image
FROM node:18-alpine # Much smaller than node:18
# Or use distroless
FROM gcr.io/distroless/nodejs18-debian11
Common Commands
Image Operations
# Build image
docker build -t myapp:1.0 .
docker build -t myapp:1.0 -f Dockerfile.prod .
# View images
docker images
docker image ls
# Remove image
docker rmi myapp:1.0
docker image prune # Remove dangling images
# Tag image
docker tag myapp:1.0 registry.example.com/myapp:1.0
# Push/pull images
docker push registry.example.com/myapp:1.0
docker pull nginx:alpine
Container Operations
# Run container
docker run -d --name myapp -p 3000:3000 myapp:1.0
docker run -it --rm node:18-alpine sh # Interactive, remove on exit
# View containers
docker ps # Running containers
docker ps -a # All containers
# Container logs
docker logs myapp
docker logs -f myapp # Follow logs
# Enter container
docker exec -it myapp sh
docker exec -it myapp /bin/bash
# Stop/start/restart
docker stop myapp
docker start myapp
docker restart myapp
# Remove container
docker rm myapp
docker rm -f myapp # Force remove running container
# View resource usage
docker stats
Data Persistence
# Volume (recommended)
docker volume create mydata
docker run -v mydata:/app/data myapp:1.0
# Bind Mount (for development)
docker run -v $(pwd)/src:/app/src myapp:1.0
# View volumes
docker volume ls
docker volume inspect mydata
Docker Compose
Docker Compose is used to define and run multi-container applications.
Basic Configuration
# docker-compose.yml
version: '3.8'
services:
# Web application
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://user:pass@db:5432/mydb
- REDIS_URL=redis://cache:6379
depends_on:
- db
- cache
volumes:
- ./logs:/app/logs
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
# Database
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=mydb
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
# Cache
cache:
image: redis:7-alpine
volumes:
- redis_data:/data
command: redis-server --appendonly yes
# Nginx reverse proxy
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- app
volumes:
postgres_data:
redis_data:
networks:
default:
driver: bridge
Compose Commands
# Start services
docker compose up -d
# View service status
docker compose ps
# View logs
docker compose logs -f app
# Stop services
docker compose down
# Stop and remove volumes
docker compose down -v
# Rebuild and start
docker compose up -d --build
# Scale services
docker compose up -d --scale app=3
Development Environment Config
# docker-compose.override.yml (auto-merged)
version: '3.8'
services:
app:
build:
target: development
volumes:
- .:/app
- /app/node_modules # Exclude node_modules
environment:
- NODE_ENV=development
command: npm run dev
# Development tools
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025"
- "8025:8025"
Production Best Practices
Security Hardening
# 1. Use non-root user
FROM node:18-alpine
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser
# 2. Read-only filesystem
docker run --read-only --tmpfs /tmp myapp:1.0
# 3. Limit capabilities
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp:1.0
# 4. Scan for vulnerabilities
docker scout cve myapp:1.0
trivy image myapp:1.0
Resource Limits
# docker-compose.yml
services:
app:
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
# Or via command line
docker run --cpus=0.5 --memory=512m myapp:1.0
Log Management
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Use external logging system
docker run --log-driver=fluentd myapp:1.0
Health Checks and Restart Policies
services:
app:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
# Or: always, on-failure, no
Practical: Deploying Full Application Stack
Project Structure
myapp/
├── docker-compose.yml
├── docker-compose.prod.yml
├── .env
├── .env.example
├── app/
│ ├── Dockerfile
│ └── ...
├── nginx/
│ ├── nginx.conf
│ └── Dockerfile
└── scripts/
├── deploy.sh
└── backup.sh
Production Compose
# docker-compose.prod.yml
version: '3.8'
services:
app:
image: registry.example.com/myapp:${VERSION:-latest}
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
max_attempts: 3
environment:
- NODE_ENV=production
env_file:
- .env.production
networks:
- internal
- web
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
- app
networks:
- web
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backups:/backups
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
networks:
- internal
volumes:
postgres_data:
networks:
internal:
internal: true
web:
secrets:
db_password:
file: ./secrets/db_password.txt
Deployment Script
#!/bin/bash
# scripts/deploy.sh
set -e
VERSION=${1:-latest}
REGISTRY="registry.example.com"
echo "Deploying version: $VERSION"
# Build and push image
docker build -t $REGISTRY/myapp:$VERSION ./app
docker push $REGISTRY/myapp:$VERSION
# Deploy
VERSION=$VERSION docker compose -f docker-compose.prod.yml up -d
# Wait for health checks
sleep 30
docker compose -f docker-compose.prod.yml ps
echo "Deployment complete!"
Debugging Tips
Debugging Inside Containers
# Enter running container
docker exec -it myapp sh
# Enter as root user
docker exec -u root -it myapp sh
# View container processes
docker top myapp
# View container details
docker inspect myapp
Build Debugging
# View build process
docker build --progress=plain -t myapp .
# Stop at specific stage
docker build --target builder -t myapp:builder .
# Build without cache
docker build --no-cache -t myapp .
Network Debugging
# View networks
docker network ls
docker network inspect bridge
# Test connectivity between containers
docker exec app ping db
# View port mappings
docker port myapp
Summary
Docker containerization makes application deployment predictable and repeatable:
| Aspect | Traditional | Docker Containers |
|---|---|---|
| Env Consistency | Hard to guarantee | Fully consistent |
| Deploy Speed | Slow (config env) | Fast (start container) |
| Resource Isolation | Requires VMs | Lightweight isolation |
| Scalability | Complex | Simple (more containers) |
| Rollback | Difficult | Easy (switch image version) |
Key Takeaways:
- Images are read-only templates, containers are running instances
- Multi-stage builds significantly reduce image size
- Docker Compose simplifies multi-container application management
- Production environments need security hardening and resource limits
- Properly leverage layer caching to speed up builds
Containerization is not just a technical upgrade—it’s a core practice of DevOps culture.
Containerization: Making “works on my machine” a thing of the past.