Kube-Ops Projects
01Intermediate

Open Source Deployment Lab

Operate a production-style WordPress stack: private networking, persistence, backups, health checks, and failure drills.

Expert-Designed ProjectIndustry-ValidatedProduction-Ready
DifficultyIntermediateDuration6–8 hours
NginxWordPressMariaDBDocker ComposeBash

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

HTTP :8080

Nginx

reverse proxy

FastCGI :9000

WordPress

PHP-FPM application runtime

TCP db:3306

MariaDB

private relational database

HostPublic networkPrivate network · internal

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.
NetworkAttached servicesPurpose
publicNginxHost-facing ingress network
private (internal: true)Nginx, WordPress, MariaDBInternal app + database traffic; no route to the host
VolumeMounted atPurpose
wordpress-data/var/www/htmlWordPress files, plugins, themes, uploads
db-data/var/lib/mysqlMariaDB persistent data directory
ServiceHealth check
NginxGET /healthz from inside the container
WordPressPHP can open the local PHP-FPM listener (port 9000)
MariaDBhealthcheck.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

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/
    .gitkeep

Step-by-Step Guide

  1. 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 up

    Open 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.
  2. 02

    Cold start & health verification

    Start from a clean state and verify every service reaches healthy.

    bash
    CONFIRM_CLEAN=yes make clean
    make up
    make ps

    The 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.
  3. 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`.
  4. 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.
  5. 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-failure

    restart: 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.
  6. 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-issue
    chmod 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.
  7. 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 -5

    MARIADB_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.
  8. 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 ps
    MARIADB_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).
make clean is destructive — it removes containers and named volumes. It refuses to run unless you pass CONFIRM_CLEAN=yes make clean.
Only .env.example is committed; real values live in .env, which you create locally. For a real deployment, replace .env with your platform's secret manager or Docker secrets.

Project details

Path
Docker
Level
Level 1 · Foundations
Difficulty
Intermediate
Duration
6–8 hours
Position
1 of 10
Progress
10%