Intermediate Β· DevOps Β· Production-Ready

Docker Intermediate

Move beyond the basics. Learn the patterns that actually matter in production β€” smaller images, safer containers, resilient systems. πŸ”§

⭐ ⭐ ⭐ ⭐ ⭐
πŸ“š Your Progress
0%
Lesson 1 of 9

πŸ—οΈ Multi-Stage Builds INTERMEDIATE

Your images are probably enormous. Multi-stage builds are the single most impactful technique for shrinking them β€” sometimes from 1 GB down to under 20 MB.

πŸ“‹ Prerequisites: You should already know how to write a basic Dockerfile (FROM, RUN, COPY, CMD) and build images with docker build. If not, complete the beginner course first.
πŸ€” The Problem: Fat Images

In the beginner course, every Dockerfile started from a base image, installed build tools, compiled or installed dependencies, then packaged everything together. The result: the final image contains all of that β€” compilers, debuggers, build toolchains, test frameworks β€” things that have zero business being in a production container.

Consider a Go application. Compiling it requires the entire Go compiler (hundreds of MB). But the compiled binary itself is a single static file β€” maybe 10 MB. Why ship the compiler to production? You don't. Multi-stage builds let you use the compiler in one stage and throw it away before the final image is made.

ApproachExample Image SizeAttack Surface
Single-stage (naive)~900 MBLarge β€” build tools included
Multi-stage~15 MBMinimal β€” binary only
Multi-stage + scratch~8 MBNear zero β€” no OS at all
πŸ’‘ How Multi-Stage Builds Work

A multi-stage Dockerfile has more than one FROM instruction. Each FROM starts a fresh, independent stage. You can name stages with AS <name>. The key trick is the COPY --from=<stage> instruction, which lets you copy files from any previous stage into the current one β€” and leave everything else behind.

Docker only ships the last stage as the final image. All earlier stages are used during the build and then discarded. They never appear in the final image at all.

Dockerfile β€” Multi-Stage Go Build
# ═══════════════════════════════════════════
# STAGE 1: "builder" β€” has the full Go compiler
# This stage will be DISCARDED from the final image
# ═══════════════════════════════════════════
FROM golang:1.22-alpine AS builder

WORKDIR /build

# Download dependencies first (layer cache trick)
COPY go.mod go.sum ./
RUN go mod download

# Copy source and compile a statically-linked binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server

# ═══════════════════════════════════════════
# STAGE 2: "final" β€” a bare-bones Linux image
# ONLY the compiled binary is copied in from the builder stage
# The Go compiler, source code, and build tools NEVER appear here
# ═══════════════════════════════════════════
FROM alpine:3.19

# Add CA certificates so HTTPS requests work in the container
RUN apk --no-cache add ca-certificates

# Copy ONLY the compiled binary from the builder stage
COPY --from=builder /app/server /usr/local/bin/server

EXPOSE 8080
CMD ["server"]
🐍 Multi-Stage for Python (a Different Pattern)

Python doesn't compile to a single binary the way Go does, but multi-stage builds are still useful β€” especially for separating dependency installation (which needs build tools like gcc to compile C extensions) from the final runtime image (which only needs the compiled wheels).

Dockerfile β€” Multi-Stage Python App
# Stage 1: Install dependencies using a full Python image
# (some packages need gcc/musl to compile β€” the slim image has it here)
FROM python:3.12-slim AS deps

WORKDIR /install
COPY requirements.txt .

# Install into a specific target directory we can copy wholesale
RUN pip install --no-cache-dir --prefix=/runtime -r requirements.txt

# Stage 2: Clean runtime image
FROM python:3.12-slim

WORKDIR /app

# Copy the pre-installed packages from the deps stage
COPY --from=deps /runtime /usr/local

# Copy only application source code β€” no build tools, no pip cache
COPY src/ ./src/

CMD ["python", "src/app.py"]
🧊
The Extreme Case: FROM scratch scratch is a special Docker base image that is literally empty β€” no OS, no shell, no filesystem, nothing. Statically compiled Go and Rust binaries can run directly from scratch. The result is an image that contains only your binary. This is the gold standard for minimal attack surface in production. The tradeoff: no docker exec -it bash for debugging, because there's no bash.
Terminal β€” Build and Verify Image Size
# Build with a specific target stage (useful for debugging)
# This stops at the "builder" stage so you can inspect it
docker build --target builder -t myapp:debug .

# Build the full final image normally
docker build -t myapp:prod .

