Docker & Containerization
From Dockerfile basics to production-ready multi-stage builds, orchestration, and security
Why Docker Matters
Containers have become the standard unit of deployment in cloud-native ecosystems. Docker gives you consistent, portable, and reproducible environments, from laptop to production. Mastering Docker is the foundation for Kubernetes, CI/CD pipelines, serverless containers, and scalable microservices.
Dockerfile Fundamentals, The Blueprint
A Dockerfile is a text file containing instructions to build a Docker image. Each instruction creates a layer, and Docker caches these layers for faster rebuilds.
| Instruction | Purpose & Usage |
|---|---|
FROM | Sets the base image. Every Dockerfile starts with this. Choose slim/alpine versions for smaller sizes. |
WORKDIR | Sets working directory. All subsequent commands run from here. Creates the directory if it doesn't exist. |
COPY | Copies files from build context to image. Use COPY . . carefully, prefer specific files. |
RUN | Executes commands during build. Chain commands with && to reduce layers. |
CMD | Default command when container starts. Can be overridden at runtime. |
ENTRYPOINT | Sets the main executable. Use with CMD for default arguments. |
EXPOSE | Documents which ports the container listens on. Doesn't actually publish ports. |
ENV | Sets environment variables. Available during build and runtime. |
# Dockerfile FROM node:20-alpine WORKDIR /app # Copy package files first (for layer caching) COPY package*.json ./ # Install dependencies RUN npm ci --omit=dev # Copy application code COPY . . # Document the port EXPOSE 3000 # Default command CMD ["node", "server.js"]
$ docker build -t my-app:1.0 . [+] Building 12.3s (10/10) FINISHED => [1/5] FROM node:20-alpine => [2/5] WORKDIR /app => [3/5] COPY package*.json ./ => [4/5] RUN npm ci --omit=dev => [5/5] COPY . . => exporting to image Successfully built and tagged my-app:1.0
.dockerignore, Keep Your Images Clean
The .dockerignore file prevents unnecessary files from being sent to the Docker daemon, reducing build context size and preventing sensitive data leaks.
# .dockerignore
node_modules npm-debug.log .git .env .env.local *.md .vscode .idea dist coverage .DS_Store
Without .dockerignore: Build context = 450MB
With .dockerignore: Build context = 12MB
37x smaller! Faster builds and no accidentally copied secrets.
Images vs Containers, The Core Difference
Image
Read-only template (like a class or blueprint)
- Contains OS + app + dependencies
- Immutable & versioned
- Stored in registries (Docker Hub, ECR, GCR)
- Built from Dockerfile
Container
Running (or stopped) instance of an image (like an object)
- Writable layer on top of image
- Can be many from one image
- Ephemeral by default
- Isolated processes with own filesystem
$ docker pull nginx:1.25-alpine $ docker images REPOSITORY TAG IMAGE ID CREATED SIZE nginx 1.25-alpine 4bb46517cac3 2 weeks ago 41MB
$ docker run -d -p 8080:80 --name my-nginx nginx:1.25-alpine $ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES a7f8c9d2e1b3 nginx:1.25-alpine "/docker-entrypoint.…" 3 seconds ago Up 2 seconds 0.0.0.0:8080->80/tcp my-nginx
1. Docker created a container from the nginx:1.25-alpine image
2. Mapped host port 8080 → container port 80
3. Ran it in detached mode (-d) so it runs in the background
4. Named it "my-nginx" for easy reference
Image Tagging & Versioning Strategy
# Build with semantic versioning $ docker build -t my-app:1.2.3 . $ docker tag my-app:1.2.3 my-app:1.2 $ docker tag my-app:1.2.3 my-app:1 $ docker tag my-app:1.2.3 my-app:latest # Push to registry $ docker tag my-app:1.2.3 myregistry.io/my-app:1.2.3 $ docker push myregistry.io/my-app:1.2.3
Container Lifecycle Management
Understanding the container lifecycle is critical for debugging and operations.
| Command | When to Use |
|---|---|
docker run | Create and start a new container from an image. Use for first-time launches. |
docker start | Restart a stopped container. Preserves the container's state and filesystem. |
docker stop | Gracefully stop a running container (sends SIGTERM, waits 10s, then SIGKILL). |
docker kill | Immediately terminate a container (SIGKILL). Use when stop hangs. |
docker restart | Stop and start a container. Useful for applying config changes. |
docker rm | Remove a stopped container. Add -f to force remove running ones. |
Essential docker run Flags
$ docker run \
-d \ # Detached mode (background)
--name api \ # Friendly name
-p 3000:3000 \ # Port mapping (host:container)
-e NODE_ENV=production \ # Environment variable
-v app-data:/data \ # Volume mount
--restart unless-stopped \ # Auto-restart policy
--memory="512m" \ # Memory limit
--cpus="1.5" \ # CPU limit
my-app:1.2.3Debugging Running Containers
# View real-time logs $ docker logs -f my-nginx # View last 50 lines with timestamps $ docker logs --tail 50 -t my-nginx # Execute command in running container $ docker exec -it my-nginx sh /# ls -la /# ps aux /# exit # Inspect container details (networking, volumes, env vars) $ docker inspect my-nginx # View resource usage stats $ docker stats my-nginx
docker exec -it container-name sh is your best friend for debugging. It drops you into a shell inside the running container.Volumes, Making Data Survive Container Death
Containers are ephemeral, delete the container = delete the data (unless you use volumes).
| Type | When to Use | Example |
|---|---|---|
| Named Volume | Production data (databases, user uploads). Managed by Docker. | -v postgres-data:/var/lib/postgresql/data |
| Bind Mount | Development (hot reload). Direct host filesystem access. | -v $(pwd)/app:/app |
| tmpfs Mount | Temporary data (sessions, caches). Stored in memory only. | --tmpfs /tmp |
# Create and use named volume $ docker volume create postgres-data $ docker run -d -v postgres-data:/var/lib/postgresql/data postgres:16 $ docker volume ls DRIVER VOLUME NAME local postgres-data # Volume persists even if container is deleted $ docker rm -f <container-id> $ docker volume ls # Still there!
# Mount current directory for live code changes $ docker run -d \ -v $(pwd)/src:/app/src \ -v $(pwd)/package.json:/app/package.json \ -p 3000:3000 \ node:20-alpine npm run dev # Edit files on host → changes immediately visible in container
Networking, How Containers Talk
Network Modes
- bridge (default), private internal network
- host, shares host network stack (no isolation)
- none, no networking
- custom, user-defined networks (recommended)
Why Custom Networks?
- Automatic DNS resolution (use container names)
- Better isolation between apps
- Can connect/disconnect on the fly
- Essential for multi-container apps
# Create custom network $ docker network create my-app-net $ docker run -d --name api --network my-app-net my-api:1.0 $ docker run -d --name db --network my-app-net postgres:16 # API can now reach database via hostname "db" # Inside api container: $ docker exec api ping db PING db (172.18.0.3): 56 data bytes 64 bytes from 172.18.0.3: icmp_seq=0 ttl=64 time=0.123 ms
Port Mapping Explained
# Different port mapping scenarios $ docker run -p 8080:80 nginx # Host 8080 → Container 80 $ docker run -p 127.0.0.1:8080:80 nginx # Only localhost can access $ docker run -p 80:80 -p 443:443 nginx # Multiple ports $ docker run -P nginx # Random port assignment
Docker Compose, Multi-Container Orchestration
Running multi-container apps with docker run gets messy fast. Docker Compose lets you define entire application stacks in a single YAML file.
# docker-compose.yml
version: '3.8'
services:
# Database
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: admin
POSTGRES_PASSWORD: secret
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- backend
# Redis Cache
cache:
image: redis:7-alpine
networks:
- backend
# API Backend
api:
build: ./api
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://admin:secret@db:5432/myapp
REDIS_URL: redis://cache:6379
depends_on:
- db
- cache
networks:
- backend
- frontend
# Frontend
web:
build: ./web
ports:
- "80:80"
depends_on:
- api
networks:
- frontend
networks:
frontend:
backend:
volumes:
postgres-data:# Start entire stack $ docker compose up -d
Creating network "myapp_frontend" with the default driver Creating network "myapp_backend" with the default driver Creating volume "myapp_postgres-data" with default driver Creating myapp_db_1 ... done Creating myapp_cache_1 ... done Creating myapp_api_1 ... done Creating myapp_web_1 ... done
docker compose up --build to force rebuild images when you change your Dockerfile or application code.# View running services $ docker compose ps # View logs $ docker compose logs -f api # Stop and remove everything $ docker compose down # Stop and remove including volumes $ docker compose down -v
Environment Variables & Secrets
# .env (DO NOT COMMIT THIS)
POSTGRES_PASSWORD=super_secret_password API_KEY=abc123xyz789 JWT_SECRET=your-secret-key-here
# docker-compose.yml
services:
api:
build: ./api
env_file:
- .env # Load from file
environment:
NODE_ENV: production # Or set directlySecurity Warning
Add .env to .gitignore! Never commit secrets to version control. Use secret management tools (Vault, AWS Secrets Manager) in production.
Multi-stage Builds, Small, Secure Production Images
Build in a big image → copy only artifacts to tiny runtime image = much smaller, faster, more secure images.
# Dockerfile - Node.js + React example
# ---------------- Stage 1: Build ---------------- FROM node:20-alpine AS builder WORKDIR /app # Install dependencies COPY package*.json ./ RUN npm ci # Build application COPY . . RUN npm run build # ---------------- Stage 2: Production ---------------- FROM nginx:1.25-alpine # Copy only the built artifacts COPY --from=builder /app/dist /usr/share/nginx/html # Copy nginx config (optional) COPY nginx.conf /etc/nginx/nginx.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]
After multi-stage: ~40MB (only nginx + static files)
Result: 22x smaller! Faster pulls, less attack surface, cheaper storage
Multi-stage for Go Backend
# Dockerfile - Go backend
# Build stage FROM golang:1.21-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o main . # Production stage - distroless for maximum security FROM gcr.io/distroless/static-debian12 COPY --from=builder /app/main / EXPOSE 8080 USER nonroot:nonroot CMD ["/main"]
Security bonus: Distroless has no shell, package manager, or unnecessary binaries, minimal attack surface
Best Practices & Security
Security Essentials
DO
- Use official or verified images
- Specify exact versions (not :latest)
- Run as non-root user
- Use multi-stage builds
- Scan images for vulnerabilities
- Use .dockerignore
- Keep images updated
DON'T
- Use :latest in production
- Run as root unnecessarily
- Store secrets in images
- Include dev dependencies
- Expose unnecessary ports
- Use untrusted base images
- Commit .env files
Running as Non-Root User
FROM node:20-alpine
# Create app user
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
WORKDIR /app
# Install dependencies as root
COPY package*.json ./
RUN npm ci --omit=dev
# Copy app and change ownership
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]Image Vulnerability Scanning
# Scan with Docker Scout (built-in) $ docker scout cves my-app:1.0 # Scan with Trivy (comprehensive) $ trivy image my-app:1.0 Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
Layer Caching Optimization
FROM node:20-alpine WORKDIR /app COPY . . RUN npm install CMD ["node", "app.js"]
FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . CMD ["node", "app.js"]
Resource Limits (Production)
# Set limits to prevent resource exhaustion $ docker run -d \ --memory="512m" \ --memory-swap="512m" \ --cpus="1.0" \ --pids-limit=100 \ --restart=unless-stopped \ my-app:1.0
Common Mistakes to Avoid
❌ Using :latest in Production
:latest is not actually "latest", it's just the default tag. Your "latest" might be different than someone else's. Always use semantic versioning: my-app:1.2.3
❌ Not Using .dockerignore
Copying node_modules, .git, and build artifacts bloats images and can leak secrets. Build context goes from 500MB to 5MB with proper .dockerignore.
❌ Running as Root
If an attacker compromises your app running as root, they have full container control. Always create and switch to a non-root user.
❌ Storing Secrets in Images
ENV variables in Dockerfile are baked into the image and visible to anyone with docker inspect. Use Docker secrets, env files, or secret management tools.
❌ Not Cleaning Up
Docker accumulates dangling images, stopped containers, and unused volumes. Run docker system prune -a regularly to reclaim disk space.
Cleanup & Maintenance
# Remove stopped containers $ docker container prune # Remove unused images $ docker image prune -a # Remove unused volumes $ docker volume prune # Remove unused networks $ docker network prune # Nuclear option: remove EVERYTHING $ docker system prune -a --volumes # See what's taking up space $ docker system df TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 15 5 2.1GB 1.2GB (57%) Containers 8 2 450MB 300MB (66%) Local Volumes 3 1 1.5GB 800MB (53%)
Warning
docker system prune -a --volumes is destructive! It removes all unused images, stopped containers, and volumes. Only use if you're sure you don't need them.
Key Takeaways
- Dockerfile basics: FROM, WORKDIR, COPY, RUN, CMD, the foundation of image building
- Images vs Containers: Images are blueprints, containers are running instances
- Volumes: Named volumes for production, bind mounts for development
- Networking: Custom networks enable service discovery via container names
- Docker Compose: Define multi-container apps in YAML, start with one command
- Multi-stage builds: 10-20x smaller images, minimal attack surface
- Security: Non-root users, image scanning, secrets management, version pinning
- Best practices: .dockerignore, layer caching, resource limits, regular cleanup