Skip to main content

๐Ÿณ 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 scout and 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:2 image for a private registry you fully control.
Build and tag an image locally, push it to a registry, then pull it onto any server ๐Ÿ’ป Your Machine docker build docker tag docker login โ˜๏ธ Registry Docker Hub GHCR ECR / ACR / GAR ๐Ÿš€ Servers docker pull docker run prod / staging / CI push pull Build once, tag it, push it โ€” pull the identical image everywhere else.
Figure 1: The push/pull flow โ€” your machine publishes to a registry; any number of servers pull the same image back down.

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, or distroless, 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 USER directive 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:

FlagWhat it does
--read-onlyMounts the container filesystem read-only; pair with --tmpfs /tmp for scratch space
--cap-drop ALLDrops every Linux capability, then add back only what's needed with --cap-add
--security-opt no-new-privilegesPrevents processes from gaining new privileges (e.g. via setuid binaries)
--user 1000:1000Runs as a specific non-root UID/GID even if the image didn't set USER
--pids-limitCaps 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 volumeBind mount
Managed byDocker (/var/lib/docker/volumes)You (an exact host path)
Best forProduction data โ€” databases, app stateLocal dev โ€” live-editing source into a container
PortabilityHigh โ€” decoupled from host layoutLow โ€” 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
graph LR A["Container stdout / stderr"] --> B{"Logging driver"} B --> C["json-file (default)"] B --> D["journald (systemd)"] B --> E["fluentd / syslog"] C --> F["Local disk (rotate it!)"] E --> G["Central stack (ELK, Loki, cloud)"] style B fill:#bfdbfe,color:#1e293b style G fill:#bbf7d0,color:#1e293b

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:

DriverScopeUse it when
bridgeSingle hostThe default โ€” containers on one host talk to each other; a user-defined bridge adds DNS by container name
hostSingle hostYou need the container to share the host's network stack directly (max performance, no isolation)
overlayMultiple hostsContainers 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.

A manager node schedules replicated service tasks across multiple worker nodes Orchestration Cluster ๐Ÿงญ Manager Node schedules tasks & keeps desired state โš™๏ธ Worker 1 web ยท r1 api ยท r1 web ยท r2 โš™๏ธ Worker 2 web ยท r3 api ยท r2 overlay network โš™๏ธ Worker 3 api ยท r3 db ยท r1 web ยท r4
Figure 2: The manager holds the desired state ("run 4 web replicas, 3 api, 1 db") and schedules those tasks across workers, rescheduling any that fail.

Swarm vs Kubernetes โ€” an honest comparison

Two paths dominate, and they trade simplicity against power:

Docker SwarmKubernetes
SetupBuilt into Docker; one command to startSeparate system; real setup effort (or a managed service)
Learning curveGentle โ€” you already know most of itSteep โ€” many new concepts (pods, deployments, services, ingress)
PowerCovers the common cases wellDeep features: autoscaling, rollouts, huge ecosystem
AdoptionSimple/built-in; smaller communityThe 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:

graph LR A["git push"] --> B["CI runs tests"] B --> C{"Pass?"} C -->|No| D["Notify developer"] C -->|Yes| E["docker build"] E --> F["Tag with Git SHA"] F --> G["docker push to registry"] G --> H["Deploy / rollout"] style C fill:#fde68a,color:#1e293b style H fill:#bbf7d0,color:#1e293b style D fill:#fecaca,color:#1e293b

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:

  1. Start a local registry: docker run -d -p 5000:5000 --name registry registry:2
  2. Grab a small image to work with: docker pull nginx:alpine
  3. Tag it for your local registry (host localhost:5000).
  4. Push it, then delete the local copy and pull it back to prove the round-trip.
  5. Run docker scout quickview on nginx:alpine and 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 removed docker 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

๐Ÿš€ 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. ๐Ÿณ