feat: use host bind mounts for persistent data

This commit is contained in:
bisco
2026-06-25 10:12:41 +02:00
parent c66dd7e511
commit 0a590989bb
12 changed files with 232 additions and 42 deletions
+15
View File
@@ -22,3 +22,18 @@ LETSENCRYPT_STAGING=1
LETSENCRYPT_RENEW_INTERVAL_SECONDS=43200 LETSENCRYPT_RENEW_INTERVAL_SECONDS=43200
LETSENCRYPT_RETRY_SECONDS=300 LETSENCRYPT_RETRY_SECONDS=300
TLS_RELOAD_INTERVAL_SECONDS=30 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
+1
View File
@@ -3,5 +3,6 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
backups/ backups/
runtime/
tests/functional/test-results/ tests/functional/test-results/
tests/functional/playwright-report/ tests/functional/playwright-report/
+21 -5
View File
@@ -31,6 +31,7 @@ No host PHP, database, Node.js, or WordPress installation is required.
```bash ```bash
cp .env.example .env cp .env.example .env
./scripts/prepare-host-volumes.sh
docker compose up --build -d docker compose up --build -d
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
docker compose ps docker compose ps
@@ -44,6 +45,20 @@ Open:
Use the development credentials copied into `.env` only locally. Change them before Use the development credentials copied into `.env` only locally. Change them before
sharing the environment. 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 ## Edit content
- **Appearance > Customize**: hero, manifesto, laboratory, teacher, lessons, contacts, - **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: Back up the database and WordPress files at the same logical point in time:
```bash ```bash
mkdir -p backups/wordpress-files
docker compose stop wordpress 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 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 docker compose start wordpress
``` ```
@@ -121,6 +136,7 @@ Do not commit backups or `.env`. Encrypt and test real backups off-host. See
## Rollback ## Rollback
Revert the application commit and rebuild containers. Preserve `db_data`, Revert the application commit and rebuild containers. Preserve the host directories
`wordpress_data`, and certificate volumes. Database restoration is a separate, configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and
destructive operation and requires a verified backup. `CERTBOT_CHALLENGES_PATH`. Database restoration is a separate, destructive operation
and requires a verified backup.
+37 -13
View File
@@ -12,7 +12,11 @@ services:
entrypoint: ["/bin/sh", "/usr/local/bin/check-environment.sh"] entrypoint: ["/bin/sh", "/usr/local/bin/check-environment.sh"]
command: ["mariadbd"] command: ["mariadbd"]
volumes: 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 - ./db/check-environment.sh:/usr/local/bin/check-environment.sh:ro
networks: networks:
- data - data
@@ -55,7 +59,11 @@ services:
define('DISALLOW_FILE_MODS', true); define('DISALLOW_FILE_MODS', true);
} }
volumes: volumes:
- wordpress_data:/var/www/html - type: bind
source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress}
target: /var/www/html
bind:
create_host_path: true
networks: networks:
- web - web
- data - data
@@ -85,8 +93,18 @@ services:
TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30} TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30}
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-0} TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-0}
volumes: volumes:
- letsencrypt_data:/etc/letsencrypt:ro - type: bind
- certbot_challenges:/var/www/certbot:ro 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: networks:
- web - web
ports: ports:
@@ -119,8 +137,16 @@ services:
entrypoint: ["/bin/sh", "/opt/certbot/renew.sh"] entrypoint: ["/bin/sh", "/opt/certbot/renew.sh"]
volumes: volumes:
- ./certbot/renew.sh:/opt/certbot/renew.sh:ro - ./certbot/renew.sh:/opt/certbot/renew.sh:ro
- letsencrypt_data:/etc/letsencrypt - type: bind
- certbot_challenges:/var/www/certbot 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: tmpfs:
- /tmp - /tmp
- /var/lib/letsencrypt - /var/lib/letsencrypt
@@ -159,7 +185,11 @@ services:
entrypoint: ["/bin/sh"] entrypoint: ["/bin/sh"]
command: ["/scripts/bootstrap.sh"] command: ["/scripts/bootstrap.sh"]
volumes: 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 - ./wp-cli/bootstrap.sh:/scripts/bootstrap.sh:ro
networks: networks:
- data - data
@@ -175,12 +205,6 @@ services:
- ALL - ALL
pids_limit: 100 pids_limit: 100
volumes:
db_data:
wordpress_data:
letsencrypt_data:
certbot_challenges:
networks: networks:
web: web:
data: data:
+7 -5
View File
@@ -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. 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; 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 Certbot provides opt-in direct TLS. Docker networks isolate the database. Runtime state
tests use separate volumes. uses host-based bind mounts with a preparation script for ownership/mode, while
functional tests use separate Docker volumes.
## Consequences ## Consequences
@@ -48,11 +49,12 @@ headers, and fail-closed TLS. Admin MFA and allowlisting remain external control
## Operational impact ## Operational impact
Database and WordPress files are coordinated state and must be backed up together. 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 Operators must monitor upstream security releases and rebuild pinned images. SMTP and
off-host storage are not part of this minimal base. off-host storage are not part of this minimal base.
## Rollback ## Rollback
Revert the implementation commit and rebuild. Preserve WordPress, database, and Revert the implementation commit and rebuild. Preserve the configured WordPress,
certificate volumes unless data deletion is intentional. Restore data only from a database, and certificate host directories unless data deletion is intentional. Restore
verified coordinated backup. data only from a verified coordinated backup.
+4 -3
View File
@@ -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 another optional service, enabled only for direct deployments. It shares challenge and
certificate volumes with NGINX but has no container-control access. certificate volumes with NGINX but has no container-control access.
Persistent state lives in `db_data`, `wordpress_data`, and the certificate volumes. Persistent state lives in host-based bind mounts configured by `DB_DATA_PATH`,
Functional tests replace the first two with isolated test volumes and reach NGINX via `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and `CERTBOT_CHALLENGES_PATH`.
an internal `azionelab.org` network alias. 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). See [ADR-0001](adr/0001-wordpress-single-page.md).
+18 -4
View File
@@ -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 NGINX binds to loopback ports 8080/8443. WordPress and MariaDB remain private. The
bootstrap is safe to rerun and does not duplicate demo records. 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 ## Production
Required controls: Required controls:
@@ -35,7 +48,8 @@ behavior, removing only the staging certificate volume when necessary.
## State and rollback ## State and rollback
Database and WordPress file volumes must be backed up together. Code rollback is a 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 volumes. Never use container rebuild from a prior commit and does not require deleting host data
`docker compose down --volumes` where content must survive. Test database restoration directories. Never delete the paths configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`,
in a disposable environment before any production restore. or `LETSENCRYPT_DATA_PATH` where content/certificates must survive. Test database
restoration in a disposable environment before any production restore.
+10 -4
View File
@@ -6,6 +6,7 @@
docker compose up --build -d docker compose up --build -d
docker compose ps docker compose ps
docker compose logs -f proxy wordpress db certbot 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 --profile tools run --rm wp-cli -c 'wp core version'
docker compose down docker compose down
``` ```
@@ -24,9 +25,11 @@ image rebuilds are the update path.
## Backup and restore ## Backup and restore
Create database and `wordpress_data` backups in one maintenance window. Backups contain Create database and WordPress file backups in one maintenance window. The default host
credentials, accounts, contact information, and uploaded media; encrypt them, restrict paths are `./runtime/db`, `./runtime/wordpress`, `./runtime/letsencrypt`, and
access, set retention, and store copies off-host. `./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 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, 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 ## 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. - 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. - Admin MFA and network allowlisting are deployment concerns and are not bundled.
- WordPress plugins expand the attack surface; install only reviewed, maintained, - WordPress plugins expand the attack surface; install only reviewed, maintained,
+11 -3
View File
@@ -14,10 +14,17 @@
## Images or theme are missing ## 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'`. 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. 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 ## Certificate issuance fails
Verify public DNS, inbound port 80, the operator email, and staging mode. Request a 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 ## Rollback
Revert the deployment commit and rebuild while preserving all named volumes. Restore Revert the deployment commit and rebuild while preserving all host data directories.
database/files only for a data rollback and only from a verified coordinated backup. Restore database/files only for a data rollback and only from a verified coordinated
backup.
+3
View File
@@ -21,6 +21,9 @@
- Containers are not privileged and do not use host networking or the Docker socket. - 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, WordPress/Apache retains the capabilities needed by the official image internally,
but no WordPress port is published. 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 - 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 healthcheck noise; NGINX remains the single request log and PHP warnings/errors stay
visible. visible.
+6 -5
View File
@@ -14,11 +14,12 @@ docker compose config --quiet
LETSENCRYPT_ENABLED=1 docker compose config --quiet LETSENCRYPT_ENABLED=1 docker compose config --quiet
``` ```
The override replaces normal database and WordPress volumes, maps `azionelab.org` to The normal stack uses host-based bind mounts under `./runtime` by default. The test
the internal proxy, and never publishes an extra port. Browser tests cover content and override replaces database and WordPress state with isolated Docker volumes, maps
section order, contact actions, mobile overflow/navigation, semantic landmarks, image `azionelab.org` to the internal proxy, and never publishes an extra port. Browser tests
alternatives, admin routing, security headers, blocked sensitive routes, and unknown cover content and section order, contact actions, mobile overflow/navigation, semantic
virtual hosts. 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 Subjective visual review and a real-device accessibility audit remain manual release
checks. checks.
+99
View File
@@ -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