# Compare the sizes β€” you'll be amazed
docker images myapp

# Output:
# REPOSITORY   TAG     IMAGE ID       SIZE
# myapp        debug   a1b2c3d4e5f6   812MB   ← builder stage
# myapp        prod    f6e5d4c3b2a1   14.3MB  ← final stage  πŸŽ‰
🧠 Check Your Understanding
In a multi-stage Dockerfile with 3 stages, which stage becomes the final Docker image?
🧠 Check Your Understanding
Which Dockerfile instruction copies files from a previous build stage into the current one?
Lesson 2 of 9

πŸ”’ Container Security INTERMEDIATE

Running containers as root, packing secrets into images, and using unscanned base images are the three fastest ways to get breached in production. Let's fix all three.

🚨
The Default is Dangerous
By default, processes inside Docker containers run as root. If an attacker exploits a vulnerability in your app and escapes the container, they have root access on your host machine. This is not theoretical β€” it's happened to real companies. Every production container should run as a non-root user.
πŸ‘€ Security Rule 1: Never Run as Root

The USER instruction in a Dockerfile sets the user that processes run as inside the container. Create a dedicated application user with no special privileges. This limits what an attacker can do even if they compromise your application.

Dockerfile β€” Running as a Non-Root User
FROM node:20-alpine

WORKDIR /app

# Create a system group and user with no login shell and no password
# -S = system account, -G = assign to group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Install dependencies as root (required to write to /app)
COPY package*.json ./
RUN npm ci --only=production

COPY . .

# Give our new user ownership of the app directory
RUN chown -R appuser:appgroup /app

# SWITCH to the non-root user. All subsequent instructions run as appuser.
USER appuser

EXPOSE 3000
CMD ["node", "server.js"]
πŸ”‘ Security Rule 2: Never Put Secrets in Images

This is a career-ending mistake that happens constantly. An image baked with a secret (API key, database password, private key) is dangerous because:

  • Anyone who pulls the image can inspect every layer and extract the secret with docker history
  • Even if you add a later layer that "deletes" the secret, it's still in the earlier layer's history
  • If the image is ever pushed to a registry β€” even a private one β€” the secret has left your machine

The correct approach: pass secrets at runtime via environment variables, Docker secrets, or a secrets manager like HashiCorp Vault or AWS Secrets Manager.

Terminal β€” Passing Secrets at Runtime (Not Build Time)
# ❌ WRONG β€” This bakes the secret into the image permanently
# ENV DATABASE_PASSWORD=supersecret123  ← NEVER do this in a Dockerfile

# βœ… CORRECT β€” Pass secrets as environment variables at runtime
docker run -e DATABASE_PASSWORD=supersecret123 myapp

# βœ… BETTER β€” Load from a .env file (which you NEVER commit to Git)
docker run --env-file .env myapp

# βœ… BEST for production β€” Docker Swarm / Compose secrets
# (covered in the Swarm lesson β€” secrets are encrypted at rest)

# In docker-compose.yml:
# secrets:
#   db_password:
#     file: ./secrets/db_password.txt
#
# The secret is mounted as a file at /run/secrets/db_password
# inside the container β€” never as an environment variable.
πŸ” Security Rule 3: Scan Your Images for Vulnerabilities

Base images contain operating system packages. Those packages have CVEs (Common Vulnerabilities and Exposures) β€” known security holes. You need to scan your images regularly to catch these before attackers do.

Docker's built-in scanner is Docker Scout. It analyzes every package in your image, checks against vulnerability databases, and recommends base image upgrades.

Terminal β€” Scanning Images with Docker Scout
# Quick vulnerability summary for a local image
docker scout quickview myapp:prod

# Full CVE report with severity levels and fix recommendations
docker scout cves myapp:prod

# Check if a newer base image would fix vulnerabilities
docker scout recommendations myapp:prod

# Output example:
# βœ“  No critical vulnerabilities found
# !  3 high vulnerabilities in openssl 3.0.2
# β†’  Upgrade base image to alpine:3.19 to fix all 3
πŸ›‘οΈ Security Rule 4: Make the Container Filesystem Read-Only

If your application doesn't need to write to disk (most web servers don't), run the container with a read-only root filesystem. This means even if an attacker compromises your app, they can't write malicious files to the container's filesystem.

Terminal β€” Read-Only Filesystem + Writable Tmpfs
# --read-only makes the container filesystem read-only
# --tmpfs adds a temporary in-memory writable directory
# (needed for things like /tmp for temp files)
docker run \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /app/cache \
  -p 8080:8080 \
  myapp:prod

