Skip to main content

πŸ§ͺ 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:

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.

Flask API container talks to a PostgreSQL container over a private bridge network, with a named volume for database data and Adminer for administration Flask + PostgreSQL β€” one private network, one named volume api-network (bridge) Flask API python:3.12-slim Β· :5000 PostgreSQL postgres:16-alpine Β· :5432 Adminer :8080 SQL πŸ’Ύ volume: db_data
Figure 1: The API reaches the database by the service name 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.

A multi-stage frontend build produces a small nginx image serving the React app, which calls a Node Express API that reads from MongoDB, all on one network Multi-stage frontend + Node API frontend/Dockerfile β€” multi-stage Stage 1: build node:20 Β· npm run build Stage 2: serve nginx:alpine app-network (bridge) frontend (nginx) :8080 β†’ :80 api (Express) node:20 Β· :3001 mongo πŸ’Ύ mongo_data /api
Figure 2: The final frontend image contains only nginx and static files β€” the whole Node toolchain stays behind in the build stage and never ships.

πŸ“ 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.

WordPress container connects to a MySQL container on a private network; each has its own named volume, and phpMyAdmin provides database administration WordPress + MySQL β€” private network, persistent volumes wp-network (bridge) WordPress wordpress:php8.3 Β· :8000 MySQL mysql:8.4 Β· :3306 (internal) phpMyAdmin :8081 DB πŸ’Ύ wp_data πŸ’Ύ db_data
Figure 3: MySQL never publishes a host port β€” only WordPress and phpMyAdmin are reachable from your browser. The two named volumes are what survive a 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.

An nginx gateway routes to a users service and an orders service; the orders service calls the users service and a Redis datastore by service name on a shared network Gateway + 2 services + datastore β€” all by service name mesh-network (bridge) gateway nginx Β· :8080 users-service node Β· :3001 orders-service node Β· :3002 redis datastore Β· :6379 /users /orders calls users-service
Figure 4: The gateway is the only service that publishes a host port. Everything else talks over the internal 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 compose commands, compose.yaml files, healthchecks, and no obsolete version: key.

πŸš€ Level-up challenges

  1. CI/CD: add a GitHub Actions workflow that builds and pushes each image on every commit.
  2. Image scanning: run docker scout cves on every image and fix the high-severity findings.
  3. Monitoring: drop Prometheus + Grafana into the microservices stack.
  4. Environments: use a compose.override.yaml to switch between dev and prod settings.
  5. Backups: add a small cron container that snapshots the database volume nightly.

πŸ“š Additional Resources

πŸ—οΈ 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!