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.

InstructionPurpose & Usage
FROMSets the base image. Every Dockerfile starts with this. Choose slim/alpine versions for smaller sizes.
WORKDIRSets working directory. All subsequent commands run from here. Creates the directory if it doesn't exist.
COPYCopies files from build context to image. Use COPY . . carefully, prefer specific files.
RUNExecutes commands during build. Chain commands with && to reduce layers.
CMDDefault command when container starts. Can be overridden at runtime.
ENTRYPOINTSets the main executable. Use with CMD for default arguments.
EXPOSEDocuments which ports the container listens on. Doesn't actually publish ports.
ENVSets environment variables. Available during build and runtime.
BASIC NODE.JS DOCKERFILE
# 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"]
Building the image:
$ 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
Pro-Tip: Copying package.json separately leverages Docker's layer caching, if dependencies haven't changed, Docker reuses the cached layer.

.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
Impact:
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
What just happened:
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
Tagging Best Practice: Create multiple tags for flexibility. Users wanting stability use 1.2.3, those wanting minor updates use 1.2, and those living dangerously use latest.

Container Lifecycle Management

Understanding the container lifecycle is critical for debugging and operations.

CommandWhen to Use
docker runCreate and start a new container from an image. Use for first-time launches.
docker startRestart a stopped container. Preserves the container's state and filesystem.
docker stopGracefully stop a running container (sends SIGTERM, waits 10s, then SIGKILL).
docker killImmediately terminate a container (SIGKILL). Use when stop hangs.
docker restartStop and start a container. Useful for applying config changes.
docker rmRemove 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.3
Result: Container runs with resource limits, auto-restarts on failure, and persists data to a volume.

Debugging 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
Pro-Tip: 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).

TypeWhen to UseExample
Named VolumeProduction data (databases, user uploads). Managed by Docker.-v postgres-data:/var/lib/postgresql/data
Bind MountDevelopment (hot reload). Direct host filesystem access.-v $(pwd)/app:/app
tmpfs MountTemporary data (sessions, caches). Stored in memory only.--tmpfs /tmp
NAMED VOLUME (PRODUCTION)
# 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!
Result: Database data survives container restarts and even deletions. Safe for production.
BIND MOUNT (DEVELOPMENT)
# 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
Warning: Bind mounts give containers direct access to host filesystem. Never use in production unless you know what you're doing.

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
Magic: No hardcoded IPs! Docker's internal DNS resolves "db" to the database container's IP automatically.

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
Security Tip: Bind to 127.0.0.1 when exposing services only for local development to prevent external access.

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
Building the image:
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
Pro-Tip: Use 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
What just happened: One command started a database, cache, API, and frontend, all connected with proper networking and dependencies!

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 directly
Security 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;"]
Before: ~900MB (full node image with dev dependencies)
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"]
Final image size: ~8MB (just the binary + minimal runtime)
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"]
Security impact: If an attacker compromises the app, they only have appuser privileges, not root. Limits potential damage.

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)
CI/CD Integration: Run vulnerability scans in your pipeline and fail builds if critical CVEs are found.

Layer Caching Optimization

❌ INEFFICIENT
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "app.js"]
Every code change rebuilds npm install (slow!)
✅ OPTIMIZED
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "app.js"]
npm install cached unless package.json changes

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
Without limits, a single container can consume all host resources and crash other containers. Always set limits in production.

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