A free, guided curriculum
Learn Docker by
containerizing a real app.
Seven phases, start to finish: install Docker, run and manage containers, write your own Dockerfiles, wire up networking and persistent storage, define multi-container apps with Compose, harden images for production, and get hands-on with orchestration. No prior container experience required.
Build Stages
Phase 1 of 7
Core Concepts
Containers vs. VMs, installation, and your first container.
Goal of this phase: Understand what Docker actually is and why it exists, install it, and run your first container.
1. What is Docker? (Concept)
Docker packages an application together with everything it needs to run — code, runtime, system libraries, configuration — into a single unit called a container. That container runs the same way on your laptop, a teammate's machine, or a production server, because it carries its own environment with it instead of depending on whatever happens to be installed on the host.
The classic problem Docker solves: "it works on my machine." If the container works on your machine, it works everywhere, because the container is the machine (as far as the application is concerned).
Containers vs. Virtual Machines
VIRTUAL MACHINES CONTAINERS
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ App A │ │ App B │ │ App A │ │ App B │
├─────────┤ ├─────────┤ ├─────────┤ ├─────────┤
│ Guest OS│ │ Guest OS│ │ Bins/Libs│ │Bins/Libs│
├─────────┴─┴─────────┤ ├─────────┴─┴─────────┤
│ Hypervisor │ │ Docker Engine │
├───────────────────────┤ ├───────────────────────┤
│ Host OS │ │ Host OS │
├───────────────────────┤ ├───────────────────────┤
│ Hardware │ │ Hardware │
└───────────────────────┘ └───────────────────────┘
| Virtual Machine | Container | |
|---|---|---|
| What it virtualizes | Entire hardware + its own OS kernel | Just the application + its dependencies; shares the host's OS kernel |
| Startup time | Minutes | Seconds (often sub-second) |
| Size | Gigabytes (full OS included) | Megabytes typically |
| Isolation | Very strong (separate kernel) | Strong, but shares host kernel (process-level isolation) |
| Density | Few per host | Many per host |
Why this matters for you: you already run EC2 instances (full VMs) for things like Jenkins. Containers are a lighter-weight way to package and run individual applications — often inside those same VMs.
2. Docker Architecture (Concept)
| Term | Plain explanation |
|---|---|
| Docker Engine | The core software that builds and runs containers. Has a client and a daemon. |
Docker Daemon (dockerd) |
Background service that does the actual work — building images, running containers, managing networks/volumes. |
Docker Client (docker CLI) |
The command-line tool you type commands into. Talks to the daemon. |
| Image | A read-only template — the blueprint for a container (e.g., "Python 3.12 with my app code baked in"). |
| Container | A running (or stopped) instance of an image — the live, working version. |
| Registry | A place to store and share images (Docker Hub is the default public one, like GitHub but for images). |
| Dockerfile | A text file with instructions for building an image (covered in depth in Phase 3). |
Dockerfile --build--> Image --run--> Container
│
push/pull
│
Registry (Docker Hub)
3. Installation
Option A: Docker Desktop (Windows/Mac — GUI + CLI)
- Download from docker.com/products/docker-desktop
- Install and launch it
- Verify from a terminal:
bash docker --version docker info
Option B: Docker Engine on Linux / EC2 (matches your existing AWS setup)
# Amazon Linux 2023
sudo yum install -y docker
sudo systemctl start docker
sudo systemctl enable docker
# Let your user run docker without sudo every time
sudo usermod -aG docker $USER
# Log out and back in (or run `newgrp docker`) for the group change to apply
# Verify
docker --version
docker info
This is the same pattern you used to give Jenkins access to Docker back in your Jenkins Phase 5 guide — good to recognize it's the identical setup.
4. Hands-On: Run Your First Container
docker run hello-world
Read the output carefully — Docker actually tells you, step by step, what just happened:
1. Docker client contacted the daemon
2. Daemon pulled the hello-world image from Docker Hub (since you didn't have it locally)
3. Daemon created a new container from that image
4. Daemon ran it, which printed the message you see
5. Daemon streamed that output back to your terminal
This is the full lifecycle in miniature: pull → create → run → output.
Try a real, interactive container
docker run -it ubuntu bash
-i= interactive (keep STDIN open)-t= allocate a pseudo-terminal (makes it feel like a normal shell)ubuntu= the imagebash= the command to run inside the container
You're now inside a running Ubuntu container. Try:
cat /etc/os-release
ls /
exit
exit stops the container and returns you to your host shell.
Explore basic commands
docker ps # list running containers
docker ps -a # list ALL containers (including stopped ones)
docker images # list images you've pulled/built locally
docker version # client + daemon version info
5. Checkpoint — Can You Answer These?
- In your own words, what's the core difference between a container and a VM?
- What's the difference between an image and a container?
- What are the Docker client and Docker daemon, and how do they relate?
- When you ran
docker run hello-world, what were the actual steps that happened behind the scenes? - What does
-itdo when running a container interactively?
If you can answer all five, you're ready for Phase 2: Working with Containers — lifecycle management, port mapping, volumes, and inspecting running containers.
Quick Reference — This Phase
docker --version
docker info
docker run hello-world
docker run -it ubuntu bash
docker ps
docker ps -a
docker images
Next: Phase 2 — Working with Containers (lifecycle, ports, volumes, inspecting). Ask when ready.
Phase 2 of 7
Working with Containers
Lifecycle, port mapping, volumes, inspecting running containers.
Goal of this phase: Master the day-to-day mechanics of running containers — lifecycle, networking basics (port mapping), persisting data (volumes), and inspecting what's happening inside a running container.
Builds directly on Phase 1.
1. Container Lifecycle (Concept)
A container moves through a small set of states:
docker create docker start docker stop
│ │ │
▼ ▼ ▼
[Created] ──────────► [Running] ──────────► [Stopped]
│ │
└──────────────── docker rm ◄──────────────┘
| Command | What it does |
|---|---|
docker create |
Creates a container from an image, but doesn't start it |
docker start |
Starts a created/stopped container |
docker run |
Shortcut for create + start in one step (what you used in Phase 1) |
docker stop |
Gracefully stops a running container (sends SIGTERM, then SIGKILL after a timeout) |
docker restart |
Stops then starts a container |
docker rm |
Removes a stopped container permanently |
docker kill |
Immediately force-stops a container (SIGKILL, no grace period) |
Hands-On: Full Lifecycle Walkthrough
# Run an nginx web server container in the background (detached)
docker run -d --name my-nginx nginx
# Confirm it's running
docker ps
# Stop it
docker stop my-nginx
# Confirm it's stopped but still exists
docker ps -a
# Start it again (same container, same name)
docker start my-nginx
# Restart it
docker restart my-nginx
# Stop and remove it entirely
docker stop my-nginx
docker rm my-nginx
# Confirm it's gone
docker ps -a
-d = detached mode (runs in the background, gives you your terminal back immediately). --name gives the container a friendly name instead of a random one like wonderful_turing.
2. Executing Commands Inside a Running Container
docker run -d --name my-nginx nginx
# Run a one-off command inside it
docker exec my-nginx ls /usr/share/nginx/html
# Get an interactive shell inside the running container
docker exec -it my-nginx bash
# (inside the container now)
cat /etc/nginx/nginx.conf
exit
docker exec is how you "step into" a container that's already running — extremely useful for debugging.
3. Port Mapping (Concept + Hands-On)
By default, a container's network is isolated from your host machine. To reach a service running inside a container (like nginx's web server on port 80), you have to explicitly map a host port to a container port.
docker run -d --name my-nginx -p 8080:80 nginx
-p 8080:80 means: host port 8080 → container port 80.
Now open http://localhost:8080 in a browser (or curl http://localhost:8080 from the terminal) — you'll see the default nginx welcome page, served from inside the container.
# Map multiple ports if needed
docker run -d -p 8080:80 -p 8443:443 nginx
# Let Docker pick a random available host port
docker run -d -P nginx
docker port <container_name> # see what it picked
4. Environment Variables
Many images are configured via environment variables instead of config files.
docker run -d --name my-db -e MYSQL_ROOT_PASSWORD=secretpass -e MYSQL_DATABASE=testdb mysql:8
-e KEY=VALUE injects an environment variable into the container. Check the image's Docker Hub page for which variables it expects — this is the standard way images document their configuration options.
5. Volumes: Persisting Data (Concept)
Containers are ephemeral by default — when you docker rm a container, anything written inside it is gone. Volumes solve this by storing data outside the container's writable layer, so it survives container removal.
| Type | What it is |
|---|---|
| Named volume | Docker manages the storage location for you (docker volume create) — the standard, recommended approach |
| Bind mount | You point directly at a folder on your host machine — useful for local development (edit code on host, see changes instantly in container) |
| Anonymous volume | Like a named volume but without a name — Docker auto-generates an ID; harder to reuse deliberately |
Hands-On: Named Volume
# Create a named volume
docker volume create my-data
# Run a container using it
docker run -d --name my-db -v my-data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=secretpass mysql:8
# Inspect it
docker volume inspect my-data
docker volume ls
# Even if you remove the container, the volume (and its data) survives
docker stop my-db
docker rm my-db
docker volume ls # my-data is still there
# Reattach it to a new container
docker run -d --name my-db-2 -v my-data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=secretpass mysql:8
Hands-On: Bind Mount (great for local dev)
mkdir -p ~/docker-practice/site
echo "<h1>Hello from a bind-mounted file</h1>" > ~/docker-practice/site/index.html
docker run -d --name dev-nginx -p 8081:80 -v ~/docker-practice/site:/usr/share/nginx/html nginx
Open http://localhost:8081 — you'll see your file. Now edit ~/docker-practice/site/index.html directly on your host and refresh the browser — the change appears immediately, no rebuild needed. This is exactly why bind mounts are popular for active development.
6. Inspecting Containers
# Full JSON metadata: network settings, mounts, env vars, everything
docker inspect my-nginx
# Live logs (add -f to follow/tail in real time)
docker logs my-nginx
docker logs -f my-nginx
# Real-time resource usage (CPU, memory, network I/O) — like `top` for containers
docker stats
# Just this one container
docker stats my-nginx
# See running processes inside a container
docker top my-nginx
7. Cleaning Up
Containers, images, and volumes pile up quickly during learning. Useful cleanup commands:
docker container prune # remove all stopped containers
docker image prune # remove unused (dangling) images
docker volume prune # remove unused volumes (careful — this deletes data!)
docker system prune # remove all unused containers, networks, and dangling images
docker system prune -a # more aggressive — also removes unused images not just dangling ones
8. Checkpoint — Can You Answer These?
- What's the difference between
docker stopanddocker kill? - What does
-p 8080:80actually mean — which side is the host and which is the container? - Why do containers lose their data by default when removed, and what solves that?
- What's the difference between a named volume and a bind mount, and when would you use each?
- How would you get an interactive shell inside a container that's already running?
- What does
docker system prune -ado, and why should you be careful withdocker volume prune?
If you can answer all six, you're ready for Phase 3: Images & Dockerfiles — where you'll stop just running other people's images and start building your own.
Quick Reference — This Phase
# Lifecycle
docker run -d --name <name> <image>
docker stop <name>
docker start <name>
docker restart <name>
docker rm <name>
# Exec / debug
docker exec -it <name> bash
docker logs -f <name>
docker stats
docker top <name>
# Ports & env
docker run -d -p <host_port>:<container_port> -e KEY=VALUE <image>
# Volumes
docker volume create <name>
docker run -d -v <volume_name>:<container_path> <image>
docker run -d -v <host_path>:<container_path> <image> # bind mount
docker volume ls
docker volume inspect <name>
# Cleanup
docker container prune
docker image prune
docker system prune -a
Next: Phase 3 — Images & Dockerfiles (building your own images). Ask when ready.
Phase 3 of 7
Images & Dockerfiles
Build your own images, layers, multi-stage builds, publishing.
Goal of this phase: Stop just running other people's images and start building your own — write Dockerfiles, understand layers and caching, use multi-stage builds, and publish images to Docker Hub.
Builds on Phases 1–2. You'll need the Docker Hub account/credentials pattern you're already familiar with from your Jenkins Phase 5 work.
1. Image Layers (Concept)
An image is built as a stack of read-only layers, each representing one instruction in a Dockerfile. Docker caches each layer, so if nothing changed in an early layer, it reuses the cached version instead of rebuilding it — this is why build order matters a lot for speed.
┌─────────────────────────┐
│ Layer 4: COPY app code │ ← changes often
├─────────────────────────┤
│ Layer 3: RUN pip install │ ← changes when deps change
├─────────────────────────┤
│ Layer 2: COPY requirements.txt │
├─────────────────────────┤
│ Layer 1: FROM python:3.12-slim │ ← rarely changes
└─────────────────────────┘
Key principle: put things that change least often near the top of the Dockerfile, and things that change most often (like your actual application code) near the bottom. That way, a code change doesn't invalidate the cached "install dependencies" layer.
2. Anatomy of a Dockerfile (Concept)
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
| Instruction | Purpose |
|---|---|
FROM |
The base image everything else builds on top of. Always the first instruction. |
WORKDIR |
Sets the working directory inside the container for subsequent instructions (creates it if it doesn't exist) |
COPY |
Copies files from your host (build context) into the image |
ADD |
Like COPY, but also supports auto-extracting tar files and fetching URLs — generally prefer COPY unless you specifically need those extras |
RUN |
Executes a command at build time and bakes the result into a new layer (e.g., installing packages) |
ENV |
Sets an environment variable available both at build time and inside the running container |
EXPOSE |
Documents which port the container listens on (doesn't actually publish it — that's still -p at runtime) |
CMD |
The default command to run when the container starts. Can be overridden at docker run time. |
ENTRYPOINT |
Similar to CMD, but harder to override — used when you want the container to always run as a specific executable |
ARG |
A build-time-only variable, passed in with --build-arg (not available inside the running container) |
CMD vs ENTRYPOINT — the practical difference
ENTRYPOINT ["python"]
CMD ["app.py"]
Running docker run myimage executes python app.py.
Running docker run myimage other.py executes python other.py (only the CMD part gets overridden, ENTRYPOINT stays fixed).
This pattern is common when you want the container to always behave like a fixed tool, with the arguments being the flexible part.
3. Hands-On: Build Your First Image
Create a small project:
mkdir -p ~/docker-practice/webapp && cd ~/docker-practice/webapp
app.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello from my own Docker image!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
requirements.txt:
flask==3.0.0
Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
Build it:
docker build -t my-webapp:1.0 .
-t my-webapp:1.0= tag the image with a name and version.= build context (the current directory — everything Docker can see andCOPYfrom)
Run it:
docker run -d -p 5000:5000 --name webapp-test my-webapp:1.0
curl http://localhost:5000
You should see Hello from my own Docker image!.
See the layers
docker history my-webapp:1.0
Each line corresponds to one Dockerfile instruction and shows its size — useful for spotting what's bloating your image.
4. .dockerignore (Concept + Hands-On)
Just like .gitignore, a .dockerignore file excludes files from the build context — smaller context means faster builds and avoids accidentally baking in things like .git, virtual environments, or secrets.
Create .dockerignore in the same folder:
__pycache__
*.pyc
.git
.env
venv/
Rebuild and notice the "Sending build context to Docker daemon" size shrink if you had any of those present.
5. Multi-Stage Builds (Concept + Hands-On)
A multi-stage build uses multiple FROM statements in one Dockerfile — early stages can contain build tools and source code, while the final stage only copies over the finished artifact. This keeps your production image small and free of compilers, dev dependencies, and build tooling.
Example — a Go app (compiled binary), showing the size difference clearly:
# Stage 1: build
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o app .
# Stage 2: final, minimal runtime image
FROM alpine:3.19
COPY --from=builder /src/app /app
CMD ["/app"]
The final image only contains the compiled binary and Alpine's minimal base — not Go's entire toolchain (which is hundreds of MB). This pattern applies to any compiled or transpiled language (Node with a build step, Java with Maven, etc.) — use it whenever your build tools are only needed to produce the artifact, not to run it.
Try it with your Flask app too (illustrative even without compilation)
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /deps /usr/local/lib/python3.12/site-packages
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
Compare image sizes:
docker build -t my-webapp:multistage .
docker images | grep my-webapp
6. Tagging and Pushing to Docker Hub
Tags identify specific versions of an image. Convention: <username>/<repo>:<tag>.
# Tag your local image for your Docker Hub account
docker tag my-webapp:1.0 <your-dockerhub-username>/my-webapp:1.0
docker tag my-webapp:1.0 <your-dockerhub-username>/my-webapp:latest
# Log in (same credential pattern you used in Jenkins Phase 5)
docker login
# Push both tags
docker push <your-dockerhub-username>/my-webapp:1.0
docker push <your-dockerhub-username>/my-webapp:latest
Verify on hub.docker.com — your image is now publicly pullable by anyone:
docker pull <your-dockerhub-username>/my-webapp:1.0
7. Checkpoint — Can You Answer These?
- Why does instruction order in a Dockerfile matter for build speed?
- What's the difference between
CMDandENTRYPOINT? - What does the build context (the
.indocker build -t name .) actually mean, and what does.dockerignoredo to it? - Why use a multi-stage build instead of just installing everything in one stage?
- What's the difference between
ARGandENV? - What does
docker tagactually do — does it copy the image?
If you can answer all six, you're ready for Phase 4: Networking & Storage — going deeper on how containers talk to each other and manage persistent data at a production level.
Quick Reference — This Phase
docker build -t <name>:<tag> .
docker history <image>
docker tag <image>:<tag> <username>/<repo>:<tag>
docker login
docker push <username>/<repo>:<tag>
docker pull <username>/<repo>:<tag>
FROM <base-image>
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
Next: Phase 4 — Networking & Storage (deep dive). Ask when ready.
Phase 4 of 7
Networking & Storage
Custom networks, container-to-container DNS, volume backups.
Goal of this phase: Go deeper on how containers talk to each other, how Docker's networking modes work, and how to manage persistent data reliably.
Builds on Phases 1–3.
1. Docker Network Drivers (Concept)
| Driver | What it does | When to use |
|---|---|---|
| bridge (default) | Creates a private internal network on the host; containers on it can reach each other by name | Default for most single-host setups |
| host | Container shares the host's network stack directly — no isolation, no port mapping needed | Performance-sensitive cases, rarely needed for typical apps |
| none | No networking at all | Fully isolated batch/processing jobs |
| overlay | Connects containers across multiple Docker hosts | Docker Swarm / multi-host clusters (Phase 7) |
The default bridge network vs. a custom bridge network
When you just run docker run <image> without specifying a network, containers land on Docker's default bridge network — but containers on the default bridge cannot resolve each other by name, only by IP. A custom bridge network gives you automatic DNS resolution by container name, which is what you actually want for multi-container apps.
2. Hands-On: Container-to-Container Communication
# Create a custom bridge network
docker network create my-app-net
# Run a database container on it
docker run -d --name my-db --network my-app-net -e MYSQL_ROOT_PASSWORD=secretpass -e MYSQL_DATABASE=testdb mysql:8
# Run another container on the SAME network and try to reach the db BY NAME
docker run -it --network my-app-net --rm mysql:8 mysql -h my-db -u root -psecretpass -e "SHOW DATABASES;"
Notice -h my-db — you're connecting using the container name as the hostname, not an IP address. Docker's built-in DNS on custom networks resolves this automatically. This is exactly how multi-container apps (app + database + cache) find each other in practice.
# Inspect the network
docker network inspect my-app-net
# List all networks
docker network ls
# Connect an already-running container to another network
docker network connect my-app-net <container_name>
# Disconnect
docker network disconnect my-app-net <container_name>
3. Publishing Ports vs. Internal Networking
An important distinction:
- Container-to-container (same custom network) → containers reach each other directly by name, on their internal ports, no -p needed
- Host-to-container (you, outside Docker, reaching in) → requires -p host_port:container_port
This is why in a typical setup, your database container often has no -p mapping at all (nothing outside Docker needs to reach it directly), while your web app container does get -p (so your browser, outside Docker, can reach it).
4. Volumes Deep Dive (Concept + Hands-On)
You used named volumes and bind mounts briefly in Phase 2. Here's the fuller picture.
Where does Docker actually store volume data?
docker volume inspect my-data
Look at the Mountpoint field — on Linux this is typically under /var/lib/docker/volumes/. You generally shouldn't touch this directly; always go through Docker commands.
Backing up a volume
# Create a temporary container that mounts the volume and tars it to your host
docker run --rm \
-v my-data:/data \
-v $(pwd):/backup \
busybox tar czf /backup/my-data-backup.tar.gz -C /data .
Restoring a volume from a backup
docker volume create my-data-restored
docker run --rm \
-v my-data-restored:/data \
-v $(pwd):/backup \
busybox tar xzf /backup/my-data-backup.tar.gz -C /data
This "spin up a throwaway container just to move data" pattern is standard practice — it's exactly the same trick you'd have seen in the Jenkins backup guide, applied here to a single volume instead of all of JENKINS_HOME.
Read-only mounts
docker run -v my-data:/data:ro my-image
The :ro suffix mounts the volume as read-only inside the container — useful when a container should only read shared config/data, never modify it.
5. tmpfs Mounts (Concept)
A tmpfs mount stores data in the host's memory only — never written to disk, and gone the moment the container stops. Useful for sensitive temporary data (like decrypted secrets) that shouldn't persist anywhere.
docker run -d --tmpfs /app/tmp my-image
6. Practical Data Management Guidance
| Scenario | Recommended approach |
|---|---|
| Database storage that must persist | Named volume |
| Local development, want live-edit of source code | Bind mount |
| Sharing config/secrets read-only across containers | Named volume mounted :ro |
| Sensitive temp data, never persisted | tmpfs |
| Production deployments | Named volumes, backed up on a schedule (same principle as Jenkins Phase 7's backup practices) |
7. Hands-On Mini-Project: Two Containers, One Network, One Volume
Bring it together — a web app container talking to a database container over a custom network, with the database backed by a named volume.
docker network create app-net
docker volume create db-data
docker run -d --name app-db \
--network app-net \
-v db-data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=secretpass \
-e MYSQL_DATABASE=appdb \
mysql:8
docker run -d --name app-web \
--network app-net \
-p 8080:5000 \
-e DB_HOST=app-db \
-e DB_NAME=appdb \
my-webapp:1.0
Even though app-db has no -p mapping (unreachable from your host directly), app-web reaches it internally via the app-net network using the hostname app-db. Only app-web is exposed to the outside world via -p 8080:5000.
8. Checkpoint — Can You Answer These?
- Why can't containers resolve each other by name on the default bridge network, but they can on a custom one?
- What's the practical difference between when you need
-pand when you don't? - How would you back up a named volume's contents to a
.tar.gzfile on your host? - What's a
tmpfsmount, and when would you use one instead of a regular volume? - In the mini-project above, why does
app-dbnot need a-pflag whileapp-webdoes?
If you can answer all five, you're ready for Phase 5: Docker Compose — where instead of manually running these docker network create / docker volume create / docker run commands one by one, you'll define the entire multi-container setup in a single file.
Quick Reference — This Phase
# Networks
docker network create <name>
docker network ls
docker network inspect <name>
docker network connect <network> <container>
docker network disconnect <network> <container>
# Volume backup/restore pattern
docker run --rm -v <volume>:/data -v $(pwd):/backup busybox tar czf /backup/backup.tar.gz -C /data .
docker run --rm -v <volume>:/data -v $(pwd):/backup busybox tar xzf /backup/backup.tar.gz -C /data
# Read-only / tmpfs
docker run -v <volume>:<path>:ro <image>
docker run --tmpfs <path> <image>
Next: Phase 5 — Docker Compose (multi-container apps as code). Ask when ready.
Phase 5 of 7
Docker Compose
Multi-container apps as code, health checks, environment overrides.
Goal of this phase: Stop manually typing long docker run, docker network create, and docker volume create commands — define your entire multi-container application in a single file and manage it as one unit.
Builds directly on Phase 4's two-container mini-project.
1. Why Compose? (Concept)
At the end of Phase 4, standing up a two-container app took four separate commands, in a specific order, with manually-typed flags that are easy to get wrong or forget. Docker Compose replaces all of that with one declarative YAML file and one command:
docker compose up
This is the same shift in spirit as Jenkins Pipelines vs. clicking through the UI (Jenkins Phase 4) — you move from manual, order-dependent steps to a single reviewable, version-controlled definition.
Note: Modern Docker ships Compose built in as docker compose (no hyphen). You may also see the older standalone docker-compose (with a hyphen) in tutorials — functionally similar, but docker compose is the current standard.
2. Anatomy of a docker-compose.yml (Concept)
version: "3.9"
services:
web:
build: .
ports:
- "8080:5000"
environment:
- DB_HOST=db
- DB_NAME=appdb
depends_on:
- db
networks:
- app-net
db:
image: mysql:8
environment:
- MYSQL_ROOT_PASSWORD=secretpass
- MYSQL_DATABASE=appdb
volumes:
- db-data:/var/lib/mysql
networks:
- app-net
networks:
app-net:
volumes:
db-data:
| Key | Purpose |
|---|---|
services |
Each entry is one container, defined declaratively |
build: . |
Build an image from a Dockerfile in this directory (instead of pulling one) |
image: |
Use an existing image directly (no build needed) |
ports |
Same as -p in docker run |
environment |
Same as -e in docker run |
depends_on |
Controls startup order (note: this only waits for the container to start, not for the app inside it to be ready — see health checks below) |
networks (top-level) |
Declares custom networks, replacing manual docker network create |
volumes (top-level) |
Declares named volumes, replacing manual docker volume create |
networks: (inside a service) |
Which declared networks this service joins |
Compare this directly to the four manual commands from Phase 4 — every piece maps one-to-one. This file is those commands, just declarative and reviewable.
3. Hands-On: Convert Your Phase 4 Project to Compose
mkdir -p ~/docker-practice/compose-app && cd ~/docker-practice/compose-app
Copy your app.py, requirements.txt, and Dockerfile from Phase 3 into this folder.
Create docker-compose.yml:
version: "3.9"
services:
web:
build: .
ports:
- "8080:5000"
environment:
- DB_HOST=db
depends_on:
- db
networks:
- app-net
db:
image: mysql:8
environment:
- MYSQL_ROOT_PASSWORD=secretpass
- MYSQL_DATABASE=appdb
volumes:
- db-data:/var/lib/mysql
networks:
- app-net
networks:
app-net:
volumes:
db-data:
Bring it all up:
docker compose up -d
One command replaces everything you did manually in Phase 4 — Compose creates the network, the volume, builds the web image, pulls db, and starts both containers in the right order.
Check status:
docker compose ps
docker compose logs -f web
Tear it all down:
docker compose down
Notice docker compose down removes the containers and network, but keeps the named volume by default (data safety) — add -v if you explicitly want to remove volumes too:
docker compose down -v
4. Common Compose Commands
docker compose up # start (foreground, logs stream to terminal)
docker compose up -d # start detached
docker compose down # stop and remove containers + network
docker compose ps # list services and their status
docker compose logs # view logs from all services
docker compose logs -f web # follow logs from one service
docker compose exec web bash # shell into a running service (like docker exec)
docker compose build # rebuild images without starting
docker compose restart # restart all services
docker compose stop # stop without removing
5. Environment-Specific Configs (Concept + Hands-On)
Real projects usually need different settings for local development vs. production (debug mode on/off, different resource limits, live-reload volumes vs. baked-in code). Compose supports override files.
docker-compose.yml (base, shared):
version: "3.9"
services:
web:
build: .
environment:
- DB_HOST=db
networks:
- app-net
db:
image: mysql:8
environment:
- MYSQL_ROOT_PASSWORD=secretpass
networks:
- app-net
networks:
app-net:
volumes:
db-data:
docker-compose.override.yml (auto-applied in local dev, adds bind-mount + port + debug):
version: "3.9"
services:
web:
ports:
- "8080:5000"
volumes:
- .:/app
environment:
- FLASK_DEBUG=1
docker compose up automatically merges docker-compose.yml with docker-compose.override.yml if it's present — no extra flags needed. This gives you live code reload locally.
For production, use an explicit separate file instead:
docker-compose.prod.yml:
version: "3.9"
services:
web:
ports:
- "80:5000"
restart: always
Run it explicitly:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
6. Health Checks (Concept + Hands-On)
depends_on alone only waits for a container to start — a database container can report "running" before MySQL is actually ready to accept connections, which can cause your web service to crash on startup. A health check plus depends_on: condition: service_healthy fixes this properly.
services:
db:
image: mysql:8
environment:
- MYSQL_ROOT_PASSWORD=secretpass
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 5
web:
build: .
depends_on:
db:
condition: service_healthy
Now web genuinely waits until MySQL reports healthy, not just "container started."
7. Mini-Project: Three-Service App
Extend further — add a reverse proxy (nginx) in front of your web app, a very common real-world pattern.
version: "3.9"
services:
proxy:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- web
networks:
- app-net
web:
build: .
environment:
- DB_HOST=db
depends_on:
db:
condition: service_healthy
networks:
- app-net
db:
image: mysql:8
environment:
- MYSQL_ROOT_PASSWORD=secretpass
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
retries: 5
volumes:
- db-data:/var/lib/mysql
networks:
- app-net
networks:
app-net:
volumes:
db-data:
nginx.conf:
server {
listen 80;
location / {
proxy_pass http://web:5000;
}
}
Note: only proxy publishes a port to the host (80:80) — web and db stay entirely internal, reachable only within app-net. This is the real-world pattern: a single public entry point, with everything else hidden behind it.
8. Checkpoint — Can You Answer These?
- What problem does Compose solve compared to manually running
docker network create/docker volume create/docker runcommands? - What's the difference between
depends_onalone anddepends_oncombined with a health check? - What does
docker-compose.override.ymldo automatically, and why is that useful for local dev? - Why does
docker compose downkeep volumes by default, and how do you remove them anyway? - In the three-service mini-project, why does only
proxyhave aportsmapping?
If you can answer all five, you're ready for Phase 6: Production Practices — image security, size optimization, resource limits, logging, and how all of this ties into the CI/CD pipeline you already built in Jenkins.
Quick Reference — This Phase
docker compose up -d
docker compose down
docker compose down -v
docker compose ps
docker compose logs -f <service>
docker compose exec <service> bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services:
web:
build: .
ports: ["8080:5000"]
environment: [KEY=value]
depends_on:
db:
condition: service_healthy
networks: [app-net]
networks:
app-net:
volumes:
db-data:
Next: Phase 6 — Production Practices (security, size, limits, logging, CI/CD). Ask when ready.
Phase 6 of 7
Production Practices
Smaller images, non-root users, scanning, limits, CI/CD integration.
Goal of this phase: Take the images and Compose setups you've built and harden them the way a real production deployment requires — smaller images, non-root users, resource limits, sane logging, and tying it all back into the Jenkins CI/CD pipeline you already built.
Builds on Phases 1–5.
1. Minimizing Image Size (Concept + Hands-On)
Smaller images pull faster, deploy faster, and have a smaller attack surface (fewer packages = fewer potential vulnerabilities).
Choose a smaller base image
| Base | Typical size | Notes |
|---|---|---|
python:3.12 |
~1GB | Full Debian-based image, lots of build tools included |
python:3.12-slim |
~150MB | Debian-based but stripped down |
python:3.12-alpine |
~50MB | Alpine Linux, minimal — but uses musl instead of glibc, which occasionally causes compatibility issues with some Python packages |
docker build -t my-webapp:slim -f Dockerfile.slim .
docker images | grep my-webapp
Combine RUN instructions to reduce layers
# Less efficient — three separate layers, and apt cache stays baked into the image
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Better — one layer, and cache cleanup actually shrinks the image
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
Cleanup only helps if it happens in the same RUN instruction as the install — otherwise the "deleted" files are already baked into an earlier layer and still count toward image size.
Use multi-stage builds (you learned this in Phase 3 — apply it everywhere in production)
Revisit your Phase 3 multi-stage example. In production, this is standard practice, not optional — final images should never contain compilers or build-only dependencies.
2. Running as a Non-Root User (Concept + Hands-On)
By default, containers run as root inside the container. If an attacker breaks out of the application, running as root gives them more to work with. Best practice: create and use a dedicated non-root user.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create a non-root user and switch to it
RUN useradd --create-home appuser
USER appuser
EXPOSE 5000
CMD ["python", "app.py"]
Rebuild and verify:
docker build -t my-webapp:secure .
docker run --rm my-webapp:secure whoami
Should print appuser, not root.
3. Image Security Scanning (Concept + Hands-On)
Even a minimal image can carry known vulnerabilities in its OS packages or language dependencies. Scanning tools check your image against vulnerability databases.
Using Docker Scout (built into modern Docker Desktop/CLI)
docker scout quickview my-webapp:secure
docker scout cves my-webapp:secure
This reports known CVEs (Common Vulnerabilities and Exposures) found in your image's layers, with severity ratings — review "Critical" and "High" findings first.
Using Trivy (popular open-source alternative)
# Install (Linux example)
sudo yum install -y trivy # or see aquasecurity/trivy docs for your OS
trivy image my-webapp:secure
Practical habit: run a scan before pushing any image to a registry that will actually be deployed — this is exactly the kind of check that belongs as a stage in your Jenkins pipeline (see section 6).
4. Resource Limits (Concept + Hands-On)
Without limits, one misbehaving container can consume all of a host's CPU/memory and starve everything else running on it.
docker run -d --name limited-app \
--memory="256m" \
--memory-swap="256m" \
--cpus="0.5" \
my-webapp:secure
--memory="256m"— hard memory ceiling; container is killed (OOM) if it tries to exceed this--cpus="0.5"— limits the container to half of one CPU core
In Compose:
services:
web:
build: .
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
Verify limits are applied:
docker stats limited-app
5. Logging Strategy (Concept)
By default, Docker captures a container's STDOUT/STDERR via the json-file logging driver — fine for learning, but it grows unbounded and isn't ideal for real production monitoring.
Cap default log file size
docker run -d \
--log-opt max-size=10m \
--log-opt max-file=3 \
my-webapp:secure
This keeps at most 3 log files of 10MB each per container, rotating automatically.
Production pattern (awareness)
In real deployments, containers typically ship logs to a centralized system — CloudWatch Logs (since you already know AWS from your Jenkins Phase 6 work), or tools like Fluentd/Loki. Docker supports this via alternate logging drivers, e.g.:
docker run -d --log-driver=awslogs --log-opt awslogs-region=us-east-1 --log-opt awslogs-group=my-app-logs my-webapp:secure
This is worth knowing exists; treat deep configuration of it as a stretch goal rather than a required exercise here.
6. Docker in CI/CD (Ties Back to Jenkins)
This is where everything connects directly to the Jenkins pipeline you already built in your Jenkins Phase 5 guide. A production-grade version of that pipeline adds the practices from this phase as explicit stages:
stage('Build') {
steps {
sh 'docker build -t ${IMAGE_NAME}:${IMAGE_TAG} .'
}
}
stage('Security Scan') {
steps {
sh 'docker scout cves ${IMAGE_NAME}:${IMAGE_TAG} || true'
// '|| true' prevents a scan finding from silently failing the whole build while you're learning;
// in real production you'd fail the build on Critical/High findings instead
}
}
stage('Test Image Runs') {
steps {
sh '''
docker run -d --name test-container -p 5050:5000 ${IMAGE_NAME}:${IMAGE_TAG}
sleep 3
curl -f http://localhost:5050 || exit 1
docker stop test-container && docker rm test-container
'''
}
}
stage('Push') {
steps {
sh 'docker push ${IMAGE_NAME}:${IMAGE_TAG}'
}
}
Notice the "Test Image Runs" stage — a basic smoke test, confirming the container actually starts and responds, before pushing it anywhere. This kind of gate is what separates "the build succeeded" from "the build actually works."
7. Hands-On: Harden Your Phase 5 Project
Go back to your Phase 5 three-service Compose project and apply what you learned here:
1. Switch the web service's Dockerfile to a -slim base if it isn't already
2. Add a non-root USER instruction
3. Add resource limits to each service in docker-compose.yml
4. Run a security scan on the built image
5. Add log rotation options to the db and web services
8. Checkpoint — Can You Answer These?
- Why does combining
RUNinstructions into one line actually reduce final image size, not just line count? - Why run containers as a non-root user by default?
- What does an image security scan actually check for?
- What happens if a container exceeds its
--memorylimit? - Why is unbounded default logging a problem in production, and what's the quick fix vs. the real production pattern?
- Why does the pipeline stage list include a "Test Image Runs" step before "Push"?
If you can answer all six, you're ready for Phase 7: Container Orchestration Intro — the final phase, covering why orchestration matters, a lightweight look at Docker Swarm, and how this connects to Kubernetes and your existing Terraform/Ansible/AWS skills.
Quick Reference — This Phase
# Non-root user check
docker run --rm <image> whoami
# Security scanning
docker scout cves <image>
trivy image <image>
# Resource limits
docker run --memory="256m" --cpus="0.5" <image>
# Log rotation
docker run --log-opt max-size=10m --log-opt max-file=3 <image>
RUN useradd --create-home appuser
USER appuser
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
Next: Phase 7 — Container Orchestration Intro (Swarm, Kubernetes overview). Ask when ready.
Phase 7 of 7
Orchestration Intro
Docker Swarm hands-on, and where Kubernetes fits in.
Goal of this phase: Understand why a single Docker host isn't enough at real scale, get hands-on with Docker Swarm (orchestration built directly into Docker), and understand conceptually where Kubernetes fits — plus how this connects to the Terraform/Ansible/AWS skills you already have.
This is the final phase of the core Docker curriculum. Builds on everything in Phases 1–6.
1. Why Orchestration? (Concept)
Everything so far has run on one machine. That's fine for learning, but real production systems need:
| Need | Why a single host falls short |
|---|---|
| Scaling | One host has finite CPU/memory — can't just add more containers forever |
| High availability | If that one host goes down, everything goes down |
| Self-healing | If a container crashes, something needs to notice and restart it automatically |
| Load balancing | Traffic needs to be spread across multiple copies of your app |
| Rolling updates | Deploying a new version without downtime, across many instances at once |
An orchestrator manages containers across a cluster of machines, handling all of the above automatically instead of you doing it by hand.
2. Docker Swarm Basics (Concept + Hands-On)
Swarm is Docker's own built-in orchestrator — no extra installation needed, and it uses concepts you already know (images, networks, Compose-like YAML), making it the natural first step beyond a single host.
Key concepts
| Term | Meaning |
|---|---|
| Node | A machine (physical/VM/EC2 instance) participating in the swarm |
| Manager node | Coordinates the cluster, schedules work, maintains desired state |
| Worker node | Runs containers as instructed by managers |
| Service | A definition of "run this image, this many times, with these settings" — Swarm's equivalent of a Compose service, but cluster-aware |
| Task | One running container instance that's part of a service |
| Replica | The number of task instances of a service you want running |
Hands-On: Initialize a Single-Node Swarm
You can experiment with Swarm on just one machine before adding more nodes.
docker swarm init
# Confirm
docker node ls
Hands-On: Deploy a Service
docker service create --name web --replicas 3 -p 8080:80 nginx
# Check status
docker service ls
docker service ps web
You now have 3 replicas of nginx running, all load-balanced automatically behind port 8080 — Swarm's built-in routing mesh handles this for you, no manual load balancer setup needed.
Hands-On: Scale It
docker service scale web=5
docker service ps web
Hands-On: Self-Healing
# Find one of the running containers and kill it directly
docker ps
docker kill <container_id>
# Check service status again
docker service ps web
Notice Swarm automatically started a replacement task to bring the replica count back to 5 — this is self-healing in action, something a plain docker run setup never does on its own.
Hands-On: Rolling Update
docker service update --image nginx:1.25 --update-parallelism 1 --update-delay 10s web
This updates replicas one at a time, waiting 10 seconds between each — avoiding taking all instances down simultaneously.
Multi-Node Swarm (Conceptual — requires multiple EC2 instances)
# On the manager node
docker swarm init --advertise-addr <manager-private-ip>
# This prints a `docker swarm join` command with a token
# On each worker node, run the printed command, e.g.:
docker swarm join --token <token> <manager-ip>:2377
Since you already know how to launch and manage EC2 instances (from your Jenkins/AWS work), a natural stretch exercise is standing up 2-3 small EC2 instances and forming a real multi-node swarm — though a single-node swarm is sufficient to learn the core concepts above.
Deploying a Compose file to Swarm
Swarm can consume a Compose file directly with docker stack deploy:
docker stack deploy -c docker-compose.yml my-app
docker stack services my-app
docker stack rm my-app
This reuses the exact Compose file structure from Phase 5, with a few Swarm-specific additions (like deploy: blocks for replicas — which you already saw partially in Phase 6 for resource limits).
3. Kubernetes: Conceptual Overview (No Hands-On Required Here)
Kubernetes (K8s) solves the same core problem as Swarm — orchestrating containers across a cluster — but is far more feature-rich and has become the industry-standard choice for larger, more complex deployments.
Swarm vs. Kubernetes at a glance
| Docker Swarm | Kubernetes | |
|---|---|---|
| Setup complexity | Simple, built into Docker | Significantly more complex |
| Learning curve | Gentle — reuses Docker/Compose concepts | Steep — many new concepts (Pods, Deployments, Services, Ingress, ConfigMaps...) |
| Ecosystem/adoption | Smaller, less common in job postings now | Dominant industry standard |
| Flexibility | Good for simpler needs | Extremely flexible, huge plugin ecosystem |
| Managed cloud options | Limited | AWS EKS, Azure AKS, Google GKE — all major clouds offer managed Kubernetes |
Why teams choose Kubernetes despite the complexity
- Enormous ecosystem of tools (Helm for packaging, Istio for service mesh, ArgoCD for GitOps, etc.)
- Fine-grained control over networking, storage, and scheduling
- The de facto standard most companies and job postings expect
A rough concept mapping (Swarm → Kubernetes)
| Swarm concept | Rough Kubernetes equivalent |
|---|---|
| Service | Deployment + Service |
| Task | Pod |
docker stack deploy |
kubectl apply -f |
| Routing mesh | Service + Ingress |
| Swarm manager | Control plane (API server, scheduler, etcd) |
Recommendation: now that you understand orchestration concepts through Swarm (which uses vocabulary and mental models you already have from Docker/Compose), Kubernetes becomes a much more approachable next learning project on its own — it deserves its own dedicated multi-phase curriculum rather than being rushed here. Just say the word whenever you want that built out the same way as this one.
4. Where This Connects to Your Existing Skills
| Your existing skill | How it connects here |
|---|---|
| Terraform | Used to provision the actual EC2 instances/VPC/security groups that become Swarm nodes (or a managed EKS cluster, if you go the Kubernetes route later) |
| Ansible | Used to install Docker and join nodes to the swarm automatically (docker swarm join) across many machines, instead of doing it by hand on each one |
| AWS | Hosts the nodes; also offers managed alternatives — ECS (AWS's own simpler container orchestrator) or EKS (managed Kubernetes) instead of self-managing Swarm |
| Jenkins | Your pipeline's final "Deploy" stage would call docker stack deploy (or kubectl apply) instead of just running a single container directly on one server |
This is genuinely the natural end-state of everything you've been building across both curriculums: Jenkins pipeline → builds and scans a Docker image → Terraform provisions a cluster → Ansible configures the nodes → the pipeline deploys the image as a scaled, self-healing service across that cluster.
5. Checkpoint — Can You Answer These?
- Name three problems orchestration solves that a single Docker host can't.
- What's the difference between a Swarm manager node and a worker node?
- When you killed a container in the self-healing exercise, what did Swarm do, and why does that matter for production?
- What does
--update-parallelism 1 --update-delay 10saccomplish during a rolling update? - In your own words, why do most companies choose Kubernetes over Swarm despite Swarm being simpler?
- How would Terraform and Ansible each play a role in setting up a multi-node Swarm or Kubernetes cluster?
If you can answer all six, you've completed the full 7-phase Docker curriculum — from zero, to building your own images, to multi-container apps with Compose, to production hardening, to a working orchestrated deployment.
Quick Reference — This Phase
docker swarm init
docker node ls
docker service create --name web --replicas 3 -p 8080:80 nginx
docker service ls
docker service ps web
docker service scale web=5
docker service update --image <new-image> --update-parallelism 1 --update-delay 10s web
docker stack deploy -c docker-compose.yml my-app
docker stack services my-app
docker stack rm my-app
What's Next (Beyond This Curriculum)
- Kubernetes — a natural, dedicated next curriculum, now that Swarm has given you the core orchestration vocabulary
- AWS ECS/EKS — managed alternatives to self-hosting Swarm/Kubernetes, worth exploring given your existing AWS skills
- Combine both curriculums into one real project: Jenkins pipeline that builds, scans, and deploys a Docker image to a Terraform-provisioned, Ansible-configured cluster — a genuinely strong, resume-ready capstone
Congratulations on finishing the Docker curriculum — combined with your completed Jenkins curriculum, this is a comprehensive, production-relevant CI/CD and containerization skill set.