# In docker-compose.yml:
# services:
#   web:
#     read_only: true
#     tmpfs:
#       - /tmp
πŸ“‹
Security Checklist for Every Production Container
☐ Run as a non-root user (USER instruction)
☐ No secrets in the image (use runtime env vars or Docker secrets)
☐ Scan the image with Docker Scout before deploying
☐ Use minimal base images (Alpine or distroless)
☐ Read-only filesystem where possible (--read-only)
☐ Drop unnecessary Linux capabilities (--cap-drop ALL)
☐ Never use --privileged in production
🧠 Check Your Understanding
A colleague adds ENV DB_PASSWORD=hunter2 to a Dockerfile, builds, and pushes the image. What is the threat?
🧠 Check Your Understanding
What does --read-only on a docker run command do?
Lesson 3 of 9

❀️ Health Checks INTERMEDIATE

A running container is not the same as a healthy container. Docker has no way to know if your application inside is actually serving requests β€” unless you tell it how to check.

🩺 What is a Health Check?

Docker's HEALTHCHECK instruction defines a command that Docker runs periodically inside the container to determine if the application is functioning correctly. The command returns either exit code 0 (healthy) or 1 (unhealthy).

Without a health check, Docker only knows if the container process is running. With a health check, it knows if your application is actually ready to serve traffic. This is critical for:

  • Load balancers β€” only route traffic to healthy containers
  • Orchestrators β€” Swarm and Kubernetes restart unhealthy containers automatically
  • Rolling deployments β€” don't remove old containers until new ones pass their health check
Container lifecycle with HEALTHCHECK:

starting β†’ healthy β†’ ... continues serving traffic ...
                  β†“ (check fails 3 times)
              unhealthy β†’ orchestrator restarts container

Container lifecycle WITHOUT HEALTHCHECK:

running β†’ app crashes silently, process stays up β†’ traffic errors forever
πŸ“ HEALTHCHECK Syntax and Options

The HEALTHCHECK instruction takes four optional flags and a command:

  • --interval=30s β€” How often to run the check (default: 30 seconds)
  • --timeout=10s β€” How long to wait before declaring the check failed (default: 30 seconds)
  • --start-period=15s β€” Grace period after container startup before health checks begin counting failures (default: 0s). Give your app time to initialize!
  • --retries=3 β€” How many consecutive failures before the container is marked unhealthy (default: 3)
Dockerfile β€” Health Checks for Different App Types
# ── WEB SERVER (HTTP endpoint check) ──────────────────────
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --only=production

# Check the /health endpoint every 30s.
# Wait 20s before starting checks (app needs time to boot).
# 3 failures = unhealthy.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

# ── DATABASE (TCP port check) ──────────────────────────────
FROM postgres:16-alpine

# pg_isready checks if PostgreSQL is accepting connections
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
  CMD pg_isready -U postgres || exit 1

# ── REDIS ──────────────────────────────────────────────────
FROM redis:7-alpine

HEALTHCHECK --interval=10s --timeout=3s \
  CMD redis-cli ping | grep -q PONG || exit 1
Terminal β€” Monitoring Health Check Status
# See health status in container listing
docker ps

# STATUS column will show one of:
#   Up 5 minutes (health: starting)   ← within start-period
#   Up 5 minutes (healthy)            ← all good
#   Up 5 minutes (unhealthy)          ← needs investigation!

# Get detailed health check history (last 5 checks)
docker inspect --format '{{json .State.Health}}' <container_id> | jq

# Output shows each check result, exit code, and output:
# {
#   "Status": "healthy",
#   "FailingStreak": 0,
#   "Log": [
#     { "ExitCode": 0, "Output": "OK\n", "Start": "2024-..." }
#   ]
# }
πŸ”Œ The /health Endpoint β€” Best Practice

Your web application should expose a dedicated GET /health (or /healthz) endpoint that returns HTTP 200 when healthy. A great health endpoint doesn't just say "I'm running" β€” it actually checks its dependencies:

  • Can the app reach the database and run a simple query?
  • Is the Redis cache reachable?
  • Are any critical background workers running?

Return HTTP 503 if any critical dependency is down. This gives load balancers a true picture of whether the container can serve production traffic.

🧠 Check Your Understanding
Your Node.js app takes 45 seconds to fully initialize on startup. What HEALTHCHECK option prevents it from being marked unhealthy during this startup window?
Lesson 4 of 9

βš–οΈ Resource Limits INTERMEDIATE

Without limits, one misbehaving container can consume all available memory and bring down every other container on the host. cgroups let you enforce hard boundaries.

πŸ’₯ The "Noisy Neighbor" Problem

In a shared host environment β€” a VM running multiple containers β€” no container gets guaranteed resources by default. A memory leak in one container will happily consume all available RAM. The OS kernel's OOM (Out of Memory) killer will then start killing processes at random, which may be processes in completely unrelated containers. This is called the "noisy neighbor" problem, and it's catastrophic in production.

Docker uses Linux cgroups (control groups) β€” the same kernel feature that provides container isolation β€” to enforce hard limits on CPU, memory, and I/O per container.

Terminal β€” Setting Resource Limits
# ── MEMORY LIMITS ──────────────────────────────────────────

# Hard memory limit: container is killed if it exceeds 512MB
docker run --memory 512m myapp

# Soft limit (reservation): Docker tries to keep usage below
# this, but doesn't kill the container if it goes over.
# Used by Swarm for scheduling decisions.
docker run --memory 512m --memory-reservation 256m myapp

# Disable swap for the container (force it to use RAM only)
docker run --memory 512m --memory-swap 512m myapp

# ── CPU LIMITS ─────────────────────────────────────────────

# Limit to 1.5 CPU cores (can use fractional values)
docker run --cpus 1.5 myapp

# Set relative CPU weight (default: 1024)
# A container with 512 gets half the CPU of one with 1024
# when CPU is contended
docker run --cpu-shares 512 myapp

# Pin the container to specific CPU cores (e.g., cores 0 and 1 only)
docker run --cpuset-cpus "0,1" myapp
πŸ“„ Resource Limits in Docker Compose

In production, you'll define resource limits in your docker-compose.yml rather than passing flags. This ensures limits are always applied consistently and documented in source control.

docker-compose.yml β€” Resource Limits
services:

  web:
    image: myapp:prod
    deploy:
      resources:
        limits:
          cpus: '0.5'        # Hard cap: 50% of one CPU core
          memory: 256M      # Hard cap: 256 megabytes RAM
        reservations:
          cpus: '0.1'        # Soft reservation for scheduling
          memory: 128M

  database:
    image: postgres:16
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 1G       # Databases need more memory β€” be generous
        reservations:
          memory: 512M
Terminal β€” Monitoring Resource Usage in Real Time
# Real-time resource usage for ALL running containers (like top, for Docker)
docker stats

# Output:
# CONTAINER    CPU %   MEM USAGE / LIMIT   MEM %   NET I/O
# web          0.5%    45MB / 256MB        17.5%   1.2MB / 800KB
# database     1.2%    312MB / 1GB         30.4%   500KB / 120KB

# Stats for a single container (by name or ID)
docker stats web

# One-shot stats snapshot (no live refresh)
docker stats --no-stream
⚠️
JVM and Resource Limits Java apps running in containers need special attention. Older JVMs read total host RAM to set their heap size, completely ignoring the container's memory limit. A JVM in a 256MB container might try to allocate a 4GB heap, immediately getting OOM-killed. Fix this with -XX:+UseContainerSupport (default in JDK 11+) and explicit heap flags like -Xmx200m. Always test your JVM app with limits applied.
🧠 Check Your Understanding
You have 4 containers on one host with no resource limits. Container A has a memory leak and begins consuming all available RAM. What happens?
Lesson 5 of 9

🌐 Advanced Networking INTERMEDIATE

The beginner course covered basic bridge networks and port mapping. Here we go deeper: network segmentation, reverse proxies, and the overlay networks that span multiple hosts.

πŸ” Network Segmentation β€” Defense in Depth

In production, not every container should be able to talk to every other container. Your web server needs to reach your database β€” but your database has no business talking to your email service. Putting all containers on one network is the equivalent of having no internal firewall.

The correct pattern: use multiple networks. A container can be on multiple networks simultaneously. Put it only on the networks it actually needs to communicate on.

docker-compose.yml β€” Network Segmentation
services:

  # Nginx is the only container exposed to the outside world.
  # It sits on the "frontend" network only.
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - frontend

  # The web app lives on BOTH networks β€”
  # it receives traffic from nginx (frontend)
  # and talks to the database (backend).
  web:
    build: .
    networks:
      - frontend
      - backend

  # The database is ONLY on the backend network.
  # Nginx and external traffic can NEVER reach it directly.
  database:
    image: postgres:16
    networks:
      - backend

