🛡️ 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:
- public: https://wrdp.loca.zone (nginx TLS -> 127.0.0.1:51080)
- admin: https://wrdp.loca.zone/wp-admin
- credentials: user
admin, password in.env(WP_ADMIN_PASSWORD)
- privilege: docker requires
sudoon this host (user not in docker group), matchingdev/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, bound127.0.0.1:51080:80 - db:
mariadb:11.4, internalwrdp_netonly (no host port) - wpcli: one-off
wordpress:clirunner (compose profilecli)
- wordpress:
- 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)
- core + wp-content:
- 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:latestandwordpress:clitags plusmariadb: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
- current definitions: moving
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 -vplusrm -rf wp db_datadeletes 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/wpbind mount, outside the WordPress document root - ownership: host directory and PHP-created log are owned by uid/gid
33:33(www-data:www-datain 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_DEBUGandWP_DEBUG_LOGstay enabled for bench development with display off; only the public file path changed
- live host path:
- container_logs:
- follow command:
sudo docker compose logs -f wordpress
- follow command:
- proxy_log_policy:
- files:
/var/log/nginx/access.logand/var/log/nginx/error.log - implemented retention:
/etc/logrotate.d/nginxrotates 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
- files:
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 filessites-available/wrdp.loca.zone,snippets/wrdp-proxy.conf,snippets/security-headers-app.conf, andconf.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
0700because archives include.envand the database - WordPress core: excluded from
content.tar.gz;meta.txtrecords the WordPress and MariaDB image digests so core can be restored to the same image lineage - timer:
wrdp-backup.timerruns nightly at03:30in the user systemd scope throughsudo -n - q5vault copy: this tree has no
.git, so these backups are the only copy ofq5vault
- command:
- 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.sha256before extraction or import - content proof: extracts
content.tar.gzto a temporary directory and asserts recovered paths forwp/wp-config.php,wp/wp-content/plugins/quizwizz/quizwizz.php,docker-compose.yml,uploads.ini,.env, andq5vault/index.md - database proof: replays
db.sql.gzinto scratch databasewrdp_drill - assertions: restored table count matches snapshot metadata
snapshot_table_count; restored QuizWizz table count matches snapshot metadatasnapshot_quizwizz_table_count; restoredqw_quizpost count matches snapshot metadatasnapshot_quiz_count; restoredsiteurlmatches snapshot metadatasnapshot_siteurl - diagnostics: current live table count, live QuizWizz table count, live quiz count, and live
siteurlare logged for comparison only; they are not assertion targets - safety: only writes the scratch
wrdp_drilldatabase and a temporary extraction directory; the exit trap always dropswrdp_drill
- command:
Health probe
- health:
- command:
scripts/wrdp-health.sh - timer:
wrdp-health.timerruns 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.logreturns404https://wrdp.loca.zone/.envreturns404- security headers include
Strict-Transport-Security Service State Healthrequiresdocker compose ps --format '{{.Service}} {{.State}} {{.Health}}'to report bothdb running healthyandwordpress running healthy, with WordPress HTTP healthcheck covered by the homepage200check- retention cron hooks
qwizz_cleanup_security_tables,qwizz_cleanup_ephemeral, andqwizz_cleanup_private_filesare scheduled and not overdue by more than3600seconds - root filesystem usage stays below
90percent
- app home returns HTTP
- command:
- cron_posture:
- scheduler: WP-Cron stays traffic-driven; no replacement scheduler or
DISABLE_WP_CRONchange is introduced - retention hooks:
qwizz_cleanup_security_tables: dailyqwizz_cleanup_ephemeral: hourlyqwizz_cleanup_private_files: hourly
- monitoring: the hourly health probe watches for missing or overdue retention events instead of replacing WP-Cron
- scheduler: WP-Cron stays traffic-driven; no replacement scheduler or
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 withsudo -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>
- copy plugin into bind mount:
- 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
- list:
- 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
- admin file editing is disabled via
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:
- open settings —
/wp-admin/admin.php?page=qw-settings - run one search —
languageintoinput[data-settings-search] - open the subjects picker — click
[data-qwizz-icon-open], wait for[data-qwizz-icon-picker]to losehidden - 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 != hiddenandopacity > 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 a0×0rect behind#col-left { display: none }, with.form-wrapand#addtagalso0×0. It is rendered on the term-edit screen byincludes/Subject/SubjectAdminUi.php:105-135, dialog at:124, so the workload must navigate toterm.php?taxonomy=qw_subject&tag_ID=<id>.
Measured result, 2026-08-31
| Surface | Steps to first verified visible element | Workload outcome |
|---|---|---|
| Ego task space | not measurable | no task-space tool mounted and no runtime on the host |
| OMP browser relay | not measurable | broker serving, extension half never connected |
Tool-owned Chromium via app.path | 2, against a warm authenticated browser | completed all four operations |
- tool_owned_chromium:
- step 1:
openof/wp-admin/admin.php?page=qw-settingsat1600×1000. The tool reported this as spawning/usr/bin/google-chromeat pid2951906, but that process had startedMon Aug 31 04:15:47and was already5221 sold 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 titleQuizWizz-Einstellungen ‹ QuizWizz – WordPress - step 2, first verified visible element:
input[data-settings-search]at1011×44,top 338,display: block,visibility: visible,opacity: 1; the same read showed[data-save-status]at172×44and.qwizz-workspace-nav [aria-current="true"]at109×44readingAllgemein;#loginformwas 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-diris 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
languageproduced a visible[data-search-list]at1341×71withrowCount 1andsectionMatchRows 1, first rowGAST-BUILDER › ABSCHNITTSÜBEREINSTIMMUNG … - steps 4-5: the
edit-tags.phppicker attempt failed —tab.click('[data-qwizz-icon-open]')timed out after8000 mswhile 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_questionthe picker opened and verified visible at1585×1000withrole="dialog",aria-modal="true", headingSymbol wählen, 63 icon buttons, the filter focused, the trigger ataria-expanded="true",#wpwrapinert, and the panel reparented todocument.body - step 7: one screenshot,
8782bytes; a vision read of it returned headingSymbol wählen, placeholderSymbole suchen ...and roughly 24 icon tiles inside the viewport. The capture was ephemeral under/tmpand is not retained in this vault - step 8: state restored — picker
hidden, triggeraria-expanded="false",#wpwrapinert cleared, panel reparented back, and the icon key still empty, so nothing was submitted - shared resource:
closewithkill: truereportedReleased managed taband left pid2951906running, 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
2is 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.
- step 1:
- relay_leg_blocker:
- broker:
omp browser-relay --port 9224was running as pid1232357with06:52:06elapsed, andss -ltnpshowedLISTEN 127.0.0.1:9224owned by that process - extension source is present at
/home/loca/.omp/browser-relay/extension— manifestOMP Browser Relay0.1.0, MV3, permissionsdebugger, tabs, tabGroups, storage, alarms - the extension is the client half:
background.js:2setsDEFAULT_PORT = 9224and:182dialsws://127.0.0.1:${port}/ext GET http://127.0.0.1:9224/extreturned426 websocket upgrade requiredwhile/and/statusreturned404, andss -tnpshowed no established connection to9224; the broker is healthy and the extension half is absent- the browser tool refused both an explicit
app.relayopen and a plain open carrying noappblock, so relay routing is active by setting for agent sessions here and the unqualified browser path currently points at a surface that cannot serve:
- broker:
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.jsonmounts onlycontext7,Quartz DocsandOh-my-pi Docs, withsequential-thinkingdisabled - no
ego,ego-liteoregolitebinary is onPATH; nothing exists at/opt/ego-lite,/opt/ego,/usr/share/ego-lite,/home/loca/ego,/home/loca/.ego,/home/loca/.config/ego-liteor/home/loca/dev/services/ego-lite; no systemd system or user unit matchesego omp --helpexposes no task-space subcommand —browser-relayis 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 —
DISPLAYandWAYLAND_DISPLAYare both unset, and all 11 Chrome main processes run--headless=newwith zero headful Chrome
- no task-space tool is mounted in the agent session;
No surface was fabricated to force a verdict
Xvfbandxvfb-runare 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 readingon - 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_SITEURLforced to https inWORDPRESS_CONFIG_EXTRA - tls: Let’s Encrypt via certbot
--nginx(coverswrdp.loca.zone+wiki.wrdp.loca.zone)
- app vhost:
- ingress_policy:
- shared upstream:
snippets/wrdp-proxy.confproxies to127.0.0.1:51080, setsHost,X-Real-IP,X-Forwarded-For: $remote_addr,X-Forwarded-Proto, andproxy_read_timeout 300s - security headers:
snippets/security-headers-app.confappliesX-Frame-Options: SAMEORIGIN,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, andStrict-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.confdefineswrdp_loginat1r/s;location = /wp-login.phpappliesburst=10 nodelayand returns429when the non-exempt ceiling is exceeded - public heavy-route ceiling:
conf.d/wrdp_limits.confdefineswrdp_public_heavyat2r/s; exactPOSTroutes/wp-json/quizwizz/v1/{craft-draft,reroll,pdf-preview,guest-assistant}and their plain-permalink?rest_route=/quizwizz/v1/...equivalents allowburst=10 nodelayand return429when 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 thebak,old,orig,save,swpandswofamilies are suffix-aware, so.bak-20260904-cutover,.old_foo,.orig.copy,.save-1and.swp-1are 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:51080reaches 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/.swoname with a further-,_or.suffix — returns404; do not use the media library to distribute archives from this host - uploaded PHP:
^/wp-content/(uploads|upgrade)/.*\.(php|phtml|phar)$returns404 - legacy and packaging leftovers:
/xmlrpc.php,/wp-config.php,/wp-config-docker.php,/readme.html, and/license.txtreturn404
- shared upstream:
- client_ip_trust:
- nginx: sends
X-Forwarded-For: $remote_addr, so client-supplied forwarding chains do not enter the app path - Apache:
mod_remoteipis loaded withRemoteIPHeader X-Forwarded-Forand internal proxy coverage for the172.31.0.0/16compose network - PHP:
REMOTE_ADDRis already the real client IP after Apache rewrites it - QuizWizz:
RateLimiter::client_ip()returnsREMOTE_ADDRdirectly becausequizwizz_trusted_proxieshas no subscriber - dependency:
mod_remoteipis the single mechanism the plugin’s per-network rate limiting depends on
- nginx: sends
- wiki_host:
- wiki vhost:
wiki.wrdp.loca.zonenow uses the existingwiki_limitrate limit in itslocation / - wiki validation: wrdp deliberately ships no
wiki-ia.json, sovalidate-ssot.pyintentionally 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 requiresource:/reviewed:frontmatter on all 59 pages); hygiene is gated byscripts/wiki-lint.pyinstead
- wiki vhost: