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_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
+1
View File
@@ -3,5 +3,6 @@
__pycache__/
*.py[cod]
backups/
runtime/
tests/functional/test-results/
tests/functional/playwright-report/
+21 -5
View File
@@ -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.
+37 -13
View File
@@ -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:
+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.
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.
+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
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).
+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
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.
+10 -4
View File
@@ -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,
+11 -3
View File
@@ -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.
+3
View File
@@ -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.
+6 -5
View File
@@ -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.
+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