ποΈ 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.
FROM, RUN, COPY, CMD) and build images with docker build. If not, complete the beginner course first.
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.
| Approach | Example Image Size | Attack Surface |
|---|---|---|
| Single-stage (naive) | ~900 MB | Large β build tools included |
| Multi-stage | ~15 MB | Minimal β binary only |
| Multi-stage + scratch | ~8 MB | Near zero β no OS at all |
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.
# βββββββββββββββββββββββββββββββββββββββββββ # 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"]
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).
# 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"]
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.
# 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 π
π 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.
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.
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.
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"]
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.
# β 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.
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.
# 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
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.
# --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
β 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
ENV DB_PASSWORD=hunter2 to a Dockerfile, builds, and pushes the image. What is the threat?--read-only on a docker run command do?β€οΈ 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.
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
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
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)
# ββ 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
# 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-..." } # ] # }
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.
βοΈ 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.
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.
# ββ 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
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.
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
# 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
-XX:+UseContainerSupport (default in JDK 11+) and explicit heap flags like -Xmx200m. Always test your JVM app with limits applied.
π 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.
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.
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
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
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; } }
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.
[ 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.
π 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.
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
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 β
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
: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.
:latest?π 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.
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
# 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
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:
| Driver | Sends logs to | Best For |
|---|---|---|
json-file | Local JSON files on host | Development |
local | Local binary format (compressed) | Single-host production |
syslog | System syslog daemon | Traditional server setups |
fluentd | Fluentd log aggregator | Production (EFK stack) |
awslogs | Amazon CloudWatch Logs | AWS deployments |
gcplogs | Google Cloud Logging | GCP deployments |
splunk | Splunk Enterprise/Cloud | Enterprise environments |
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.
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:
π 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.
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.
ββββ 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
# 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
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.
# 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()
π 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.
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.
# β 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
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 timesalwaysβ Always restart, even if manually stopped (restarts after daemon restart too)unless-stoppedβ Likealways, but respects manual stops across daemon restarts. This is the right choice for production.
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
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.
# β 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
| Category | Requirement |
|---|---|
| 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 |
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:
- Core K8s objects: Pods, Deployments, Services, ConfigMaps, Secrets
- kubectl: The Kubernetes CLI β most concepts from docker CLI translate directly
- Helm: The package manager for Kubernetes (like docker-compose.yml, but for K8s)
- Ingress controllers: How external traffic enters a K8s cluster (Nginx Ingress, Traefik)
- Persistent storage in K8s: PersistentVolumes, StorageClasses β much more complex than Docker volumes
- K8s networking: Services, DNS, Network Policies, CNI plugins
- 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.
Harden an Existing Application
Take the Compose stack you built in the beginner course and apply everything from this course:
- Rewrite the Dockerfile as a multi-stage build. Measure the size before and after.
- Add a non-root USER to the Dockerfile. Add a HEALTHCHECK that hits a
/healthendpoint in your app. - Add a /health route to your app that checks the database connection and returns 200 or 503.
- Add resource limits to all services in docker-compose.yml.
- Add a second Docker network and put the database on the backend-only network.
- Move any secrets (passwords, keys) from the Compose file into a .env file and add it to .gitignore and .dockerignore.
- Set restart: unless-stopped on all long-running services.
- Run
docker scout quickviewon your final image. Fix anything critical.
When you're done, your stack should pass every row of the Production Readiness Checklist above.