π§ͺ Practice Lab: Hands-on Projects
Four complete, runnable projects that turn everything you've learned into real dockerized applications β build them, run them, and verify them for yourself.
π― What You'll Build
This is a hands-on lab, not a concept lesson. By the end you will have stood up, from scratch:
- A Python Flask REST API on a slim, non-root image with cache-friendly layering
- A React + Node.js app using a multi-stage build (React β nginx) wired to an API
- A WordPress + MySQL stack with named volumes and a private network
- A microservices mesh β a gateway, two services, and a datastore talking by service name
Every project ships an architecture diagram, a file tree, its Dockerfile(s), a modern compose.yaml, the exact build/run/verify commands, and the output you should expect.
Estimated Time: 2β3 hours (about 30β45 minutes per project)
Prerequisite: Lessons 1β6 β you should be comfortable with images, containers, volumes, networks, and Compose.
In This Lab
π§ Which project should I try first?
Work them in order if you can β each one adds a concept. But if you want to jump in:
- New to Dockerfiles? Start with Project 1 (Flask) β one image, one database, the cleanest introduction to non-root and layer caching.
- Want to see multi-stage builds shine? Go to Project 2 (React + Node).
- Prefer zero application code? Project 3 (WordPress) is pure Compose β official images, volumes, and a private network.
- Ready for the deep end? Project 4 (Microservices) ties service-to-service networking together.
Everything is modern Compose v2: commands are docker compose (with a space), the file is compose.yaml, and there is no obsolete top-level version: key anywhere.
Project 1 Β· Python Flask REST API π
We'll build a small Flask REST API backed by PostgreSQL, and add Adminer so you can poke at the database in a browser. The star of this project is the Dockerfile: a pinned slim base, a non-root user, and dependencies copied before the source so rebuilds hit the layer cache.
postgres β no IP addresses, no host ports for the DB. Only the API and Adminer publish ports.π Project structure
flask-api-docker/
βββ app/
β βββ app.py # the Flask application
β βββ requirements.txt # pinned Python dependencies
βββ Dockerfile # slim base, non-root, cache-friendly
βββ .dockerignore # keep build context small
βββ compose.yaml # api + postgres + adminer
βββ .env # database credentials (never commit)
Step 1 Β· The Flask application
A tiny tasks API β list and create tasks β reading its database connection entirely from environment variables so the same image works in any environment.
# app/app.py
import os
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = (
f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASS']}"
f"@{os.environ['DB_HOST']}/{os.environ['DB_NAME']}"
)
db = SQLAlchemy(app)
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
completed = db.Column(db.Boolean, default=False)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/tasks")
def get_tasks():
tasks = Task.query.all()
return jsonify([
{"id": t.id, "title": t.title, "completed": t.completed}
for t in tasks
])
@app.post("/tasks")
def add_task():
data = request.get_json(force=True)
task = Task(title=data["title"])
db.session.add(task)
db.session.commit()
return jsonify({"id": task.id, "title": task.title}), 201
with app.app_context():
db.create_all()
# app/requirements.txt
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
psycopg2-binary==2.9.9
gunicorn==22.0.0
Step 2 Β· A modern, cache-friendly Dockerfile
Read the comments β every line earns its place. Copying requirements.txt and installing before copying the source means editing app.py won't re-run pip install. We also create and switch to a non-root user, and serve with gunicorn instead of Flask's dev server.
# syntax=docker/dockerfile:1
FROM python:3.12-slim
# Don't buffer logs; don't write .pyc files
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# 1) Dependencies first β this layer is cached until requirements.txt changes
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 2) Source second β editing code invalidates only from here down
COPY app/ .
# 3) Run as an unprivileged user, not root
RUN useradd --create-home --uid 10001 appuser
USER appuser
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
π§Ή Keep the build context small. A .dockerignore stops junk from being shipped to the daemon and baked into layers:
# .dockerignore
__pycache__/
*.pyc
.env
.git
.venv
README.md
Step 3 Β· Wire it together with Compose
Note what's missing: no version: key (obsolete in Compose v2), the file is named compose.yaml, and the database has no published port β only the API and Adminer are reachable from your host.
# compose.yaml
services:
api:
build: .
ports:
- "5000:5000"
environment:
DB_HOST: postgres
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
depends_on:
postgres:
condition: service_healthy
networks:
- api-network
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 3s
retries: 5
networks:
- api-network
adminer:
image: adminer:4
ports:
- "8080:8080"
depends_on:
- postgres
networks:
- api-network
networks:
api-network:
driver: bridge
volumes:
db_data:
# .env (git-ignored β do NOT commit real secrets)
DB_NAME=flask_api_db
DB_USER=flask_user
DB_PASS=change_me_in_prod
Step 4 Β· Build, run, and verify
# Build the image and start everything (v2 syntax β note the space)
docker compose up --build -d
# Watch the stack come up healthy
docker compose ps
# Create a task
curl -X POST http://localhost:5000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Docker Compose"}'
# List tasks back
curl http://localhost:5000/tasks
Expected output:
$ docker compose ps
NAME IMAGE STATUS PORTS
flask-api-docker-api-1 flask-api-docker-api Up 0.0.0.0:5000->5000/tcp
flask-api-docker-postgres-1 postgres:16-alpine Up (healthy) 5432/tcp
flask-api-docker-adminer-1 adminer:4 Up 0.0.0.0:8080->8080/tcp
$ curl -X POST ... /tasks
{"id":1,"title":"Learn Docker Compose"}
$ curl http://localhost:5000/tasks
[{"completed":false,"id":1,"title":"Learn Docker Compose"}]
Open http://localhost:8080 for Adminer β log in with System: PostgreSQL, Server: postgres, and the username/password from your .env. You'll see your task table and the row you just created.
π οΈ Stretch goal & troubleshooting
Stretch: Add a DELETE /tasks/<id> route and a completed toggle. Then scan the image for known vulnerabilities with the modern tool (the old docker scan was removed):
docker scout quickview flask-api-docker-api
docker scout cves flask-api-docker-api
"connection refused" on first boot? The API can start before Postgres is ready. The healthcheck plus condition: service_healthy above solves that β but if you removed it, add a short retry loop or bring the DB up first with docker compose up -d postgres.
Permission errors writing files? Remember the container runs as appuser (uid 10001), not root. Anything the app writes must live somewhere that user owns β /app is fine; / is not.
Project 2 Β· React + Node.js (multi-stage) βοΈ
Now the headline technique: a multi-stage build. React compiles to static files, so there's no reason to ship Node and 400 MB of node_modules to production. We build the app in a Node stage, then copy only the compiled output into a tiny nginx stage. A separate Node/Express API serves data, and Compose wires the two together.
π Project structure
react-node-docker/
βββ frontend/
β βββ src/
β βββ package.json
β βββ nginx.conf # proxy /api to the backend
β βββ Dockerfile # multi-stage: node build β nginx serve
βββ backend/
β βββ server.js
β βββ package.json
β βββ Dockerfile # small node runtime
βββ compose.yaml
βββ .dockerignore
Step 1 Β· The Node/Express API
// backend/server.js
const express = require("express");
const mongoose = require("mongoose");
const app = express();
app.use(express.json());
mongoose.connect(process.env.MONGO_URL || "mongodb://mongo:27017/todoapp");
const Todo = mongoose.model("Todo", {
title: String,
completed: Boolean,
});
app.get("/api/health", (req, res) => res.json({ status: "ok" }));
app.get("/api/todos", async (req, res) => {
res.json(await Todo.find());
});
app.post("/api/todos", async (req, res) => {
const todo = await Todo.create({ title: req.body.title, completed: false });
res.status(201).json(todo);
});
app.listen(3001, () => console.log("API listening on :3001"));
Step 2 Β· The multi-stage frontend Dockerfile
This is the technique to internalize. Stage build installs dependencies and compiles; stage serve starts fresh from nginx and copies only the built dist/ folder via --from=build.
# syntax=docker/dockerfile:1
# ---- Stage 1: build the React app ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci # cached until package-lock.json changes
COPY . .
RUN npm run build # emits static files to /app/dist
# ---- Stage 2: serve with nginx ----
FROM nginx:1.27-alpine AS serve
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
# nginx:alpine already runs its worker processes unprivileged
CMD ["nginx", "-g", "daemon off;"]
The nginx config serves the SPA and forwards API calls to the backend by service name:
# frontend/nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Serve the single-page app
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API calls to the Node service by its Compose name
location /api/ {
proxy_pass http://api:3001;
}
}
Step 3 Β· The backend Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # production deps only
COPY . .
USER node # the node image ships a non-root 'node' user
EXPOSE 3001
CMD ["node", "server.js"]
Step 4 Β· Compose wires frontend β api β mongo
# compose.yaml
services:
frontend:
build: ./frontend
ports:
- "8080:80"
depends_on:
- api
networks:
- app-network
api:
build: ./backend
environment:
MONGO_URL: mongodb://mongo:27017/todoapp
depends_on:
- mongo
networks:
- app-network
mongo:
image: mongo:7
volumes:
- mongo_data:/data/db
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
mongo_data:
Step 5 Β· Build, run, and verify
docker compose up --build -d
# The browser talks to the frontend; the frontend proxies /api to the backend
curl http://localhost:8080/api/health
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Ship a multi-stage image"}'
curl http://localhost:8080/api/todos
# Prove the multi-stage payoff β the frontend image is tiny
docker images | grep react-node-docker-frontend
Expected output:
$ curl http://localhost:8080/api/health
{"status":"ok"}
$ curl -X POST ... /api/todos
{"title":"Ship a multi-stage image","completed":false,"_id":"66b...","__v":0}
$ curl http://localhost:8080/api/todos
[{"_id":"66b...","title":"Ship a multi-stage image","completed":false}]
$ docker images | grep react-node-docker-frontend
react-node-docker-frontend latest a1b2c3d4 ~55MB
π‘ Why it matters: a single-stage React image easily hits 400 MB+; the multi-stage nginx image lands around 50β60 MB. Smaller images pull faster, start faster, and have a far smaller attack surface.
π οΈ Stretch goal & troubleshooting
Stretch: Add a --target build to docker build to inspect just the build stage, then compare sizes with docker images. Add a .dockerignore containing node_modules and dist so local artifacts don't bloat the build context.
404 on /api? Check the nginx proxy_pass target matches the API's service name (api) and port (3001) β not localhost. Inside the network, localhost means the nginx container itself.
Blank page? The SPA fallback try_files $uri $uri/ /index.html; is what lets client-side routes work on refresh. Without it, deep links 404.
Project 3 Β· WordPress + MySQL Stack π
Sometimes the win is that you write no application code at all β you compose official images. Here we stand up WordPress with a MySQL database, persist both with named volumes, and isolate them on a private network. Add phpMyAdmin to browse the database.
docker compose down.π Project structure
wordpress-docker/
βββ compose.yaml # wordpress + mysql + phpmyadmin
βββ .env # database credentials (git-ignored)
That's the whole project. No Dockerfile β the official images do everything.
Step 1 Β· The Compose file
Again: no version: key, file named compose.yaml, MySQL kept off the host with named volumes for both WordPress files and the database.
# compose.yaml
services:
wordpress:
image: wordpress:php8.3-apache
ports:
- "8000:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: ${MYSQL_DATABASE}
WORDPRESS_DB_USER: ${MYSQL_USER}
WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- wp_data:/var/www/html
depends_on:
db:
condition: service_healthy
networks:
- wp-network
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 10
networks:
- wp-network
phpmyadmin:
image: phpmyadmin:5
ports:
- "8081:80"
environment:
PMA_HOST: db
depends_on:
- db
networks:
- wp-network
networks:
wp-network:
driver: bridge
volumes:
wp_data:
db_data:
# .env (git-ignored)
MYSQL_DATABASE=wordpress
MYSQL_USER=wp_user
MYSQL_PASSWORD=wp_password
MYSQL_ROOT_PASSWORD=root_password
Step 2 Β· Bring it up and visit it
# Start the whole stack in the background
docker compose up -d
# Confirm all three services are healthy/up
docker compose ps
# Follow WordPress logs while it initializes
docker compose logs -f wordpress
Expected output:
$ docker compose ps
NAME IMAGE STATUS
wordpress-docker-db-1 mysql:8.4 Up (healthy)
wordpress-docker-wordpress-1 wordpress:php8.3-apache Up
wordpress-docker-phpmyadmin-1 phpmyadmin:5 Up
# In wordpress logs, near the end:
[core:notice] AH00094: Command line: 'apache2 -D FOREGROUND'
Now open http://localhost:8000 β you'll be greeted by the WordPress install wizard. Pick a language, create your admin account, and you're running WordPress in Docker. Browse the database at http://localhost:8081 (phpMyAdmin, server db).
π Prove persistence
The whole point of named volumes: your content survives container restarts. Try it:
# Recreate containers but KEEP volumes β your site is intact
docker compose down
docker compose up -d # WordPress install still there
# Wipe EVERYTHING including volumes β fresh install next time
docker compose down -v
π οΈ Stretch goal & troubleshooting
Stretch: Develop a custom theme live by bind-mounting a local folder into WordPress. Add to the wordpress service:
volumes:
- wp_data:/var/www/html
- ./themes:/var/www/html/wp-content/themes/mytheme
Then create ./themes/style.css with a theme header and activate it under Appearance β Themes.
WordPress can't connect to the database? It usually starts before MySQL finishes initializing. The healthcheck + condition: service_healthy above fixes this. Also confirm WORDPRESS_DB_HOST is db:3306 (the service name), not localhost.
Back up the database:
docker compose exec db \
mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" wordpress > backup.sql
Project 4 Β· Microservices Mesh πΈοΈ
The capstone. A small stack that shows the core idea of microservices on Docker: service-to-service networking by name. An nginx gateway routes requests to two independent services β a users service and an orders service β and the orders service calls the users service and a shared Redis datastore, all by their Compose service names. No IP addresses anywhere.
mesh-network using DNS names Docker provides automatically.π Project structure
microservices-docker/
βββ gateway/
β βββ nginx.conf # routes /users and /orders
βββ users-service/
β βββ index.js
β βββ package.json
β βββ Dockerfile
βββ orders-service/
β βββ index.js
β βββ package.json
β βββ Dockerfile
βββ compose.yaml # gateway + 2 services + redis
Step 1 Β· The two services
Each service is a few lines of Express. The key line is in orders: it fetches from http://users-service:3001 β the service name, resolved by Docker's built-in DNS.
// users-service/index.js
const express = require("express");
const app = express();
const USERS = [{ id: 1, name: "Ada" }, { id: 2, name: "Linus" }];
app.get("/health", (req, res) => res.json({ service: "users", status: "ok" }));
app.get("/", (req, res) => res.json(USERS));
app.get("/:id", (req, res) =>
res.json(USERS.find((u) => u.id === Number(req.params.id)) || {}));
app.listen(3001, () => console.log("users-service on :3001"));
// orders-service/index.js
const express = require("express");
const { createClient } = require("redis");
const app = express();
const redis = createClient({ url: "redis://redis:6379" });
redis.connect();
app.get("/health", (req, res) => res.json({ service: "orders", status: "ok" }));
app.get("/", async (req, res) => {
// Talk to another service BY NAME β Docker DNS resolves "users-service"
const users = await fetch("http://users-service:3001/").then((r) => r.json());
const count = await redis.incr("orders:views");
res.json({ views: count, customers: users });
});
app.listen(3002, () => console.log("orders-service on :3002"));
Step 2 Β· A shared, minimal Dockerfile
Both Node services use the same small Dockerfile (drop one in each service folder):
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "index.js"]
Step 3 Β· The gateway routing
# gateway/nginx.conf
events {}
http {
server {
listen 80;
location /users/ {
proxy_pass http://users-service:3001/;
}
location /orders/ {
proxy_pass http://orders-service:3002/;
}
location /health {
return 200 "gateway ok\n";
}
}
}
Step 4 Β· Compose ties the mesh together
# compose.yaml
services:
gateway:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- users-service
- orders-service
networks:
- mesh-network
users-service:
build: ./users-service
networks:
- mesh-network
orders-service:
build: ./orders-service
depends_on:
- redis
- users-service
networks:
- mesh-network
redis:
image: redis:7-alpine
networks:
- mesh-network
networks:
mesh-network:
driver: bridge
Step 5 Β· Build, run, and verify service-to-service networking
docker compose up --build -d
# Everything is reached THROUGH the gateway on one port
curl http://localhost:8080/health
curl http://localhost:8080/users/
curl http://localhost:8080/orders/
# Hit orders again β the Redis counter proves shared state
curl http://localhost:8080/orders/
Expected output:
$ curl http://localhost:8080/health
gateway ok
$ curl http://localhost:8080/users/
[{"id":1,"name":"Ada"},{"id":2,"name":"Linus"}]
$ curl http://localhost:8080/orders/
{"views":1,"customers":[{"id":1,"name":"Ada"},{"id":2,"name":"Linus"}]}
$ curl http://localhost:8080/orders/ # again
{"views":2,"customers":[{"id":1,"name":"Ada"},{"id":2,"name":"Linus"}]}
The customers array in the orders response came from the users service β one container called another purely by name, and the views counter incrementing proves Redis is shared. That is microservices networking in a nutshell.
π See the DNS for yourself: exec into a service and ping a sibling by name:docker compose exec orders-service ping -c1 users-service
π οΈ Stretch goal & troubleshooting
Stretch: Scale a service and watch Docker load-balance across replicas:
docker compose up -d --scale users-service=3
docker compose ps # three users-service replicas
Docker's internal DNS round-robins requests to users-service across the replicas β no config change needed.
"getaddrinfo ENOTFOUND users-service"? The caller and callee must share a network, and you must use the service name, not the container name or localhost. Confirm both list mesh-network.
Gateway 502? The upstream service isn't up yet or is crashing. Check docker compose logs orders-service β a common cause is Redis not being ready; add a healthcheck and condition: service_healthy as in Project 1.
Wrap-Up & Next Steps
π What you practiced
- Cache-friendly, non-root Dockerfiles β dependencies before source, pinned slim bases, an unprivileged
USER, and a.dockerignore(Project 1). - Multi-stage builds β compile in a heavy stage, ship only artifacts in a tiny nginx stage, cutting image size by an order of magnitude (Project 2).
- Pure-Compose stacks from official images β WordPress + MySQL with named volumes and a private network, and proof that volumes outlive containers (Project 3).
- Service-to-service networking β a gateway routing to independent services that call each other and a shared datastore purely by service name (Project 4).
- Modern Compose v2 throughout β
docker composecommands,compose.yamlfiles, healthchecks, and no obsoleteversion:key.
π Level-up challenges
- CI/CD: add a GitHub Actions workflow that builds and pushes each image on every commit.
- Image scanning: run
docker scout cveson every image and fix the high-severity findings. - Monitoring: drop Prometheus + Grafana into the microservices stack.
- Environments: use a
compose.override.yamlto switch between dev and prod settings. - Backups: add a small cron container that snapshots the database volume nightly.
π Additional Resources
- Docker Compose documentation β the authoritative reference for the v2 spec
- Multi-stage builds β the official deep dive behind Project 2
- Dockerfile best practices β caching, layering, and non-root guidance
- Docker Scout β the modern image-scanning tool (replaces the removed
docker scan) - Docker Hub β official images for WordPress, MySQL, Redis, nginx, and more
ποΈ You've dockerized four real applications!
You didn't just read about Docker β you built, ran, and verified working stacks across four architectures. That's the muscle memory that turns Docker from a tool you know about into one you reach for. Keep containerizing!