Docker & Containerization
Dockerfile basics, 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
One read-only image, a thin writable layer per container
Figure: The image is immutable, read-only layers built bottom-up from the Dockerfile. Each container adds only a thin writable layer and shares the same underlying image, which is why running ten containers from one image is cheap.
# Pull and inspect the image
$ docker pull nginx:1.25-alpine $ docker images REPOSITORY TAG IMAGE ID CREATED SIZE nginx 1.25-alpine 4bb46517cac3 2 weeks ago 41MB
# Run the container
$ 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
# 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
On a custom network, containers reach each other by name
Figure: On a user-defined bridge network, Docker's built-in DNS resolves container names (api, db) to their IPs, so nothing is hardcoded. Only ports you explicitly publish (8080:80) are reachable from the host.
# 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.
Build in a heavy stage, ship only the artifacts
Figure: The builder stage carries the whole toolchain and dev dependencies, but only the compiled dist/ crosses into the final image. Everything else is discarded, taking the image from ~900MB to ~40MB with far less attack surface.
# 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
# Dockerfile - run 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
: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
Copying node_modules, .git, and build artifacts bloats images and can leak secrets. Build context goes from 500MB to 5MB with proper .dockerignore.
If an attacker compromises your app running as root, they have full container control. Always create and switch to a non-root user.
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.
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