๐ณ Lesson 6: Advanced Docker & Production
You can build images and run multi-container apps. Now let's ship them safely โ distributing images through registries, hardening containers, watching them in production, and knowing when a single host stops being enough.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Tag, push, and pull images across Docker Hub and alternative registries (GHCR, cloud registries)
- Scan images for vulnerabilities with
docker scoutand apply supply-chain best practices, including BuildKit secrets - Harden a running container with resource limits, capability drops, read-only roots, and restart policies
- Explain when you outgrow a single host and honestly compare Docker Swarm with Kubernetes
Estimated Time: 55โ70 minutes
Prerequisite: Lessons 1โ5 โ images, containers, volumes, networks, and Docker Compose.
In This Lesson
Registries: Distributing Your Images
An image on your laptop helps nobody else. A registry is the shared library that stores and distributes images โ think of it as GitHub for container images. You push an image up once, and every server, teammate, and CI runner can pull the exact same bytes back down.
Docker Hub is the default public registry, but it's far from the only one:
- Docker Hub โ the default; huge selection of official images. Public repos are free; private repos and higher pull limits need a paid plan.
- GHCR (GitHub Container Registry,
ghcr.io) โ ties images to your GitHub org and permissions; very popular for open source and CI. - Cloud registries โ AWS ECR, Google Artifact Registry, Azure ACR. These sit next to where you deploy, so pulls are fast and access is governed by your cloud's IAM.
- Self-hosted โ run the open-source
registry:2image for a private registry you fully control.
Tag, push, pull
A registry name is baked into the image tag itself: registry/namespace/repo:tag. For Docker Hub the registry host is implied, so user/myapp:1.0 is enough. Let's publish an image:
# 1. Authenticate (opens a browser or prompts for a token)
docker login
# 2. Tag your local image with your registry namespace + a real version
docker tag myapp:1.0 raydev/myapp:1.0
# 3. Push it up
docker push raydev/myapp:1.0
Output:
The push refers to repository [docker.io/raydev/myapp]
5f70bf18a086: Pushed
a3b8c2d91e04: Pushed
c1e9f4a77b21: Layer already exists
1.0: digest: sha256:9b2c...e41a size: 1786
Notice "Layer already exists" โ registries deduplicate by layer, so shared base layers upload only once. On any other machine, pulling is the mirror image:
# For GHCR you tag with the ghcr.io host explicitly
docker tag myapp:1.0 ghcr.io/raydev/myapp:1.0
docker push ghcr.io/raydev/myapp:1.0
# On a server, pull it back
docker pull raydev/myapp:1.0
โ ๏ธ Pull rate limits: Anonymous and free Docker Hub accounts are subject to pull rate limits (a capped number of pulls per few hours). In CI this bites fast โ dozens of unauthenticated pulls can hit the ceiling. Fix it by docker login-ing in your pipeline, or by mirroring images to a registry closer to your infrastructure (ECR, GHCR, a pull-through cache).
๐ Tagging strategy
Never deploy :latest to production โ it's a moving target that makes rollbacks ambiguous. Push an immutable, meaningful tag: a semantic version (myapp:1.4.2) and/or the Git commit SHA (myapp:sha-9b2c1e4). You can still also tag :latest for convenience, but pin the real version in your deploys.
Image Security & Supply Chain
An image is your whole software supply chain in one artifact โ base OS, system packages, language runtime, third-party libraries, and your code. A vulnerability in any layer ships to production. Two habits keep you safe: scan what you ship, and build minimal, secret-free images.
Scanning with Docker Scout
Heads up on tooling: the old docker scan command (powered by Snyk) has been removed. The current, built-in tool is Docker Scout. Start with a one-line health summary:
# Quick overview of an image's vulnerability posture
docker scout quickview myapp:1.0
Output:
Target โ myapp:1.0 โ 0C 2H 5M 11L
digest โ 9b2c1e4ae41a โ
Base image โ node:20-slim โ 0C 1H 3M 8L
What's next:
View vulnerabilities โ docker scout cves myapp:1.0
View base image update recommendations โ docker scout recommendations myapp:1.0
The columns are severity buckets โ Critical, High, Medium, Low. Drill into the actual CVEs and get fix guidance:
# List the specific CVEs, filtered to the ones that matter
docker scout cves --only-severity critical,high myapp:1.0
# Ask Scout how to fix them (often just a newer base image)
docker scout recommendations myapp:1.0
Build a smaller, safer image
Most of your risk lives in things you didn't need to include. The best-practice checklist:
- Pin a minimal base โ prefer
-slim,alpine, ordistroless, and pin by digest (node:20-slim@sha256:โฆ) so a moving tag can't silently change under you. - Run as a non-root user โ add a
USERdirective so a container breakout doesn't hand over root. - Least privilege โ install only what you run; every extra package is more attack surface and more CVEs.
- Never bake in secrets โ API keys and passwords in image layers are permanent and readable by anyone who pulls the image, even if a later layer "deletes" them.
# Minimal, pinned base + a non-root user
FROM node:20-slim
# Create and switch to an unprivileged user
RUN useradd --create-home appuser
WORKDIR /home/appuser/app
COPY --chown=appuser:appuser . .
RUN npm ci --omit=dev
USER appuser
CMD ["node", "server.js"]
Secrets the right way: BuildKit
A classic mistake is passing a secret as a build ARG โ it gets stored in the image history forever. BuildKit (the default builder in modern Docker) solves this with build secrets that are mounted only for a single RUN step and never persisted into any layer:
# syntax=docker/dockerfile:1
FROM alpine:3.20
# The secret is mounted at /run/secrets/npmrc for THIS run only
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
# Provide the secret file at build time โ it never lands in a layer
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:1.0 .
โ ๏ธ Build secrets vs runtime secrets: --mount=type=secret is for things you need while building (private registry tokens, package credentials). Secrets your app needs at runtime belong in the environment or an orchestrator's secret store โ never in the image.
Runtime Hardening & Resources
By default a container can consume all the host's CPU and memory and runs with a broad set of Linux capabilities. In production you clamp both down โ capping resources protects neighbors from a runaway container, and dropping privileges shrinks the blast radius if the app is compromised.
Resource limits
Without limits, one leaking container can starve every other workload on the host (the "noisy neighbor" problem). Set explicit ceilings:
# Cap memory at 512MB and CPU at 1.5 cores
docker run --memory=512m --cpus=1.5 myapp:1.0
# Limit the number of processes to blunt fork bombs
docker run --pids-limit=200 myapp:1.0
Output โ docker stats confirms the cap:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % PIDS
a1b2c3d4e5f6 myapp 38.5% 187MiB / 512MiB 36.5% 14
Drop privileges
Layer these flags to run the tightest container that still works:
| Flag | What it does |
|---|---|
--read-only | Mounts the container filesystem read-only; pair with --tmpfs /tmp for scratch space |
--cap-drop ALL | Drops every Linux capability, then add back only what's needed with --cap-add |
--security-opt no-new-privileges | Prevents processes from gaining new privileges (e.g. via setuid binaries) |
--user 1000:1000 | Runs as a specific non-root UID/GID even if the image didn't set USER |
--pids-limit | Caps process count to contain fork bombs |
# A hardened run: read-only root, no extra caps, no privilege escalation
docker run -d \
--read-only --tmpfs /tmp \
--cap-drop ALL --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges \
--memory=512m --cpus=1.0 --pids-limit=200 \
--restart=unless-stopped \
myapp:1.0
Restart policies
Production containers should recover on their own. A restart policy tells Docker what to do when a container exits:
--restart=noโ the default; never restart automatically.--restart=on-failure[:N]โ restart only on a non-zero exit, optionally capped at N tries.--restart=unless-stoppedโ always restart, except when you explicitly stopped it. The usual pick for long-running services.--restart=alwaysโ restart no matter what, including after a daemon reboot.
โ Healthchecks pair with restarts
A restart policy reacts to a container exiting โ but a hung app may keep running while serving errors. A HEALTHCHECK lets Docker probe liveness and mark the container unhealthy, which orchestrators use to replace it:
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
Data, Logging & Networking in Prod
Persistent data
Containers are ephemeral โ delete one and its writable layer is gone. Anything that must survive (databases, uploads) needs to live outside the container. You have two options, and in production the choice is usually clear:
| Named volume | Bind mount | |
|---|---|---|
| Managed by | Docker (/var/lib/docker/volumes) | You (an exact host path) |
| Best for | Production data โ databases, app state | Local dev โ live-editing source into a container |
| Portability | High โ decoupled from host layout | Low โ tied to a specific host path |
# Create and use a named volume for a database
docker volume create pgdata
docker run -d --name db -v pgdata:/var/lib/postgresql/data postgres:16
# Back it up by tar-ing the volume through a throwaway container
docker run --rm -v pgdata:/data -v "$PWD":/backup alpine \
tar czf /backup/pgdata-backup.tar.gz -C /data .
โ ๏ธ Back up your volumes: a named volume survives container deletion, but not disk failure. Treat volume backups (and a tested restore) as part of your deployment, not an afterthought.
Logging & monitoring
You can't fix what you can't see. Docker captures each container's stdout/stderr, viewable with docker logs, and routes it through a configurable logging driver.
# Follow logs live, showing the last 100 lines first
docker logs -f --tail 100 myapp
# Live resource usage across all running containers
docker stats
The default json-file driver writes to local disk and will fill it up unless you cap it. Set rotation, and in real deployments forward logs to a central system so they outlive the container:
# Cap json-file log size and rotation
docker run --log-opt max-size=10m --log-opt max-file=3 myapp:1.0
For deeper insight, observability tooling (Prometheus + Grafana, cAdvisor, or a hosted APM) scrapes container metrics and app traces. docker stats and healthchecks are the built-in starting points; those tools are where you graduate as scale grows.
Networking recap for multiple hosts
You met bridge networks in Lesson 5. Production adds the multi-host case. The three drivers you'll reach for:
| Driver | Scope | Use it when |
|---|---|---|
| bridge | Single host | The default โ containers on one host talk to each other; a user-defined bridge adds DNS by container name |
| host | Single host | You need the container to share the host's network stack directly (max performance, no isolation) |
| overlay | Multiple hosts | Containers across a Swarm/K8s cluster must communicate as if on one network |
The key jump: a bridge network stops at the edge of one machine. To let containers on different hosts talk, you need an overlay network โ which is exactly what orchestrators create for you.
Orchestration: Beyond One Host
Everything so far assumed a single Docker host. That's fine until it isn't: one machine has a hardware failure, or your traffic outgrows its CPU and RAM. You need containers spread across many hosts, automatically restarted when they die, and load-balanced behind one address. That job is orchestration.
Swarm vs Kubernetes โ an honest comparison
Two paths dominate, and they trade simplicity against power:
| Docker Swarm | Kubernetes | |
|---|---|---|
| Setup | Built into Docker; one command to start | Separate system; real setup effort (or a managed service) |
| Learning curve | Gentle โ you already know most of it | Steep โ many new concepts (pods, deployments, services, ingress) |
| Power | Covers the common cases well | Deep features: autoscaling, rollouts, huge ecosystem |
| Adoption | Simple/built-in; smaller community | The industry standard for scale; universal cloud support |
Honestly framed: Swarm is the simple, built-in option โ great when you want basic multi-host orchestration without a new platform to learn. Kubernetes is the powerful, complex, industry-standard option โ more to learn and operate, but it's what most teams standardize on once scale and features matter. Neither is "wrong"; pick the smallest tool that solves your actual problem.
Swarm's whole model is only a few commands:
# Turn the current host into a Swarm manager
docker swarm init
# Deploy a replicated service โ Swarm schedules 3 copies across the cluster
docker service create --name web --replicas 3 -p 80:8080 raydev/myapp:1.0
# Scale up on demand
docker service scale web=5
# See where the replicas landed
docker service ps web
CI/CD: automate the build and push
In real teams, nobody pushes images from a laptop. A CI/CD pipeline does it on every commit: run tests, build the image, tag it with the Git SHA, push to the registry, and trigger a deploy. The flow:
Every major CI system (GitHub Actions, GitLab CI, and others) has first-class Docker support, typically logging in with a short-lived token so no long-lived password sits in the pipeline.
Try It Yourself
Time to publish an image and scan it โ the two production skills you'll use most. No paid account needed: we'll run a local registry so you can practice push/pull end to end.
๐๏ธ Exercise 1: Tag & push to a local registry, then scan an image
Objective: Run your own registry, push an image to it and pull it back, then get a vulnerability summary with Docker Scout.
Instructions:
- Start a local registry:
docker run -d -p 5000:5000 --name registry registry:2 - Grab a small image to work with:
docker pull nginx:alpine - Tag it for your local registry (host
localhost:5000). - Push it, then delete the local copy and pull it back to prove the round-trip.
- Run
docker scout quickviewonnginx:alpineand read the severity counts.
๐ก Hint
The registry host becomes part of the tag: docker tag nginx:alpine localhost:5000/nginx:alpine. After pushing, remove the local image with docker rmi before pulling it back, or you won't be proving anything. If docker scout isn't found, update to Docker Desktop / recent Docker Engine โ it replaced the removed docker scan.
โ Worked solution
# 1. Run a private registry locally
docker run -d -p 5000:5000 --name registry registry:2
# 2. Pull a small public image
docker pull nginx:alpine
# 3. Tag it for the local registry
docker tag nginx:alpine localhost:5000/nginx:alpine
# 4. Push, remove local, pull back
docker push localhost:5000/nginx:alpine
docker rmi localhost:5000/nginx:alpine nginx:alpine
docker pull localhost:5000/nginx:alpine
# 5. Scan an image for vulnerabilities
docker scout quickview nginx:alpine
You should see the push report layers as Pushed, the pull re-download them from localhost:5000, and Scout print a table of Critical/High/Medium/Low counts with a "What's next" hint pointing at docker scout cves. You've just done the whole distribute-and-verify loop that CI does on every commit. Clean up with docker rm -f registry.
Quick Quiz
Question 1: Which command is the current, supported way to check an image for known vulnerabilities?
Question 2: Why should you avoid passing a secret via a build ARG and use a BuildKit secret mount instead?
Question 3: Which statement best reflects an honest Swarm-vs-Kubernetes comparison?
Summary
๐ Key Takeaways
- Registries distribute images โ tag, push, and pull across Docker Hub, GHCR, or cloud registries; pin real version tags and mind Docker Hub's pull rate limits.
- Scan and slim your images โ use
docker scout(not the removeddocker scan), pin minimal bases, run as non-root, and keep secrets out of layers with BuildKit secret mounts. - Harden at runtime โ cap CPU/memory/PIDs, drop capabilities, use read-only roots, and set a restart policy plus a HEALTHCHECK.
- Operate for real โ persist data in named volumes (and back them up), rotate/centralize logs, and use overlay networks to span multiple hosts.
- Orchestrate when one host isn't enough โ Swarm is the simple built-in choice; Kubernetes is the powerful, complex industry standard.
๐ Additional Resources
- Docker Hub repositories โ public vs private, and pull rate limits
- Docker Scout โ image analysis and vulnerability scanning
- Build secrets โ using
--mount=type=secretwith BuildKit - Runtime resource constraints โ memory, CPU, and PID limits
- Swarm mode โ the built-in orchestrator
๐ What's Next?
You can now ship containers to production responsibly. The natural next step is going deeper on orchestration at scale โ Kubernetes โ where the concepts from this lesson (replicas, health, overlay networking, rollouts) become first-class primitives. But first, cement everything hands-on in the Practice Lab, where you'll build, secure, and deploy an image end to end.
๐ Lesson 6 complete!
You've gone from "it runs on my machine" to "it runs safely, everywhere, at scale." That's production Docker. ๐ณ