diff --git a/.env.example b/.env.example index e0c0a31..6088d03 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,18 @@ LETSENCRYPT_STAGING=1 LETSENCRYPT_RENEW_INTERVAL_SECONDS=43200 LETSENCRYPT_RETRY_SECONDS=300 TLS_RELOAD_INTERVAL_SECONDS=30 + +# Host-based persistent volumes. Relative paths are resolved from the project root. +DB_DATA_PATH=./runtime/db +WORDPRESS_DATA_PATH=./runtime/wordpress +LETSENCRYPT_DATA_PATH=./runtime/letsencrypt +CERTBOT_CHALLENGES_PATH=./runtime/certbot/www + +# Expected container owners for host-based volumes. +# MariaDB's official image normally uses mysql 999:999; WordPress uses www-data 33:33. +MARIADB_VOLUME_UID=999 +MARIADB_VOLUME_GID=999 +WORDPRESS_VOLUME_UID=33 +WORDPRESS_VOLUME_GID=33 +CERTBOT_VOLUME_UID=0 +CERTBOT_VOLUME_GID=0 diff --git a/.gitignore b/.gitignore index bfef6bf..7d4d65d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ __pycache__/ *.py[cod] backups/ +runtime/ tests/functional/test-results/ tests/functional/playwright-report/ diff --git a/README.md b/README.md index 4fe4440..606e3be 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ No host PHP, database, Node.js, or WordPress installation is required. ```bash cp .env.example .env +./scripts/prepare-host-volumes.sh docker compose up --build -d docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh docker compose ps @@ -44,6 +45,20 @@ Open: Use the development credentials copied into `.env` only locally. Change them before sharing the environment. +Persistent data uses host-based bind mounts by default: + +- `./runtime/db` for MariaDB; +- `./runtime/wordpress` for WordPress core, uploads, themes, and mu-plugins copied at + container startup; +- `./runtime/letsencrypt` for certificates; +- `./runtime/certbot/www` for ACME HTTP-01 challenges. + +Run `./scripts/prepare-host-volumes.sh` before the first start, especially on Linux +hosts. The script reads `.env`, creates the directories, and assigns the expected +container owners (`999:999` for MariaDB, `33:33` for WordPress, `0:0` for Certbot). +Override the `*_DATA_PATH` and `*_VOLUME_UID/GID` variables in `.env` if your runtime +uses different host paths or image user IDs. + ## Edit content - **Appearance > Customize**: hero, manifesto, laboratory, teacher, lessons, contacts, @@ -109,10 +124,10 @@ docker compose config --quiet Back up the database and WordPress files at the same logical point in time: ```bash -mkdir -p backups/wordpress-files docker compose stop wordpress +mkdir -p backups docker compose exec -T db sh -c 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE"' > backups/database.sql -docker compose cp wordpress:/var/www/html/. backups/wordpress-files/ +tar -C runtime -czf backups/wordpress-files.tgz wordpress docker compose start wordpress ``` @@ -121,6 +136,7 @@ Do not commit backups or `.env`. Encrypt and test real backups off-host. See ## Rollback -Revert the application commit and rebuild containers. Preserve `db_data`, -`wordpress_data`, and certificate volumes. Database restoration is a separate, -destructive operation and requires a verified backup. +Revert the application commit and rebuild containers. Preserve the host directories +configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and +`CERTBOT_CHALLENGES_PATH`. Database restoration is a separate, destructive operation +and requires a verified backup. diff --git a/docker-compose.yml b/docker-compose.yml index bd7c802..6e5ded2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,11 @@ services: entrypoint: ["/bin/sh", "/usr/local/bin/check-environment.sh"] command: ["mariadbd"] volumes: - - db_data:/var/lib/mysql + - type: bind + source: ${DB_DATA_PATH:-./runtime/db} + target: /var/lib/mysql + bind: + create_host_path: true - ./db/check-environment.sh:/usr/local/bin/check-environment.sh:ro networks: - data @@ -55,7 +59,11 @@ services: define('DISALLOW_FILE_MODS', true); } volumes: - - wordpress_data:/var/www/html + - type: bind + source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress} + target: /var/www/html + bind: + create_host_path: true networks: - web - data @@ -85,8 +93,18 @@ services: TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30} TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-0} volumes: - - letsencrypt_data:/etc/letsencrypt:ro - - certbot_challenges:/var/www/certbot:ro + - type: bind + source: ${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt} + target: /etc/letsencrypt + read_only: true + bind: + create_host_path: true + - type: bind + source: ${CERTBOT_CHALLENGES_PATH:-./runtime/certbot/www} + target: /var/www/certbot + read_only: true + bind: + create_host_path: true networks: - web ports: @@ -119,8 +137,16 @@ services: entrypoint: ["/bin/sh", "/opt/certbot/renew.sh"] volumes: - ./certbot/renew.sh:/opt/certbot/renew.sh:ro - - letsencrypt_data:/etc/letsencrypt - - certbot_challenges:/var/www/certbot + - type: bind + source: ${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt} + target: /etc/letsencrypt + bind: + create_host_path: true + - type: bind + source: ${CERTBOT_CHALLENGES_PATH:-./runtime/certbot/www} + target: /var/www/certbot + bind: + create_host_path: true tmpfs: - /tmp - /var/lib/letsencrypt @@ -159,7 +185,11 @@ services: entrypoint: ["/bin/sh"] command: ["/scripts/bootstrap.sh"] volumes: - - wordpress_data:/var/www/html + - type: bind + source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress} + target: /var/www/html + bind: + create_host_path: true - ./wp-cli/bootstrap.sh:/scripts/bootstrap.sh:ro networks: - data @@ -175,12 +205,6 @@ services: - ALL pids_limit: 100 -volumes: - db_data: - wordpress_data: - letsencrypt_data: - certbot_challenges: - networks: web: data: diff --git a/docs/adr/0001-wordpress-single-page.md b/docs/adr/0001-wordpress-single-page.md index 10c0804..c4e29d0 100644 --- a/docs/adr/0001-wordpress-single-page.md +++ b/docs/adr/0001-wordpress-single-page.md @@ -18,8 +18,9 @@ sanitized theme modifications, while a must-use plugin owns Shows and Gallery cu post types so structured content is not lost when changing themes. NGINX is the only public entry point. WP-CLI provides an idempotent opt-in bootstrap; -Certbot provides opt-in direct TLS. Docker networks isolate the database and functional -tests use separate volumes. +Certbot provides opt-in direct TLS. Docker networks isolate the database. Runtime state +uses host-based bind mounts with a preparation script for ownership/mode, while +functional tests use separate Docker volumes. ## Consequences @@ -48,11 +49,12 @@ headers, and fail-closed TLS. Admin MFA and allowlisting remain external control ## Operational impact Database and WordPress files are coordinated state and must be backed up together. +Host bind-mount paths must be permissioned for the image users before startup. Operators must monitor upstream security releases and rebuild pinned images. SMTP and off-host storage are not part of this minimal base. ## Rollback -Revert the implementation commit and rebuild. Preserve WordPress, database, and -certificate volumes unless data deletion is intentional. Restore data only from a -verified coordinated backup. +Revert the implementation commit and rebuild. Preserve the configured WordPress, +database, and certificate host directories unless data deletion is intentional. Restore +data only from a verified coordinated backup. diff --git a/docs/architecture.md b/docs/architecture.md index 6012b38..b2e7c0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,8 +16,9 @@ activates the theme, configures the site, and creates realistic demo content. Ce another optional service, enabled only for direct deployments. It shares challenge and certificate volumes with NGINX but has no container-control access. -Persistent state lives in `db_data`, `wordpress_data`, and the certificate volumes. -Functional tests replace the first two with isolated test volumes and reach NGINX via -an internal `azionelab.org` network alias. +Persistent state lives in host-based bind mounts configured by `DB_DATA_PATH`, +`WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and `CERTBOT_CHALLENGES_PATH`. +Functional tests replace the database and WordPress mounts with isolated Docker test +volumes and reach NGINX via an internal `azionelab.org` network alias. See [ADR-0001](adr/0001-wordpress-single-page.md). diff --git a/docs/deployment.md b/docs/deployment.md index 4cfada1..c3f1d0b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -12,6 +12,19 @@ docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh NGINX binds to loopback ports 8080/8443. WordPress and MariaDB remain private. The bootstrap is safe to rerun and does not duplicate demo records. +The default persistent paths are host-based bind mounts under `./runtime`. Before the +first start, run: + +```bash +./scripts/prepare-host-volumes.sh +``` + +The script reads `.env`, creates the configured paths, and applies the expected +container ownership. For Linux hosts this avoids the common problem where Docker +creates missing bind-mount directories as `root:root` and WordPress or MariaDB later +cannot write to them. If a platform uses different image UIDs/GIDs, override +`MARIADB_VOLUME_UID/GID`, `WORDPRESS_VOLUME_UID/GID`, or `CERTBOT_VOLUME_UID/GID`. + ## Production Required controls: @@ -35,7 +48,8 @@ behavior, removing only the staging certificate volume when necessary. ## State and rollback -Database and WordPress file volumes must be backed up together. Code rollback is a -container rebuild from a prior commit and does not require deleting volumes. Never use -`docker compose down --volumes` where content must survive. Test database restoration -in a disposable environment before any production restore. +Database and WordPress file directories must be backed up together. Code rollback is a +container rebuild from a prior commit and does not require deleting host data +directories. Never delete the paths configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, +or `LETSENCRYPT_DATA_PATH` where content/certificates must survive. Test database +restoration in a disposable environment before any production restore. diff --git a/docs/operations.md b/docs/operations.md index a2d21e3..5fc6430 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -6,6 +6,7 @@ docker compose up --build -d docker compose ps docker compose logs -f proxy wordpress db certbot +./scripts/prepare-host-volumes.sh docker compose --profile tools run --rm wp-cli -c 'wp core version' docker compose down ``` @@ -24,9 +25,11 @@ image rebuilds are the update path. ## Backup and restore -Create database and `wordpress_data` backups in one maintenance window. Backups contain -credentials, accounts, contact information, and uploaded media; encrypt them, restrict -access, set retention, and store copies off-host. +Create database and WordPress file backups in one maintenance window. The default host +paths are `./runtime/db`, `./runtime/wordpress`, `./runtime/letsencrypt`, and +`./runtime/certbot/www`, unless overridden in `.env`. Backups contain credentials, +accounts, contact information, and uploaded media; encrypt them, restrict access, set +retention, and store copies off-host. A restore is destructive. Validate it on isolated volumes, then stop WordPress, restore the database and file volume together, restart, and verify the homepage, media, @@ -34,7 +37,10 @@ the database and file volume together, restart, and verify the homepage, media, ## Known risks -- Local named volumes are not backups. +- Local host directories are not backups. +- A host-based volume may be unreadable by the application if created with the wrong + owner or mode; run `./scripts/prepare-host-volumes.sh` after changing paths or image + user IDs. - SMTP is not configured; WordPress password-reset email needs an external mail service. - Admin MFA and network allowlisting are deployment concerns and are not bundled. - WordPress plugins expand the attack surface; install only reviewed, maintained, diff --git a/docs/runbook.md b/docs/runbook.md index 7ee869b..958c96b 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -14,10 +14,17 @@ ## Images or theme are missing -1. Confirm `wordpress_data` is mounted in both WordPress and WP-CLI. +1. Confirm `WORDPRESS_DATA_PATH` is mounted in both WordPress and WP-CLI. 2. Run `docker compose --profile tools run --rm wp-cli -c 'wp theme status azionelab'`. 3. Verify file ownership before changing permissions; never make the tree world-writable. +## A service cannot write to its volume + +1. Stop the affected service. +2. Confirm the relevant `.env` path points to the intended host directory. +3. Run `./scripts/prepare-host-volumes.sh`. +4. Start the service and inspect logs without printing secret values. + ## Certificate issuance fails Verify public DNS, inbound port 80, the operator email, and staging mode. Request a @@ -26,5 +33,6 @@ Avoid repeated production-CA retries while debugging. ## Rollback -Revert the deployment commit and rebuild while preserving all named volumes. Restore -database/files only for a data rollback and only from a verified coordinated backup. +Revert the deployment commit and rebuild while preserving all host data directories. +Restore database/files only for a data rollback and only from a verified coordinated +backup. diff --git a/docs/security.md b/docs/security.md index 1a82302..3e88a2b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -21,6 +21,9 @@ - Containers are not privileged and do not use host networking or the Docker socket. WordPress/Apache retains the capabilities needed by the official image internally, but no WordPress port is published. +- Persistent state uses host-based bind mounts. Keep those paths outside the public web + root, restrict host access, never make them world-writable, and run + `./scripts/prepare-host-volumes.sh` when paths or image user IDs change. - WordPress/Apache access logs are disabled to avoid duplicate client metadata and healthcheck noise; NGINX remains the single request log and PHP warnings/errors stay visible. diff --git a/docs/testing.md b/docs/testing.md index dbbbd18..6709686 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,11 +14,12 @@ docker compose config --quiet LETSENCRYPT_ENABLED=1 docker compose config --quiet ``` -The override replaces normal database and WordPress volumes, maps `azionelab.org` to -the internal proxy, and never publishes an extra port. Browser tests cover content and -section order, contact actions, mobile overflow/navigation, semantic landmarks, image -alternatives, admin routing, security headers, blocked sensitive routes, and unknown -virtual hosts. +The normal stack uses host-based bind mounts under `./runtime` by default. The test +override replaces database and WordPress state with isolated Docker volumes, maps +`azionelab.org` to the internal proxy, and never publishes an extra port. Browser tests +cover content and section order, contact actions, mobile overflow/navigation, semantic +landmarks, image alternatives, admin routing, security headers, blocked sensitive +routes, and unknown virtual hosts. Subjective visual review and a real-device accessibility audit remain manual release checks. diff --git a/scripts/prepare-host-volumes.sh b/scripts/prepare-host-volumes.sh new file mode 100755 index 0000000..fef45c9 --- /dev/null +++ b/scripts/prepare-host-volumes.sh @@ -0,0 +1,99 @@ +#!/bin/sh +set -eu + +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}" + +DB_DATA_PATH="${DB_DATA_PATH:-./runtime/db}" +WORDPRESS_DATA_PATH="${WORDPRESS_DATA_PATH:-./runtime/wordpress}" +LETSENCRYPT_DATA_PATH="${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt}" +CERTBOT_CHALLENGES_PATH="${CERTBOT_CHALLENGES_PATH:-./runtime/certbot/www}" + +MARIADB_VOLUME_UID="${MARIADB_VOLUME_UID:-999}" +MARIADB_VOLUME_GID="${MARIADB_VOLUME_GID:-999}" +WORDPRESS_VOLUME_UID="${WORDPRESS_VOLUME_UID:-33}" +WORDPRESS_VOLUME_GID="${WORDPRESS_VOLUME_GID:-33}" +CERTBOT_VOLUME_UID="${CERTBOT_VOLUME_UID:-0}" +CERTBOT_VOLUME_GID="${CERTBOT_VOLUME_GID:-0}" + +load_env_file() { + [ -f "$ENV_FILE" ] || return 0 + + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "" | \#*) continue ;; + esac + + key="${line%%=*}" + value="${line#*=}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + + case "$key" in + DB_DATA_PATH) DB_DATA_PATH="$value" ;; + WORDPRESS_DATA_PATH) WORDPRESS_DATA_PATH="$value" ;; + LETSENCRYPT_DATA_PATH) LETSENCRYPT_DATA_PATH="$value" ;; + CERTBOT_CHALLENGES_PATH) CERTBOT_CHALLENGES_PATH="$value" ;; + MARIADB_VOLUME_UID) MARIADB_VOLUME_UID="$value" ;; + MARIADB_VOLUME_GID) MARIADB_VOLUME_GID="$value" ;; + WORDPRESS_VOLUME_UID) WORDPRESS_VOLUME_UID="$value" ;; + WORDPRESS_VOLUME_GID) WORDPRESS_VOLUME_GID="$value" ;; + CERTBOT_VOLUME_UID) CERTBOT_VOLUME_UID="$value" ;; + CERTBOT_VOLUME_GID) CERTBOT_VOLUME_GID="$value" ;; + esac + done < "$ENV_FILE" +} + +resolve_path() { + case "$1" in + /*) printf '%s\n' "$1" ;; + *) printf '%s/%s\n' "$ROOT_DIR" "$1" ;; + esac +} + +apply_ownership() { + path="$1" + uid="$2" + gid="$3" + + if chown -R "$uid:$gid" "$path" 2>/dev/null; then + return 0 + fi + + if [ "$(id -u)" -eq 0 ]; then + echo "WARN: cannot chown $path to $uid:$gid." >&2 + return 0 + fi + + if command -v sudo >/dev/null 2>&1; then + if sudo -n chown -R "$uid:$gid" "$path" 2>/dev/null; then + return 0 + fi + echo "WARN: sudo cannot chown $path to $uid:$gid without a password." >&2 + echo "WARN: run this script with sudo if the service cannot write this path." >&2 + return 0 + fi + + echo "WARN: cannot chown $path to $uid:$gid; rerun as root or install sudo." >&2 +} + +prepare_dir() { + path="$1" + uid="$2" + gid="$3" + mode="$4" + + mkdir -p "$path" + apply_ownership "$path" "$uid" "$gid" + chmod "$mode" "$path" + echo "Prepared $path as $uid:$gid mode $mode" +} + +load_env_file + +prepare_dir "$(resolve_path "$DB_DATA_PATH")" "$MARIADB_VOLUME_UID" "$MARIADB_VOLUME_GID" 750 +prepare_dir "$(resolve_path "$WORDPRESS_DATA_PATH")" "$WORDPRESS_VOLUME_UID" "$WORDPRESS_VOLUME_GID" 755 +prepare_dir "$(resolve_path "$LETSENCRYPT_DATA_PATH")" "$CERTBOT_VOLUME_UID" "$CERTBOT_VOLUME_GID" 750 +prepare_dir "$(resolve_path "$CERTBOT_CHALLENGES_PATH")" "$CERTBOT_VOLUME_UID" "$CERTBOT_VOLUME_GID" 755