networks:
  frontend:
  backend:
    internal: true   # No external internet access from this network at all
πŸ”„ Nginx as a Reverse Proxy

In production, you almost never expose your application container directly to the internet. Instead, you put Nginx (or Traefik, or Caddy) in front of it as a reverse proxy. The reverse proxy handles:

  • SSL/TLS termination β€” decrypt HTTPS here, forward plain HTTP to your app internally
  • Load balancing β€” distribute requests across multiple app containers
  • Static file serving β€” serve CSS, JS, images directly from Nginx (much faster than Node/Python)
  • Rate limiting and caching β€” protect your app from traffic spikes
nginx.conf β€” Reverse Proxy to a Docker Container
upstream app_servers {
  # "web" is resolved by Docker's internal DNS to the web container's IP
  server web:3000;
}

server {
  listen 80;
  server_name example.com;

  # Serve static assets directly β€” never hits the app container
  location /static/ {
    root /var/www;
    expires 30d;
  }

  # Proxy all other requests to the Node.js app
  location / {
    proxy_pass http://app_servers;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}
πŸ•ΈοΈ Overlay Networks β€” Multi-Host Communication

Bridge networks only work on a single Docker host. In a cluster of multiple machines (a Docker Swarm β€” see Lesson 8), containers on different physical hosts need to communicate as if they were on the same network. That's what overlay networks do.

An overlay network creates a virtual network that spans multiple Docker hosts. Docker encrypts traffic between hosts using VXLAN encapsulation. Containers on different machines can reach each other by service name, exactly like containers on the same machine.

Overlay Network β€” Multi-Host Architecture

[ Host A ]                    [ Host B ]
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ container: web_1   β”‚    β”‚ container: web_2   β”‚
β”‚ container: db_1    β”‚    β”‚ container: worker_1β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚                           β”‚
           β””────────────overlay networkβ”€β”€β”€β”€β”˜
                     (encrypted VXLAN tunnel)

web_2 can reach db_1 using hostname "db" β€” Docker resolves it across hosts.
🧠 Check Your Understanding
In the network segmentation example, can the Nginx container directly connect to the database container?
Lesson 6 of 9

πŸ” CI/CD Integration INTERMEDIATE

Manually building and pushing Docker images is fine for learning. In production, every git push should automatically build, test, scan, and deploy a new image β€” without human intervention.

πŸ€– What is CI/CD?

Continuous Integration (CI) is the practice of automatically building and testing code every time a developer pushes a commit. Continuous Deployment (CD) takes it further β€” automatically deploying the code to production (or staging) after tests pass.

Docker is the perfect fit for CI/CD because:

  • The build environment is completely reproducible β€” no "it works on the CI server but not my laptop"
  • Tests run inside containers β€” same environment every time
  • The deployable artifact (the image) is produced and stored automatically
  • Rolling deployments are trivial β€” pull new image, replace old containers
The Docker CI/CD Pipeline

git push
   β†“
CI runner triggers
   β†“
docker build -t myapp:$GIT_SHA .   β† tagged with the exact commit hash
   β†“
docker run myapp:$GIT_SHA npm test  β† run tests inside the image
   β†“ (tests pass)
docker scout cves myapp:$GIT_SHA   β† scan for vulnerabilities
   β†“ (no critical CVEs)
docker push registry/myapp:$GIT_SHA ← push to registry
   β†“
Deploy to staging / production     β† pull new image, restart service
   β†“
Health checks pass β†’ deployment complete βœ“
.github/workflows/docker.yml β€” GitHub Actions Pipeline
name: Build, Test & Deploy

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      # 1. Check out the repository code
      - name: Checkout code
        uses: actions/checkout@v4

      # 2. Log in to Docker Hub using repository secrets
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # 3. Build the Docker image, tagged with the git commit SHA
      - name: Build image
        run: |
          docker build -t myuser/myapp:${{ github.sha }} .
          docker tag myuser/myapp:${{ github.sha }} myuser/myapp:latest

      # 4. Run the test suite inside the freshly built image
      - name: Run tests
        run: docker run --rm myuser/myapp:${{ github.sha }} npm test

      # 5. Scan for vulnerabilities (fail on critical CVEs)
      - name: Scan for vulnerabilities
        uses: docker/scout-action@v1
        with:
          command: cves
          image: myuser/myapp:${{ github.sha }}
          exit-code: true          # fail the build on critical CVEs
          only-severities: critical

      # 6. Only push to registry if on the main branch
      - name: Push to Docker Hub
        if: github.ref == 'refs/heads/main'
        run: |
          docker push myuser/myapp:${{ github.sha }}
          docker push myuser/myapp:latest
🏷️
Tagging Strategy Never rely solely on :latest in production β€” it tells you nothing about what version is deployed. Use the Git commit SHA (:abc1234) for precise traceability, semantic version tags (:v2.3.1) for releases, and :latest only as a convenience alias. If something breaks in production, the SHA tag tells you exactly which commit caused it.
🧠 Check Your Understanding
Why is tagging Docker images with the Git commit SHA better than always using :latest?
Lesson 7 of 9

πŸ“Š Logging & Observability INTERMEDIATE

When something breaks at 2am, logs are your flashlight. In a containerized environment with dozens of services, you need a centralized system β€” not 40 separate terminal windows.

πŸ“‹ The Container Logging Model

Docker's logging model is simple and opinionated: your application should write logs to stdout and stderr. Nothing else. Don't write to a log file inside the container. Docker captures stdout/stderr automatically and routes it through a configurable logging driver.

Why stdout/stderr?

  • Works universally across all languages and frameworks
  • Docker captures it automatically β€” no log library configuration needed
  • Centralized log aggregators (Fluentd, Logstash) can consume it from the Docker daemon
  • When the container is destroyed, you don't lose logs trapped in a file inside it
Terminal β€” Working With Container Logs
# Stream logs from a container (like tail -f)
docker logs -f my-container

# Show last 100 lines only
docker logs --tail 100 my-container

# Show logs with timestamps
docker logs -f --timestamps my-container

# Show only logs since a specific time
docker logs --since 2024-01-15T10:00:00 my-container

# In Compose: stream logs from ALL services combined
docker compose logs -f

# Stream only the web service logs
docker compose logs -f web
πŸ”Œ Docker Logging Drivers

By default, Docker uses the json-file logging driver β€” it writes container logs to JSON files on the host. This is fine for development but not for production at scale (the files grow unbounded and you can't search across containers). Docker supports swapping to different drivers:

DriverSends logs toBest For
json-fileLocal JSON files on hostDevelopment
localLocal binary format (compressed)Single-host production
syslogSystem syslog daemonTraditional server setups
fluentdFluentd log aggregatorProduction (EFK stack)
awslogsAmazon CloudWatch LogsAWS deployments
gcplogsGoogle Cloud LoggingGCP deployments
splunkSplunk Enterprise/CloudEnterprise environments
πŸ“ˆ The Observability Stack: Logs + Metrics + Traces

True observability of a containerized application has three pillars:

  • Logs β€” What happened? (text events from your application)
  • Metrics β€” How is it performing? (numbers over time: CPU, memory, request rate, error rate, latency)
  • Traces β€” Why is it slow? (a request's journey through multiple services, with timings per hop)

The most common open-source production stack for Docker environments is the PLG stack (Prometheus + Loki + Grafana), often combined with Tempo for distributed tracing. All three tools run in Docker containers themselves.

docker-compose.yml β€” Minimal Observability Stack
services:

  # Prometheus: scrapes metrics from your app and stores them
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus-data:/prometheus

  # Loki: log aggregation (like Elasticsearch but lightweight)
  loki:
    image: grafana/loki:latest
    ports:
      - "3100:3100"

  # Grafana: dashboard UI β€” connects to both Prometheus and Loki
  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana

volumes:
  prometheus-data:
  grafana-data:
🧠 Check Your Understanding
Where should a containerized application write its logs?
Lesson 8 of 9

🐝 Docker Swarm INTERMEDIATE

Docker Compose runs containers on one machine. Docker Swarm runs containers across a cluster of machines β€” with built-in load balancing, rolling updates, and self-healing.

πŸ€” Why Do You Need an Orchestrator?

When you run production workloads, you need answers to questions that Docker Compose can't address:

  • What happens when the host machine crashes? (Compose: all containers die. Swarm: reschedules them on another host.)
  • How do you deploy a new version with zero downtime? (Compose: restart, gap. Swarm: rolling update, no gap.)
  • How do you run 10 copies of your web server across 5 machines? (Compose: can't. Swarm: one command.)
  • How do load-balance traffic across those 10 copies? (Compose: manual. Swarm: automatic, built-in.)

Docker Swarm is Docker's built-in orchestrator. It's simpler than Kubernetes (easier to learn, less overhead) but covers most real-world needs for small to medium deployments.

Swarm Cluster Architecture

             β”Œβ”€β”€β”€ Manager Node ───┐
             β”‚ Raft consensus β”‚
             β”‚ Schedules tasks β”‚
             β”‚ Manages state β”‚
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β†“              β†“              β†“
Worker Node A   Worker Node B   Worker Node C
web.1            web.2            web.3
db.1             worker.1         worker.2
Terminal β€” Setting Up and Using Docker Swarm
# Initialize a new Swarm on this machine (becomes a manager node)
docker swarm init

# Output includes a join token for worker nodes, e.g.:
# docker swarm join --token SWMTKN-1-abc123... 192.168.1.10:2377

# On a different machine, join as a worker node:
docker swarm join --token SWMTKN-1-abc123... 192.168.1.10:2377

# See all nodes in the swarm
docker node ls

# Deploy a stack (Swarm uses the same docker-compose.yml format!)
docker stack deploy -c docker-compose.yml myapp

# List all running services across the cluster
docker service ls

# Scale a service to 5 replicas (Swarm distributes them across nodes)
docker service scale myapp_web=5

# View which nodes are running which replicas
docker service ps myapp_web

# Rolling update: deploy a new image version with zero downtime
docker service update \
  --image myapp:v2.0 \
  --update-parallelism 2 \    # update 2 replicas at a time
  --update-delay 10s \         # wait 10s between batches
  myapp_web
πŸ” Docker Secrets in Swarm

Swarm has a built-in secrets management system. Secrets are encrypted at rest in the Raft database and only decrypted and mounted into containers that explicitly need them. They appear as files at /run/secrets/<secret_name> inside the container β€” never as environment variables in the process list.

Terminal β€” Docker Swarm Secrets
# Create a secret from a file
echo "supersecretpassword" | docker secret create db_password -

# Create from a file on disk
docker secret create ssl_cert ./certs/server.crt

# In docker-compose.yml (Swarm mode):
# services:
#   web:
#     secrets:
#       - db_password
# secrets:
#   db_password:
#     external: true   ← already created with docker secret create
#
# Inside the container, the secret is available at:
# /run/secrets/db_password
# Read it in your app: open("/run/secrets/db_password").read()
🎯
Swarm vs Kubernetes Docker Swarm is production-ready for most applications and dramatically simpler than Kubernetes. Choose Swarm when your team is small, your traffic is predictable, and you don't want to invest months in K8s expertise. Choose Kubernetes when you need advanced scheduling, custom resource definitions, the massive ecosystem (Helm, Istio, Argo CD), or you're preparing for a role at a large tech company. Both are excellent β€” they solve the same problem at different scales.
🧠 Check Your Understanding
Worker Node B in a 3-node Swarm suddenly crashes. What does Docker Swarm do?
Lesson 9 of 9

πŸš€ Production Patterns & What's Next INTERMEDIATE

The last lesson isn't a single topic β€” it's a collection of the hard-won patterns that separate containers that run in production from containers that run only on a laptop.

⚑ Pattern 1: Graceful Shutdown

When Docker stops a container, it sends SIGTERM to the main process. The process has 10 seconds (by default) to clean up β€” finish in-flight requests, flush logs, close database connections β€” before Docker sends SIGKILL and force-kills it.

Most frameworks handle this automatically if you start them correctly. The critical requirement: your process must be PID 1 inside the container so it actually receives the signal. If you use a shell script as CMD, the shell becomes PID 1 and absorbs the signal β€” your app never receives it.

Dockerfile β€” Correct vs Incorrect Process Startup
# ❌ WRONG β€” Shell form of CMD. The shell is PID 1, not node.
# SIGTERM goes to the shell and is ignored. Node gets SIGKILL rudely.
CMD node server.js

# βœ… CORRECT β€” Exec form (JSON array). node IS PID 1.
# SIGTERM goes directly to node, which handles it gracefully.
CMD ["node", "server.js"]

# If you need a startup script, use exec to replace the shell:
ENTRYPOINT ["./entrypoint.sh"]
# Inside entrypoint.sh, the last line should be:
# exec "$@"   ← this replaces the shell with your app process

# Give containers more time to shut down gracefully (default: 10s)
# In docker-compose.yml:
# services:
#   web:
#     stop_grace_period: 30s
πŸ”„ Pattern 2: Restart Policies

In production, containers will crash. Hardware fails, OOM kills happen, bugs surface. Your containers should automatically restart. Docker has four restart policies:

  • no β€” Never restart (default for standalone containers)
  • on-failure[:N] β€” Restart only on non-zero exit code, optionally up to N times
  • always β€” Always restart, even if manually stopped (restarts after daemon restart too)
  • unless-stopped β€” Like always, but respects manual stops across daemon restarts. This is the right choice for production.
docker-compose.yml β€” Production Restart Policies
services:
  web:
    image: myapp:prod
    restart: unless-stopped   # Restart on crash, respect manual stops

  database:
    image: postgres:16
    restart: unless-stopped

  migration-job:
    image: myapp:prod
    command: python manage.py migrate
    restart: on-failure      # Retry if migrations fail, but stop when done
πŸ“Œ Pattern 3: Image Pinning

Never use floating tags like :latest or :alpine in production Dockerfiles. These tags are mutable β€” the image they point to can change at any time. Your build is no longer reproducible, and you'll get silent breaking changes.

Pin to an exact digest instead. A digest is a SHA256 hash of the exact image content β€” it is immutable and will never change.

Dockerfile β€” Pinned vs Floating Base Images
# ❌ Floating tag β€” what does "latest" mean in 6 months?
FROM node:latest

# ❌ Better but still mutable β€” node:20-alpine can be updated
FROM node:20-alpine

# βœ… Production-grade β€” pinned to an exact immutable digest
# This exact content will always produce this exact hash
FROM node:20-alpine@sha256:a7ab5e2f9a12c6ba01f1c03f92f7cdb5e39d3e5c3f6c7e3b4f1234567890abcd

# Get the digest for any image:
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine
πŸ“‹ The Complete Production Readiness Checklist
CategoryRequirement
Image Size☐ Multi-stage build used   ☐ Alpine or distroless base   ☐ .dockerignore configured
Security☐ Non-root USER   ☐ No secrets in image   ☐ Image scanned   ☐ Read-only filesystem
Reliability☐ HEALTHCHECK defined   ☐ Restart policy set   ☐ Resource limits configured
Observability☐ Logs to stdout/stderr   ☐ /health endpoint exists   ☐ Metrics exposed
Networking☐ Network segmentation   ☐ Only necessary ports exposed   ☐ Reverse proxy in front
Deployment☐ Images tagged with git SHA   ☐ CI/CD pipeline automated   ☐ Rollback plan exists
Process☐ CMD uses exec form   ☐ Graceful shutdown handled   ☐ stop_grace_period set
πŸ—ΊοΈ The Road to Kubernetes

You've now covered intermediate Docker. The next mountain is Kubernetes β€” the industry-standard orchestrator for large-scale container deployments. Here's what to study next, in order:

  1. Core K8s objects: Pods, Deployments, Services, ConfigMaps, Secrets
  2. kubectl: The Kubernetes CLI β€” most concepts from docker CLI translate directly
  3. Helm: The package manager for Kubernetes (like docker-compose.yml, but for K8s)
  4. Ingress controllers: How external traffic enters a K8s cluster (Nginx Ingress, Traefik)
  5. Persistent storage in K8s: PersistentVolumes, StorageClasses β€” much more complex than Docker volumes
  6. K8s networking: Services, DNS, Network Policies, CNI plugins
  7. GitOps: Argo CD or Flux β€” declarative deployments driven by Git commits

Start with minikube or k3s to run Kubernetes locally. The concepts will feel very familiar after this course.

πŸ† Final Mastery Quiz
You deploy a new container version and it starts failing health checks. Which production pattern ensures you can quickly get back to the previous working version?
πŸ† Final Mastery Quiz
Which of these represents a fully production-ready container configuration?
🎯 Capstone Project

Harden an Existing Application

Take the Compose stack you built in the beginner course and apply everything from this course:

  1. Rewrite the Dockerfile as a multi-stage build. Measure the size before and after.
  2. Add a non-root USER to the Dockerfile. Add a HEALTHCHECK that hits a /health endpoint in your app.
  3. Add a /health route to your app that checks the database connection and returns 200 or 503.
  4. Add resource limits to all services in docker-compose.yml.
  5. Add a second Docker network and put the database on the backend-only network.
  6. Move any secrets (passwords, keys) from the Compose file into a .env file and add it to .gitignore and .dockerignore.
  7. Set restart: unless-stopped on all long-running services.
  8. Run docker scout quickview on your final image. Fix anything critical.

When you're done, your stack should pass every row of the Production Readiness Checklist above.

Roadmap