Open Source Deployment Lab
Operate a production-style WordPress stack: private networking, persistence, backups, health checks, and failure drills.
Project Overview
Build and operate a production-style open-source deployment using WordPress as the main application. This is not a beginner Docker tutorial — the lab focuses on the operational concerns that decide whether a system survives contact with production: persistence, private networking, reverse proxying, backup and restore, health checks, and failure drills.
The topology is deliberately small so the important decisions stay visible and testable. Nginx is the only public entry point. WordPress runs as PHP-FPM behind the reverse proxy, and MariaDB is isolated on a private, internal-only network. The goal is not the most complicated platform possible — it is to make every operational decision explicit and verifiable.
Architecture
You will build a production-style Docker Compose topology with three services. Nginx is the only public entry point; it reverse-proxies PHP requests to WordPress (PHP-FPM) over FastCGI, and MariaDB sits on a private, internal-only network that nothing outside the stack can reach.
Host browser
your machine
Nginx
reverse proxy
WordPress
PHP-FPM application runtime
MariaDB
private relational database
Each service has a focused role:
- Nginx — public reverse proxy; serves static WordPress files and forwards PHP requests to wordpress:9000. Bound to 127.0.0.1:8080 so the lab is not exposed on every interface.
- WordPress (PHP-FPM) — the application runtime; reads DB settings from environment variables and stores mutable files in a named volume. DISALLOW_FILE_EDIT is set to reduce in-dashboard risk.
- MariaDB — private-only relational database; UTF-8 defaults, a fixed isolation level, a connection limit, and a modest InnoDB buffer pool sized for a local production lab.
| Network | Attached services | Purpose |
|---|---|---|
| public | Nginx | Host-facing ingress network |
| private (internal: true) | Nginx, WordPress, MariaDB | Internal app + database traffic; no route to the host |
| Volume | Mounted at | Purpose |
|---|---|---|
| wordpress-data | /var/www/html | WordPress files, plugins, themes, uploads |
| db-data | /var/lib/mysql | MariaDB persistent data directory |
| Service | Health check |
|---|---|
| Nginx | GET /healthz from inside the container |
| WordPress | PHP can open the local PHP-FPM listener (port 9000) |
| MariaDB | healthcheck.sh --connect --innodb_initialized |
Learning Objectives
- Run a multi-service production topology with a public reverse proxy and a private, internal-only database network.
- Persist application and database state with named Docker volumes that survive container recreation.
- Implement and verify container health checks and readiness-based dependency ordering (depends_on: service_healthy).
- Operate a backup-and-restore workflow that produces verifiable, compressed artifacts.
- Diagnose and recover from real incidents: a hard database crash and a volume permission failure.
- Enumerate the secret-exposure surface of a running stack and reason about what is acceptable.
- Perform a minor database version upgrade with zero data loss.
Project Structure
01-open-source-deployment-lab/
README.md
architecture.md
docker-compose.yml
.env.example
Makefile
nginx/
default.conf
scripts/
backup-db.sh
restore-db.sh
backup-files.sh
simulate-db-failure.sh
simulate-volume-issue.sh
backups/
.gitkeepStep-by-Step Guide
- 01
Set up & launch the stack
Copy the environment file, set real secrets, and bring the stack up.
Only Nginx publishes a host port (127.0.0.1:8080 by default). WordPress and MariaDB stay on the private network. Compose uses ${VAR:?...} required-variable syntax, so it fails fast if a secret is missing.
bash# 1. Create your local env file cp .env.example .env # 2. Replace the placeholder passwords in .env with generated values # (WORDPRESS_DB_PASSWORD, MYSQL_ROOT_PASSWORD, ...) # 3. Validate the compose model without real secrets docker compose --env-file .env.example config # 4. Start the stack make upOpen WordPress at http://127.0.0.1:8080 once the services report healthy.
Success criteria
- `docker compose ... config` expands with no missing-variable errors.
- `.env` exists locally and is never committed; only `.env.example` is in git.
- WordPress is reachable at http://127.0.0.1:8080.
- 02
Cold start & health verification
Start from a clean state and verify every service reaches healthy.
bashCONFIRM_CLEAN=yes make clean make up make psThe dependency chain nginx → wordpress → db explains the startup order. Each service sets a different health-check start_period; MariaDB needs the longest because InnoDB initializes its data directory on first boot.
Success criteria
- All three services report `(healthy)` within 90 seconds.
- `make ps` shows nothing in `restarting` or `exited` state.
- No credentials appear in console output during startup.
- 03
Network isolation verification
Prove MariaDB is unreachable from outside the private network.
bash# db has no published host port docker inspect open-source-deployment-lab-db-1 \ --format='{{.NetworkSettings.Ports}}' # nginx (on public+private) can reach db:3306 docker compose --env-file .env exec nginx sh -c 'nc -zv db 3306 2>&1' # confirm db is only on the internal:true private network docker network inspect open-source-deployment-lab_private \ --format='{{range .Containers}}{{.Name}}{{"\n"}}{{end}}'internal: true is what prevents the private network from routing to the host's network stack — not just the absence of a published port. Nginx sits on both networks because it must reach WordPress (private) while accepting host traffic (public).Success criteria
- `db:3306` is reachable from Nginx on the private network.
- No port 3306 is published to the host (empty port bindings for db).
- The private network shows `internal: true`.
- 04
Backup & restore drill
Create a backup, destroy the database, restore, and verify integrity.
bash# 1. write a baseline marker row docker compose --env-file .env exec db \ mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" "$WORDPRESS_DB_NAME" \ -e "INSERT INTO wp_options (option_name, option_value, autoload) \ VALUES ('assessment_marker','project01-test','yes');" # 2. take the backup (compressed SQL dump in backups/) make backup-db && ls -lh backups/ # 3. destroy and recreate the database CONFIRM_CLEAN=yes make clean && make up # wait for healthy # 4. restore the newest backup CONFIRM_RESTORE=yes make restore-db # 5. verify the marker survived docker compose --env-file .env exec db \ mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" "$WORDPRESS_DB_NAME" \ -e "SELECT option_value FROM wp_options WHERE option_name='assessment_marker';"The backup uses --single-transaction so InnoDB is captured as a consistent snapshot without locking tables. The restore script uses root to DROP/CREATE the database while the backup uses the app user — the correct least-privilege split.
Success criteria
- `assessment_marker` is present after restore with value `project01-test`.
- Restore completes with no error output.
- The backup file is gzip-compressed and non-zero bytes.
- 05
Database failure recovery
Simulate a hard database kill and confirm automatic recovery.
bash# terminal 1 — watch logs make logs # terminal 2 — trigger the failure (SIGKILL) make simulate-db-failurerestart: unless-stopped brings MariaDB back; its health check uses innodb_initialized so it only reports healthy once InnoDB crash recovery (redo-log replay) has finished. WordPress reconnects on its own — no restart needed — though it may briefly return 500s during the recovery window.
Success criteria
- MariaDB returns to `healthy` with no manual intervention.
- WordPress reconnects automatically after MariaDB recovers.
- You can describe the InnoDB crash-recovery sequence from the logs.
- 06
Volume permission incident
Break the uploads directory permissions, diagnose from first principles, and recover.
bash# introduce the incident (applies chmod 000 to uploads) make simulate-volume-issue # diagnose BEFORE reading the script — start here and work outward docker compose --env-file .env exec wordpress \ ls -la /var/www/html/wp-content/ # recover make restore-volume-issuechmod 000 removes read/write/execute for user, group, and other — so even the owning user cannot write. Build the habit of diagnosing the affected path and exact permission before reaching for a fix.Success criteria
- You identify the chmod 000 on uploads before reading the simulation script.
- You name the affected path and the permission blocking writes.
- `make restore-volume-issue` restores writability.
- 07
Secret exposure audit
Enumerate every place credentials are visible and judge what is acceptable.
bash# env visible via docker inspect docker inspect open-source-deployment-lab-db-1 \ --format='{{range .Config.Env}}{{println .}}{{end}}' # compose config expansion docker compose --env-file .env config | grep -i password # image layers (no custom image is built here) docker history wordpress:php8.3-fpm-alpine --no-trunc | head -20 # what is committed git status && git log --oneline -5MARIADB_PASSWORD / MARIADB_ROOT_PASSWORD are visible in docker inspect — acceptable only because reading it requires host Docker access. .env is uncommitted (correct), .env.example is committed (correct), and no secrets are baked into image layers. Moving to Docker secrets would remove the credentials from docker inspect output.
Success criteria
- You identify the DB passwords visible in `docker inspect`.
- You confirm `.env` is uncommitted and `.env.example` is committed.
- You confirm no secrets are baked into the image layers.
- 08
Minor version upgrade
Upgrade MariaDB 11.4 → 11.6 with zero data loss.
bash# record current version docker compose --env-file .env exec db mariadb --version # edit .env: MARIADB_IMAGE=mariadb:11.4 -> mariadb:11.6 docker compose --env-file .env pull db # pull without stopping make backup-db # always back up first docker compose --env-file .env up -d db # recreate on the new image # verify docker compose --env-file .env exec db mariadb --version docker compose --env-file .env psMARIADB_AUTO_UPGRADE: "1" runs mariadb-upgrade automatically when a version change is detected, keeping system tables consistent. The named-volume strategy — not the compose file — is what makes this non-destructive: the data directory persists across container recreation.Success criteria
- The `mariadb --version` string changes after the upgrade.
- All services return to `healthy`.
- Data written before the upgrade is still present afterward.
Deliverables
- Stack reaches a fully healthy state from a cold start (Step 2).
- MariaDB is unreachable from outside the private network (Step 3).
- Backup produces a valid, restorable artifact (Step 4).
- Database crash recovery is automatic and complete (Step 5).
- Volume permission incident is diagnosable and recoverable (Step 6).
- Secret-exposure surface is enumerated and understood (Step 7).
- Minor version upgrade completes with zero data loss (Step 8).
Project details
- Path
- Docker
- Level
- Level 1 · Foundations
- Difficulty
- Intermediate
- Duration
- 6–8 hours
- Position
- 1 of 10
- Progress
- 10%