🛡️ Service Operations

WRDP is an isolated WordPress installation whose only job is to let plugins be tried out safely. It runs as two long-lived Docker services, WordPress and MariaDB, plus a one-off WP-CLI runner, and it reaches the internet only through an nginx reverse proxy sitting in front of a loopback-bound port. Nothing on it is production data, so wiping it and rebuilding is an ordinary documented operation rather than an emergency. This page is the whole operator manual: getting in, starting and stopping, finding logs, backing up and proving the backup restores, checking health, dropping a plugin in, and the rules the front door enforces.

Vocabulary and mental model: 🧭 QuizWizz orientation. Current bench state: 📍 Now. Open operator decisions: open-work ledger.

Access and credentials

  • location: /home/loca/dev/wrdp
  • access:
  • privilege: docker requires sudo on this host (user not in docker group), matching dev/services/*; every command on this page is written that way

Docker topology

flowchart LR
  browser["browser"] -->|"https 443"| nginx["nginx vhost wrdp.loca.zone"]
  nginx -->|"127.0.0.1:51080"| wordpress["wordpress:latest"]
  wordpress -->|"db:3306 on wrdp_net"| db["mariadb:11.4, no host port"]
  wpcli["wpcli, one-off, compose profile cli"] --> db
  wordpress -->|"mounts"| mounts["./wp and ./logs"]
  db -->|"mounts"| dbdata["./db_data"]
  • stack_services:
    • wordpress: wordpress:latest, bound 127.0.0.1:51080:80
    • db: mariadb:11.4, internal wrdp_net only (no host port)
    • wpcli: one-off wordpress:cli runner (compose profile cli)
  • persistence:
    • core + wp-content: ./wp/ bind mount
    • database: ./db_data/ bind mount
    • secrets: .env (gitignored)
    • debug log: ./logs/ bind mount -> /var/log/wp (outside the document root)
  • isolation_guarantees:
    • throwaway db + core; the reset under Lifecycle commands discards both
    • db not exposed to host; app reachable only through nginx + loopback
  • container_image_maintenance:
    • current definitions: moving wordpress:latest and wordpress:cli tags plus mariadb:11.4; no digest pinning or automatic updater is configured
    • pending owner input, every row of IMG-01: review cadence; maintenance window; approver / operator; digest-pinning policy; pre-update backup requirement; rollback image digests and maximum rollback age
    • acceptance gate: record pre/post digests, pass scripts/wrdp-health.sh, exercise the public and admin smoke paths, and retain the rollback digests before declaring an image update complete

Lifecycle commands

  • start: sudo docker compose up -d
  • stop: sudo docker compose down (keeps data)
  • reset: sudo docker compose down -v && sudo rm -rf wp db_data

The reset is destructive

down -v plus rm -rf wp db_data deletes the database and the entire WordPress tree, including anything staged into the bind mount. Use it only when discarding the bench is intentional, and take a backup first.

Logging

  • debug_log:
    • live host path: logs/debug.log
    • container path: /var/log/wp/debug.log, reached by the ./logs/ -> /var/log/wp bind mount, outside the WordPress document root
    • ownership: host directory and PHP-created log are owned by uid/gid 33:33 (www-data:www-data in the container)
    • retained evidence: the pre-move in-docroot log was retained as logs/debug-archive-<UTC stamp>.log
    • follow command: sudo tail -f logs/debug.log
    • posture: WP_DEBUG and WP_DEBUG_LOG stay enabled for bench development with display off; only the public file path changed
  • container_logs:
    • follow command: sudo docker compose logs -f wordpress
  • proxy_log_policy:
    • files: /var/log/nginx/access.log and /var/log/nginx/error.log
    • implemented retention: /etc/logrotate.d/nginx rotates daily, keeps 14 rotations, compresses old files, and delays compression for the newest rotation
    • pending owner input, every row of OPS-LOG-01: review cadence; review owner; escalation destination; data-minimization review

Backup and restore drill

  • backup:
    • command: sudo -n scripts/wrdp-backup.sh [--dest DIR] [--keep N]
    • destination: /home/loca/backups/wrdp/<UTC stamp>/
    • contents: db.sql.gz, content.tar.gz, nginx.tar.gz, meta.txt, manifest.sha256
    • coverage: database dump; wp/wp-content; wp/wp-config.php; docker-compose.yml; uploads.ini; .env; scripts; q5vault; nginx files sites-available/wrdp.loca.zone, snippets/wrdp-proxy.conf, snippets/security-headers-app.conf, and conf.d/wrdp_limits.conf
    • retention: default --keep 7; any directory placed manually under the destination is subject to the same prune
    • permissions: backup directories are root-owned mode 0700 because archives include .env and the database
    • WordPress core: excluded from content.tar.gz; meta.txt records the WordPress and MariaDB image digests so core can be restored to the same image lineage
    • timer: wrdp-backup.timer runs nightly at 03:30 in the user systemd scope through sudo -n
    • q5vault copy: this tree has no .git, so these backups are the only copy of q5vault
  • off_host_backup_plan:
    • status: not configured; on-host backups remain the only operated copy
    • pending owner input, every row of OPS-BACKUP-01: destination URI / service; credential source; transport and at-rest encryption; remote retention and immutability policy; transfer schedule; restore owner and contact; last restore proof
    • acceptance gate: no off-host readiness claim until a copied backup passes manifest verification and a scratch restore drill
  • restore_drill:
    • command: sudo -n scripts/wrdp-restore-drill.sh [backup-dir]
    • default target: newest backup directory under /home/loca/backups/wrdp
    • integrity: verifies manifest.sha256 before extraction or import
    • content proof: extracts content.tar.gz to a temporary directory and asserts recovered paths for wp/wp-config.php, wp/wp-content/plugins/quizwizz/quizwizz.php, docker-compose.yml, uploads.ini, .env, and q5vault/index.md
    • database proof: replays db.sql.gz into scratch database wrdp_drill
    • assertions: restored table count matches snapshot metadata snapshot_table_count; restored QuizWizz table count matches snapshot metadata snapshot_quizwizz_table_count; restored qw_quiz post count matches snapshot metadata snapshot_quiz_count; restored siteurl matches snapshot metadata snapshot_siteurl
    • diagnostics: current live table count, live QuizWizz table count, live quiz count, and live siteurl are logged for comparison only; they are not assertion targets
    • safety: only writes the scratch wrdp_drill database and a temporary extraction directory; the exit trap always drops wrdp_drill

Health probe

  • health:
    • command: scripts/wrdp-health.sh
    • timer: wrdp-health.timer runs hourly in the user systemd scope
    • history: journalctl --user -u wrdp-health.service
    • alerting: no external alert channel is configured; failures are recorded by systemd and the journal only
    • pending owner input, every row of OPS-ALERT-01: alert destination; alert transport / endpoint; credential source; severity routing and quiet hours; test recipient and last delivery proof
    • acceptance gate: no alerting readiness claim until a forced non-production failure produces a received notification
    • checks:
      • app home returns HTTP 200
      • https://wrdp.loca.zone/wp-content/debug.log returns 404
      • https://wrdp.loca.zone/.env returns 404
      • security headers include Strict-Transport-Security
      • Service State Health requires docker compose ps --format '{{.Service}} {{.State}} {{.Health}}' to report both db running healthy and wordpress running healthy, with WordPress HTTP healthcheck covered by the homepage 200 check
      • retention cron hooks qwizz_cleanup_security_tables, qwizz_cleanup_ephemeral, and qwizz_cleanup_private_files are scheduled and not overdue by more than 3600 seconds
      • root filesystem usage stays below 90 percent
  • cron_posture:
    • scheduler: WP-Cron stays traffic-driven; no replacement scheduler or DISABLE_WP_CRON change is introduced
    • retention hooks:
      • qwizz_cleanup_security_tables: daily
      • qwizz_cleanup_ephemeral: hourly
      • qwizz_cleanup_private_files: hourly
    • monitoring: the hourly health probe watches for missing or overdue retention events instead of replacing WP-Cron

Dropping in a plugin for testing

  • drop_in_workflow:
    • copy plugin into bind mount: sudo cp -r /path/to/my-plugin wp/wp-content/plugins/
    • activate: sudo -n docker compose --profile cli run --rm wpcli wp plugin activate my-plugin
    • QuizWizz live-file edits: stage the complete replacement under /tmp/qwfix/; install atomically with sudo -n install -o www-data -g www-data -m 0644 /tmp/qwfix/<file> wp/wp-content/plugins/quizwizz/<file>; never edit the bind mount in place
    • PHP lint: sudo -n docker compose --profile cli run --rm --entrypoint php wpcli -l /var/www/html/wp-content/plugins/quizwizz/<file>
  • install_from_registry:
    • sudo -n docker compose --profile cli run --rm wpcli wp plugin install <slug> --activate
  • common_wpcli:
    • list: sudo -n docker compose --profile cli run --rm wpcli wp plugin list
    • deactivate: sudo -n docker compose --profile cli run --rm wpcli wp plugin deactivate my-plugin
    • delete: sudo -n docker compose --profile cli run --rm wpcli wp plugin delete my-plugin
    • status: sudo -n docker compose --profile cli run --rm wpcli wp plugin status my-plugin
  • debugging:
    • admin file editing is disabled via DISALLOW_FILE_EDIT; stage and atomically install the complete replacement instead
    • errors land in logs/debug.log, never in the browser; the follow command is under Logging

Browser surface for admin and QA work

The default surface for bench admin and QA work is a tool-owned Chromium, reached by naming app.path explicitly. It is the only one of the three candidate surfaces that completed the fixed comparison workload on this host on 2026-08-31. This is deliberately not a verdict on ego task spaces versus the OMP browser relay: neither of those produced a step count, so the documented tie-break never engaged. The unresolved half is under Open residue below, and the surface contract for guided tours stays in 🧭 Tandem showcase guide.

Fixed comparison workload

Four named operations, run once per surface against an authenticated administrator session:

  1. open settings — /wp-admin/admin.php?page=qw-settings
  2. run one search — language into input[data-settings-search]
  3. open the subjects picker — click [data-qwizz-icon-open], wait for [data-qwizz-icon-picker] to lose hidden
  4. capture one screenshot
  • decision_metric: steps to first verified visible element; one step is one browser tool call, and verified visible means a non-zero bounding rect with display != none, visibility != hidden and opacity > 0
  • tie_break: the relay wins a tie because it needs no extra surface; a tie needs two step counts, so it cannot be applied to a single measured surface
  • picker route: the picker is not reachable from edit-tags.php?taxonomy=qw_subject&post_type=qw_question — its trigger measured a 0×0 rect behind #col-left { display: none }, with .form-wrap and #addtag also 0×0. It is rendered on the term-edit screen by includes/Subject/SubjectAdminUi.php:105-135, dialog at :124, so the workload must navigate to term.php?taxonomy=qw_subject&tag_ID=<id>.

Measured result, 2026-08-31

SurfaceSteps to first verified visible elementWorkload outcome
Ego task spacenot measurableno task-space tool mounted and no runtime on the host
OMP browser relaynot measurablebroker serving, extension half never connected
Tool-owned Chromium via app.path2, against a warm authenticated browsercompleted all four operations
  • tool_owned_chromium:
    • step 1: open of /wp-admin/admin.php?page=qw-settings at 1600×1000. The tool reported this as spawning /usr/bin/google-chrome at pid 2951906, but that process had started Mon Aug 31 04:15:47 and was already 5221 s old when measured, and it appeared in a process listing taken before any browser call — so the open reused an already-running tool-owned headless Chrome (--headless=new --incognito --remote-debugging-port=37837 --user-data-dir=/tmp/.com.google.Chrome.scoped_dir.igOuaz) rather than cold-starting one. It returned title QuizWizz-Einstellungen ‹ QuizWizz – WordPress
    • step 2, first verified visible element: input[data-settings-search] at 1011×44, top 338, display: block, visibility: visible, opacity: 1; the same read showed [data-save-status] at 172×44 and .qwizz-workspace-nav [aria-current="true"] at 109×44 reading Allgemein; #loginform was absent, so no login step was needed
    • why no login step: the reused browser was already authenticated in its live context by an earlier run in the same drive. Its --user-data-dir is a scoped temp directory under --incognito, so this is not a durable on-disk administrator profile and it does not survive that browser exiting
    • step 3: one search for language produced a visible [data-search-list] at 1341×71 with rowCount 1 and sectionMatchRows 1, first row GAST-BUILDER › ABSCHNITTSÜBEREINSTIMMUNG …
    • steps 4-5: the edit-tags.php picker attempt failed — tab.click('[data-qwizz-icon-open]') timed out after 8000 ms while matching exactly one element, and the follow-up read located the cause as #col-left { display: none }
    • step 6: on term.php?taxonomy=qw_subject&tag_ID=656&post_type=qw_question the picker opened and verified visible at 1585×1000 with role="dialog", aria-modal="true", heading Symbol wählen, 63 icon buttons, the filter focused, the trigger at aria-expanded="true", #wpwrap inert, and the panel reparented to document.body
    • step 7: one screenshot, 8782 bytes; a vision read of it returned heading Symbol wählen, placeholder Symbole suchen ... and roughly 24 icon tiles inside the viewport. The capture was ephemeral under /tmp and is not retained in this vault
    • step 8: state restored — picker hidden, trigger aria-expanded="false", #wpwrap inert cleared, panel reparented back, and the icon key still empty, so nothing was submitted
    • shared resource: close with kill: true reported Released managed tab and left pid 2951906 running, because that Chrome is shared by concurrent agent sessions on this host. Releasing a tab is therefore safe for siblings, and killing this browser out from under them is not; treat the process as shared infrastructure, not as your own child process
    • replay cost: 8 browser tool calls as executed, including the dead end and its diagnosis; 5 once the term-edit route is known, plus 1 restore call
    • caveats: the 2 is a warm-browser figure and is not a cold-start figure. A cold start must authenticate first, which adds at least one step, so this count is only comparable against another surface measured the same warm way — which is a further reason no ego-versus-relay verdict is claimed here. The run also did not explicitly disable cache, so these are step-cost measurements rather than a fresh-load audit. The search reading of one row and one section match independently agrees with the current durable browser gate.
  • relay_leg_blocker:
    • broker: omp browser-relay --port 9224 was running as pid 1232357 with 06:52:06 elapsed, and ss -ltnp showed LISTEN 127.0.0.1:9224 owned by that process
    • extension source is present at /home/loca/.omp/browser-relay/extension — manifest OMP Browser Relay 0.1.0, MV3, permissions debugger, tabs, tabGroups, storage, alarms
    • the extension is the client half: background.js:2 sets DEFAULT_PORT = 9224 and :182 dials ws://127.0.0.1:${port}/ext
    • GET http://127.0.0.1:9224/ext returned 426 websocket upgrade required while / and /status returned 404, and ss -tnp showed no established connection to 9224; the broker is healthy and the extension half is absent
    • the browser tool refused both an explicit app.relay open and a plain open carrying no app block, so relay routing is active by setting for agent sessions here and the unqualified browser path currently points at a surface that cannot serve:
omp browser relay is serving at http://127.0.0.1:9224 but its extension never connected.
Install it with `omp browser-relay install` and check the toolbar badge shows "on".
  • ego_leg_blocker:
    • no task-space tool is mounted in the agent session; ~/.omp/mcp.json mounts only context7, Quartz Docs and Oh-my-pi Docs, with sequential-thinking disabled
    • no ego, ego-lite or egolite binary is on PATH; nothing exists at /opt/ego-lite, /opt/ego, /usr/share/ego-lite, /home/loca/ego, /home/loca/.ego, /home/loca/.config/ego-lite or /home/loca/dev/services/ego-lite; no systemd system or user unit matches ego
    • omp --help exposes no task-space subcommand — browser-relay is its only browser-surface subcommand
    • root cause: Ego Lite is by contract the owner’s visible task space, and this host has no graphical session — DISPLAY and WAYLAND_DISPLAY are both unset, and all 11 Chrome main processes run --headless=new with zero headful Chrome

No surface was fabricated to force a verdict

Xvfb and xvfb-run are installed, so a synthetic X server plus a relay-loaded Chrome could have been conjured. That was deliberately not done: a synthetic display is not an operator-visible surface, so step counts taken against it would measure the harness rather than the two surfaces under comparison, and the ego half would still be missing. A recorded blocker is worth more than a fabricated winner.

Open residue

The EgoVersusRelay row in the open-work ledger asked for one default, chosen and written down; the default above satisfies that. The comparison the row is named after stays undecided, and both halves need an operator-side display this host does not have:

  • relay: a headful Chrome on an operator-visible display, with the OMP Browser Relay extension loaded, connected to ws://127.0.0.1:9224/ext, and its toolbar badge reading on
  • ego: a task-space tool mounted in the agent session, plus a task space the owner has created and can see

When either precondition lands, re-run the fixed workload unchanged and record the step counts beside the ones above; the tie-break already favours the relay.

TLS and ingress

  • reverse_proxy:
    • app vhost: /etc/nginx/sites-available/wrdp.loca.zone
    • trusts X-Forwarded-Proto; WP_HOME/WP_SITEURL forced to https in WORDPRESS_CONFIG_EXTRA
    • tls: Let’s Encrypt via certbot --nginx (covers wrdp.loca.zone + wiki.wrdp.loca.zone)
  • ingress_policy:
    • shared upstream: snippets/wrdp-proxy.conf proxies to 127.0.0.1:51080, sets Host, X-Real-IP, X-Forwarded-For: $remote_addr, X-Forwarded-Proto, and proxy_read_timeout 300s
    • security headers: snippets/security-headers-app.conf applies X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, and Strict-Transport-Security: max-age=31536000
    • header boundaries: no CSP is applied because the block editor and media library need inline/blob sources; HSTS omits includeSubDomains
    • login ceiling: conf.d/wrdp_limits.conf defines wrdp_login at 1r/s; location = /wp-login.php applies burst=10 nodelay and returns 429 when the non-exempt ceiling is exceeded
    • public heavy-route ceiling: conf.d/wrdp_limits.conf defines wrdp_public_heavy at 2r/s; exact POST routes /wp-json/quizwizz/v1/{craft-draft,reroll,pdf-preview,guest-assistant} and their plain-permalink ?rest_route=/quizwizz/v1/... equivalents allow burst=10 nodelay and return 429 when a non-exempt client exceeds the ceiling
    • operator exemption: loopback and the local public host address are exempt from the login and public-heavy keys so local development and browser audits do not throttle themselves
    • dotfiles: location ~ /\.(?!well-known/) { return 404; } denies dotfiles while leaving ACME reachable
    • archives and droppings: location ~* \.(log|sql|bak(?:[-_.][^/]*)?|old(?:[-_.][^/]*)?|orig(?:[-_.][^/]*)?|save(?:[-_.][^/]*)?|swp(?:[-_.][^/]*)?|swo(?:[-_.][^/]*)?|tar|tgz|gz|zip|7z)(?:/.*)?$ { return 404; } denies logs, database dumps, archives, and editor leftovers; since 2026-09-14 the bak, old, orig, save, swp and swo families are suffix-aware, so .bak-20260904-cutover, .old_foo, .orig.copy, .save-1 and .swp-1 are denied as well as the bare extensions; trailing path-info after a denied name (x.bak/, x.bak/anything) is also denied. The runtime authority is /etc/nginx/sites-available/wrdp.loca.zone; the change and its before/after probes are in the backup exposure cleanup (§4 and §7)
    • loopback caveat: the deny lives on the public vhost only; 127.0.0.1:51080 reaches Apache directly, which serves any existing file under the document root as raw bytes. Keep droppings out of the tree rather than relying on the edge
    • accepted media consequence: the extension deny is vhost-wide, so any WordPress media-library attachment ending .zip, .log, .sql, .tar, .gz, .7z, .bak, .old, .orig, .save, .swp, .swo, or .tgz — or a .bak/.old/.orig/.save/.swp/.swo name with a further -, _ or . suffix — returns 404; do not use the media library to distribute archives from this host
    • uploaded PHP: ^/wp-content/(uploads|upgrade)/.*\.(php|phtml|phar)$ returns 404
    • legacy and packaging leftovers: /xmlrpc.php, /wp-config.php, /wp-config-docker.php, /readme.html, and /license.txt return 404
  • client_ip_trust:
    • nginx: sends X-Forwarded-For: $remote_addr, so client-supplied forwarding chains do not enter the app path
    • Apache: mod_remoteip is loaded with RemoteIPHeader X-Forwarded-For and internal proxy coverage for the 172.31.0.0/16 compose network
    • PHP: REMOTE_ADDR is already the real client IP after Apache rewrites it
    • QuizWizz: RateLimiter::client_ip() returns REMOTE_ADDR directly because quizwizz_trusted_proxies has no subscriber
    • dependency: mod_remoteip is the single mechanism the plugin’s per-network rate limiting depends on
  • wiki_host:
    • wiki vhost: wiki.wrdp.loca.zone now uses the existing wiki_limit rate limit in its location /
    • wiki validation: wrdp deliberately ships no wiki-ia.json, so validate-ssot.py intentionally skips this vault (decision 2026-08-19, tasks 1.3.2/1.4.5: enabling the manifest would forbid the owner-accepted host paths and require source:/reviewed: frontmatter on all 59 pages); hygiene is gated by scripts/wiki-lint.py instead