Files
itpp-infrastructure/docs/infrastructure/migration-plan-app4-core-bu-2026-09-15.md
T

30 KiB

migration-plan-app4-core-bu-2026-09-15.md

Owner: IT Pro Partner (Germaine Brown) Created: 2026-09-15 Status: ACTIVE (supersedes the draft app4-migration-plan.md of 2026-08-15; that file is kept for history) Scope: (a) move Core's customer-facing services onto the new app4; (b) stand up core-bu as Core's warm standby with a working failover and failback; (c) update every document, record and reference that names hosts, IPs or service locations.

Status as of 2026-09-15 (post-Phase-3-start): This plan's original Section 4 phase lists undercounted scope — the verified inventory (core-service-inventory-2026-09-15.md) found roughly 30 customer-facing services plus 3 separate DocuSeal instances, not the ~5 implied below. See Sections 10-12 (appended) for the corrected scope, backup gaps, and defects found. Actual migration progress to date: microbin has been staged on app4 and verified working at the application layer (container up, served content matches Core); the DNS flip for microbin has NOT happened yet — it is still served live from Core. No other service has moved. Treat Section 4's phase list as directional; Section 10 is the authoritative scope.


1. What is verified today (2026-09-15)

Everything in this section was measured on the live boxes, not copied from a doc.

1.1 The two new boxes

app4 core-bu
Role Core's customer-facing services Core's warm standby
Hostname v2202609377162521278.quicksrv.de v2202609377162521279.megasrv.de
IPv4 159.195.205.80/22 159.195.204.203/22
IPv6 2a0a:4cc0:c2:bcbf:34b3:8cff:fea2:2892 2a0a:4cc0:c2:b3e0:9409:42ff:fe3b:5397
Model netcup RS 4000 G12, 12 vCPU / 32 GB / 1007 GB netcup RS 2000 G12, 8 vCPU / 16 GB / 503 GB
Location Nuremberg (NBG) Nuremberg (NBG)
RTT from Core 100.5 ms 100.5 ms

core-bu is the exact twin of Core (same 8 vCPU / 16 GB / 503 GB shape), which is what a standby should be.

1.2 Provisioning status: COMPLETE and verified

Both boxes were provisioned to the ITPP standard on 2026-09-15 and each item was verified, not asserted:

  • Debian 13, hostname set, timezone America/New_York, 8 GB swap (9 GB on app4).
  • ippadmin user with NOPASSWD sudo; itpp-infra key installed for both root and ippadmin.
  • ufw active (22/80/443, plus 9100 only from Core and from the tailnet).
  • fail2ban active, unattended-upgrades active.
  • Docker CE 29.8.0 + Compose v5.5.1 (upstream repo, matching app1's docker-compose-plugin 5.3.1~trixie family).
  • node_exporter listening on :9100 - HTTP 200 from Core, closed from a third host.
  • awscli + Wasabi credentials in /root/.aws/credentials (mode 600).
  • sshd hardened to the fleet convention: PermitRootLogin without-password, PasswordAuthentication no, AllowUsers ippadmin root. Verified four ways per box (root key login, ippadmin key login + sudo -n, password auth refused, non-allowlisted user refused) with the self-reverting lockout guard armed.

1.3 Backups: enrolled and restore-tested

Host Script Schedule Destination Evidence
app4 root-essentials-backup.sh 04:45 ET s3://hermes-vps-backups/root-backup/app4/ first run: upload + in-script download/extract verify OK
core-bu root-essentials-backup.sh 05:15 ET s3://hermes-vps-backups/root-backup/core-bu/ first run: upload + in-script download/extract verify OK

Note the known gap this replicates: root-essentials-backup.sh excludes *.db by design. Any database these boxes end up hosting needs its own sqlite3 .backup / pg_dump job, exactly as anita-mnz needed hermes-db-backup.sh. This is an explicit Phase 4/5 acceptance item, not an optional nicety.

1.4 Monitoring: registered

A node_exporter job was added to the live Prometheus config (/root/docker/monitoring/prometheus/prometheus.yml) - the job did not previously exist. up=1 verified for core:9100, app4:9100, core-bu:9100.

Finding (pre-existing, not caused by this work): node_exporter is not running on app1, app2, app3 or app1-bu; and the node_exporter target list in /opt/prometheus/prometheus.yml lives in a file Prometheus never loaded (it still names decommissioned wphost02 and 178.156.131.57). Host metrics for the existing fleet were therefore never collected. Tracked in Section 8.

1.5 Credentials

Console/root credentials for both boxes are recorded in /root/.hermes/references/new-servers-2026-09-15.md (mode 600, root only). Password auth is disabled on both boxes, so those passwords are console/rescue only. Vaultwarden was locked at the time of writing, so the Vaultwarden entries are still owed (Section 9, Q3).


2. End state

Host Role after migration
Core (152.53.192.33, RS 2000, Manassas) Hermes + its direct dependencies (browserless, camofox-browser, SearXNG, Super Search MCP), Prometheus/Telegraf/Grafana, mikrotik-exporter, Caddy for Core-local routes. No customer-facing apps.
app4 (159.195.205.80, RS 4000 G12, Nuremberg) All customer-facing apps, their databases, and all customer-facing Caddy routes + TLS.
core-bu (159.195.204.203, RS 2000 G12, Nuremberg) Warm standby for Core (Hermes, its state, its watchdog). Dormant until failover.
app1-bu (5.161.225.131, Hetzner CPX21, Ashburn) Retirement candidate once core-bu is proven. Only remaining non-netcup box (Section 3.2).
anita-mnz (159.195.16.30, netcup, Manassas) Unchanged. Anita's dedicated Hermes box.
app1 / app2 / app3 (Manassas) Unchanged by this plan.

3. Deviations and risks you must decide on

3.1 app4 is in Nuremberg, not Manassas (NEW, material)

Measured: 100.5 ms RTT Core -> app4, versus 0.5 ms Core -> app2 (Manassas) and 1.6 ms -> app1-bu (Ashburn). The old draft assumed Manassas. Consequences:

  • Customer latency on app4-hosted sites. Typical US East users add roughly 80-110 ms per round trip versus a Manassas host. For static sites this is mostly invisible; for interactive apps (DocuSeal signing flow, Ops Portal, TimeTrex) it is user-visible.
  • Core <-> app4 chatter crosses the Atlantic. Any Core->app4 API call, monitoring scrape, backup pull, or Caddy proxy hit pays ~100 ms. This is acceptable if app4 is self-contained, and painful if the two are chatty. Design rule for this migration: app4 must not depend on Core at request time.
  • Benefit, and it is real: Core (US) and its standby (EU) now fail independently. A Nuremberg outage does not touch Core, and a Manassas outage does not touch the standby. The old pair (Core + app1-bu Ashburn) were 1.6 ms apart and shared the US East corridor.

Options: (A) accept Nuremberg and design app4 to be self-contained (recommended, zero cost, boxes are paid); (B) re-order app4 as a Manassas RS 4000 and keep the Nuremberg box as the standby. This is a money decision, so it is yours.

3.2 Provider diversity is now unmet

Core, app1, app2, app3, app4, core-bu and anita-mnz are all netcup. app1-bu (Hetzner) is the only other provider, and this plan retires it. Mitigation options: keep app1-bu as the provider-diverse last-resort standby even after core-bu is primary (cheapest option, EUR 31.99/mo), or move the off-site backup/DR device to a non-netcup provider. This must be decided before app1-bu is deleted, and the DR principle that has governed the org so far ("a netcup outage must not kill both live and standby") is currently satisfied by geography but not by provider.

3.3 app4 has no standby of its own

app4 becomes the single host for every customer-facing service. If it dies, customer apps are down until S3 restore. core-bu is shaped for Core, not for the customer tier (16 GB, and it is meant to be dormant). Options: (A) accept S3-restore RTO for app4; (B) let core-bu carry a cold/secondary copy of app4's data; (C) budget a second app-tier box. Recommend a decision now, because it changes what core-bu should replicate.

3.4 DNS authority is split

Confirmed by the old plan's own checklist and this project's history: itpropartner.com is on SiteGround nameservers (manual panel, no API); fleettracker360.com and voipsimplicity.com are on Cloudflare; iamgmb.com, intelsight.io, debtrecoveryexperts.com need per-domain dig NS verification in Phase 0. Every cutover record must be changed in the correct panel or it is a silent no-op. Section 6 lists the records.


4. Migration phases

Each phase has a gate: the next phase does not start until the gate's evidence exists.

Phase 0 - Inventory and recon (Core, read-only) - IN PROGRESS

Deliverable: docs/infrastructure/core-service-inventory-2026-09-15.md (+ .json) - every container, unit, port, volume, database, cron job, TLS cert and Caddy route on Core, with sizes and dependencies.

Gate: inventory lists every Caddy site block and its upstream, and explicitly resolves two conflicts that existing docs disagree on:

  1. DocuSeal is recorded on Core by the Aug 15 draft but on App1 by backup-plan.md (4:00 AM job).
  2. SearXNG likewise. Only the live docker ps / Caddyfile settles it.

Phase 1 - Provision app4 + core-bu, monitoring first - COMPLETE (2026-09-15)

See Section 1. Gate met: both boxes verified; backups restore-tested; both scraped by Prometheus; no customer app touched.

Phase 2 - Access and naming (needs your input)

  • Enroll both boxes in Tailscale (needs a reusable auth key or your approval of the login URL - Section 9, Q1).
  • Decide DNS names: app4.itpropartner.com and core-bu.itpropartner.com A/AAAA records, added in the correct panel (SiteGround for itpropartner.com). Internal access and monitoring already work by IP, so this is not blocking, but the docs and the recovery manual read better with names.
  • Install Caddy on app4 with default_bind 159.195.205.80 (avoids the Tailscale :443 conflict).

Gate: tailscale status shows both nodes; name resolution works from Core.

Phase 3 - Prove the pattern on low-risk apps

  • Move microbin (127.0.0.1:8260) first: single container, one volume, no database.
  • Move Uptime Kuma second: it is the monitoring tool, so it must be moved carefully and its own downtime window announced.
  • For each: stop on Core, rsync the volume, start on app4, verify side-by-side with curl --resolve <domain>:443:159.195.205.80, then flip DNS, then soak 24 h.
  • This phase validates the runbook (per-service steps, verification and rollback) before any customer app moves.

Gate: microbin and Uptime Kuma both served from app4 with app4-issued TLS, verified externally, and their S3 backups land from app4 - not from Core - with a restore test on at least one.

Phase 4 - Data foundation + first real app

  • Provision Postgres and Redis fresh on app4 (internal-only binds, least privilege, no public 5432/6379).
  • Move the Ops Portal backend (:8090), then DocuSeal (SQLite + attachments + its internal Redis), then TimeTrex (Postgres-backed).
  • Databases: pg_dump -Fc per database, restore on app4, then compare row counts per major table, not a spot check. SQLite: sqlite3 .backup, never cp.
  • New backup jobs on app4 for every database it now hosts (*.db is excluded from the essentials archive).

Gate: row counts match; curl --resolve responses match Core; app4-backup + restore test for each moved DB.

Phase 5 - Customer sites and the voice stack

  • rsync every static customer site root (*.iamgmb.com, *.intelsight.io, *.fleettracker360.com, *.debtrecoveryexperts.com) to app4; caddy validate the app4 config; pre-issue TLS.
  • Voice stack (voice.*, voice-open.*): enumerate Twilio webhooks and any external endpoints in Phase 0 and update them before the DNS flip, or calls break after cutover.

Gate: every moving domain answers from app4 with a valid cert; voice end-to-end call tested.

Phase 6 - DNS cutover, soak, decommission on Core

  • Lower TTL to 60-300 on every moving record 24 h before the flip (correct panel per domain).
  • Flip one domain at a time, low traffic first, verifying each (dig +short @1.1.1.1, then curl -sI).
  • Keep Core's Caddy blocks as a 301 redirect to app4 during a 24-72 h soak; remove with targeted edits and the caddy-audit hook (never a whole-file rewrite).
  • Then: stop/remove the moved containers on Core, retain volumes + images 30 days as rollback, decommission the customer schemas in Core's Postgres/Redis.

Gate: 72 h soak with no rollback; Core runs zero customer-facing apps; rollback path still intact.

Phase 7 - core-bu standby, failover AND failback proven (parallel with 3-6)

  • Install the standby package (sync + watchdog with a health-based decision branch, fence-before-takeover, and automatic failback, which the current app1-bu scripts do not have).
  • Only one standby may be armed at a time. Disarm app1-bu before arming core-bu, or a Core hiccup makes both answer as the same Telegram bot.
  • Prove it with a real, announced test: failover, then failback, then confirm the standby is dormant again.

Gate: failover and failback both demonstrated with evidence, and app1-bu verifiably disarmed.

Phase 8 - Documentation and reference sweep

Deliverable: docs/infrastructure/reference-update-matrix-2026-09-15.md - every artifact that names a host. Includes at minimum: key-inventory.md (done), backup-plan.md (done), CHANGELOG.md (done), app-inventory.csv, server-architecture-plan, server-dr-plans, dr-issue-log, the recovery manual, decommissioned-hosts.json + stale-reference-verify.py (add app1-bu when retired), Prometheus config (live one, and delete/refresh the dead /opt/prometheus/prometheus.yml), Grafana dashboards, Uptime Kuma monitors, health-master-watchdog.py, Hermes cron jobs.json live-config fields, Hudu assets, the ops portal, client-facing runbooks, and any skill that hardcodes a host or IP.

Gate: stale-reference-verify.py and doc-live-verify.py both clean; every doc cites the new IPs.


5. Acceptance criteria (whole project)

  1. Core hosts no customer-facing app; every moved domain answers from app4 with a valid TLS cert.
  2. Every moved service has: a data migration that was verified by counts/sizes, a health check, and a tested rollback.
  3. Every database on app4 has its own backup job with a performed restore test (a green cron entry is not evidence).
  4. core-bu failover and failback both demonstrated; exactly one standby armed at any time.
  5. Documentation matrix closed out: no live surface names a decommissioned host or a stale IP.
  6. app1-bu is either retired (with the provider-diversity decision recorded) or explicitly retained as the provider-diverse standby.

6. DNS and Caddy change checklist

  • dig NS every moving domain; record the authoritative panel in Phase 0.
  • Pre-write all moving site blocks into app4's Caddyfile; caddy validate; pre-issue certs.
  • default_bind 159.195.205.80 in app4's Caddy global block.
  • TTL 60-300 at least 24 h before each flip.
  • Flip per domain; verify dig +short @1.1.1.1 and curl -sI https://<domain>.
  • Keep Core blocks as 301s for the soak window; then targeted removal + caddy-audit hook.
  • Update Http->Https and any CNAME/www records in the same panel as the A record.

7. Rollback

  • Before each phase: snapshot DNS records, Core Caddyfile, Core docker ps/volume list.
  • Phases 3-5: stop on app4, flip DNS back to Core, restart the Core container. Core volumes are untouched.
  • Phase 6: with low TTL, the flip back propagates in minutes; Core blocks are retained during soak.
  • Data: Core volumes/images retained 30 days. After that, restore from app4's S3 backups (which is why the Phase 1/4 restore tests are mandatory).
  • core-bu: failback is part of the design, not an afterthought; the standby stands down on its own.

8. Follow-up findings raised by this work (not fixed here)

# Finding Impact Owner
1 node_exporter not running on app1, app2, app3, app1-bu No host metrics for the fleet This project (Phase 8)
2 /opt/prometheus/prometheus.yml is a dead file containing decommissioned hosts (wphost02, 178.156.131.57) and was never loaded Misleading; wasted trust Phase 8
3 netcup SCP/CCP API auth returns HTTP 500 / 404 (worked in July) Provisioning automation via API is dead Separate
4 hermes-standby-sync.sh has a ping-based failback flaw and no failback logic Standby reliability Phase 7
5 hermes-snapshot.sh:34 does a live VACUUM INTO Store churn Separate

9. Open decisions (needed from Germaine)

Q1 - Tailscale: add both boxes to the tailnet. Need either a reusable auth key, or approve the login URL from each box. Update 2026-09-15: still open, plus a new cleanup item — two Tailscale nodes exist for one physical box (app1-bu, offline 61 days, and app1-bu-1, idle). Decide whether to retire the stale app1-bu node now or wait until Q4 (app1-bu retirement) is decided; either way one of the two entries is dead weight today. See Section 12.4.

Q2 - Nuremberg vs Manassas for app4: accept Nuremberg (design app4 self-contained) or re-order app4 in Manassas and repurpose the Nuremberg box? Section 3.1. Update 2026-09-15: still open and now more consequential — the corrected scope (Section 10) is ~30 services, not ~5, so the Atlantic-RTT cost of a wrong answer here is paid by far more customer-facing traffic than originally assessed.

Q3 - Vaultwarden: the CLI is locked. Unlock it (or tell me when) and I will file the two new server items. Update 2026-09-15: still open, no change.

Q4 - app1-bu: retire it, or keep it as the provider-diverse standby? Section 3.2 - this is the only remaining non-netcup box. Update 2026-09-15: still open. Note the duplicate-Tailscale-node finding in Q1/Section 12.4 is directly tied to this decision — retiring app1-bu should also retire its stale tailnet entry in the same action.

Q5 - app4 standby scope: accept S3-restore RTO, or should core-bu carry a secondary copy? Section 3.3. Update 2026-09-15: still open, and now higher-stakes: with ~30 services and ~17 of them carrying zero backup coverage today (Section 11), an app4-only failure mode before those gaps are closed would be a real data-loss event, not just an availability gap. Recommend resolving Section 11's gaps before finalizing this answer.


10. Corrected scope and migration waves (added 2026-09-15, post-inventory)

The counts above (Sections 1-9) predate core-service-inventory-2026-09-15.md. That inventory is now the source of truth for scope. It found:

  • 13 Docker containers on Core, including three separate DocuSeal instances (docuseal :8091, docuseal-dre :8094, docuseal-modelortho :8092 — each its own bind-mounted ./data dir and its own .env; none of this is the single "DocuSeal" the original plan assumed), plus TimeTrex, microbin, Uptime Kuma, Grafana, Prometheus, Telegraf, mikrotik-exporter, SearXNG, browserless, camofox-browser.
  • ~30 additional customer-facing systemd services the original plan never named: auth-api, diglocate-api, dre-mcp, dre-portal, ft360-mcp, hermes-voice, hotnow-api, intelsight-api, ops-portal, osint-api, outlook-upload, pipeline-api, pry, pta-registration, rally, seemytrip, shark-game, shopping-cart, survey-registration, transitpin, twilio-mcp, verdicttank-api, verdicttank-worker, voice-agent, voice-agent-stt — plus two disabled-but-present units (crawl4ai, hermes-control-deck) that need an explicit keep/remove decision, not a silent carry-over.
  • Two independent Postgres engines (host 17.10 for hotnow; TimeTrex's own containerized 16), not one shared instance as Section 3/4 originally assumed.
  • Two stale/dead Caddy routes (crm.debtrecoveryexperts.com, crm.intelsight.io — both proxy to a localhost:3003 nothing is listening on) that must be resolved (delete or repoint) before cutover, not moved as-is.

Total real scope: ~30 customer-facing services + 3 DocuSeal instances, versus the ~5 the phase list in Section 4 was written around. Section 4's phases remain valid as a pattern (prove low-risk, then data foundation, then bulk) but its per-phase examples are illustrative, not the full list. This section supersedes that impression with actual waves:

Wave A — no state, no database (lowest risk, validates the runbook)

microbin (in progress — see Status line at top), uptime-kuma. Matches original Phase 3.

Wave B — SQLite-only services with a single owner and no cross-service dependency

diglocate-api, dre-portal, dre-mcp, intelsight-api, pipeline-api, pry, pta-registration, survey-registration, seemytrip, shark-game, shopping-cart, transitpin, outlook-upload, ft360-mcp. Rationale: each carries its own SQLite file and no shared Postgres/Redis dependency, so they can move independently and in any order without a shared-data-foundation blocker; doing this wave before Wave C also means the ~14 zero-backup services in this group (Section 11) get backup coverage on app4 sooner.

Wave C — shared data foundation + its dependents

Provision Postgres + Redis fresh on app4, then move hotnow-api (owns the hotnow Postgres DB + Redis DB1 consumer), auth-api (has an existing backup script — lowest-risk of this wave), rally, verdicttank-api + verdicttank-worker, osint-api. Rationale: these either own or depend on a database, so they wait for the fresh Postgres/Redis instance the original Phase 4 already calls for, and moving the database owner (hotnow-api) first de-risks the rest of the wave.

Wave D — DocuSeal (all three instances) + TimeTrex

docuseal, docuseal-dre, docuseal-modelortho (SQLite + attachments + internal Redis each — move and verify one at a time, they are not interchangeable despite sharing an image), then timetrex (its own containerized Postgres 16, needs a container-level pg_dump, not a host-level one). Rationale: highest customer-visibility (signing flow, time tracking) and the most complex data migrations — done last, once the runbook is proven on Waves A-C.

Wave E — voice stack + remaining

voice-agent, voice-agent-stt, hermes-voice, twilio-mcp. Rationale: unchanged from original Phase 5 — Twilio webhook endpoints must be updated before DNS flip or calls break. Kept last because it is externally integrated (Twilio-side config, not just DNS) and hardest to roll back quickly.

Explicitly out of scope for this migration (stays on Core)

gitea-runner, hermes-assistant, hermes-browser, hermes-socat-8787, host-metrics-exporter, node_exporter, osint-person, snmp-metrics, super-search, wazuh-agent, hermes-gateway, caddy (trimmed to Core-only routes post-cutover), and the two disabled units crawl4ai / hermes-control-deck pending a keep/remove decision.


11. Backup coverage gaps (added 2026-09-15, post-inventory)

The inventory found zero backup coverage for the following services today. Each has its own SQLite DB (or, for HotNow, a Postgres DB + Redis DB1) with no S3 job protecting it. These must get a backup job on app4 as part of whichever wave moves them (Section 10) — do not carry the gap forward:

Update 2026-09-15 (later the same day) - most of this list is now closed. Two backup jobs were built, run, and proven by download-and-restore after this audit was written:

  • app-data-backup.sh (Core 2:15 AM, app4 5:10 AM) - SQLite snapshots taken through the .backup API and quick_checked, plus pg_dump of the hotnow Postgres DB; the Sunday 2:50 AM --full run adds upload/attachment blobs. Proven: 10/10 DBs quick_check=ok, valid 52-entry pg_restore TOC for hotnow.dump, 275 service code files byte-identical on restore.
  • docker-stack-defs-backup.sh (Core 1:40 AM, app4 5:10 AM) - compose files, .env, configs and service code for every /root/docker stack. Proven: 557 files, all 9 compose files byte-identical on restore.

Now covered: HotNow (Postgres), IntelSight, Pipeline API, PTA registration, PTA survey, SeeMyTrip (DB + uploads), Shopping Cart (uploads), DRE Portal (DB + uploads), Rally, Ops Portal, VerdictTank users.db, DigLocate (uploads), and every /root/docker stack definition.

Still not covered: osint-api, osint-person, outlook-upload, voice-agent, voice-agent-stt, ft360-mcp, hermes-voice, twilio-mcp, and the HotNow Redis DB1. Note shark-game is covered - it is in the root-essentials-backup.sh include list, so its entry below was an undercount. The audit list that follows is retained as the record of what was found at the time.

  1. diglocate-api — no backup script
  2. hotnow-api — Postgres hotnow DB + Redis DB1, no backup script
  3. intelsight-api — no backup script (has intelsight.db)
  4. osint-api — no backup script
  5. osint-person (MCP) — no backup script
  6. outlook-upload — no backup script
  7. pipeline-api — no backup script
  8. pry — no backup script
  9. pta-registration — no backup script
  10. survey-registration (pta-survey) — no backup script
  11. seemytrip — no backup script
  12. shark-game — no backup script
  13. shopping-cart — no backup script
  14. dre-portal — no backup script (dre.db)
  15. voice-agent — no backup script
  16. voice-agent-stt — no backup script
  17. verdicttank-api / verdicttank-workerusers.db has collection cron jobs but no DB backup job (counted as one gap; the two units share the same unbacked database)

Partial/unclear coverage worth flagging separately (not counted in the ~17 above, but not solid either): ft360-mcp (stats/export scripts only, no DB backup), rally (debug/dump scripts only, no scheduled backup), ops-portal (not explicitly separated from core-services-backup.sh — unconfirmed), hermes-voice and twilio-mcp (backup status unknown, not verified either way).


12. Known issues and defects found during Phase 3 (added 2026-09-15)

12.1 Fleet provisioning defect: empty Docker data root on both new boxes

Both app4 and core-bu shipped with /var/lib/docker empty despite Docker CE reporting installed and the service active. Every image pull failed with GetImageBlob / overlay2: no such file or directory (ENOENT) errors. Root cause not yet traced to a specific provisioning step; fixed on both boxes by stopping Docker, rm -rf /var/lib/docker/*, and restarting the daemon (which recreates the expected directory structure), then proven with a real docker run hello-world completing successfully on both. This is a fleet-level provisioning defect, not a one-off — it hit both new boxes identically, so any future box built from the same provisioning process should be checked for this before being marked "Docker ready."

12.2 Docs publish pipeline dead for ~1 month

The MkDocs publish pipeline that puts these documents on the live site has been non-functional for approximately a month, for three compounding reasons: (a) a dead/expired Gitea access token used by the publish job, (b) a branch-resolution bug in the pipeline script that was silently masked by a trailing || true, so failures never surfaced as failures, and (c) the deploy step pointing at the wrong path. No Gitea Actions runner exists anywhere in the fleet, so even with the above three items fixed, automated publish still cannot fire today — publishing currently requires the owner to run it manually. This explains why a plan this stale could stay "published" without anyone noticing the drift.

12.3 app4's essentials backup does not cover /root/docker

root-essentials-backup.sh on app4 (Section 1.3) does not include /root/docker in its backup paths. Any compose stack migrated onto app4 under /root/docker/... (which is where Docker-based services conventionally live per Section 10's Wave A/D services) will not be captured by the essentials backup as currently configured. This must be fixed before or immediately after the first Docker-based service lands on app4 — otherwise migrated services are less protected on app4 than they were on Core.

Fixed 2026-09-15. Rather than adding 5.8 GB of mostly-reproducible installs to the essentials tarball, a dedicated job was written: docker-stack-defs-backup.sh archives compose files, .env, configs and service code for every stack (240 files / 6.4 KB on Core), excluding venv/, node_modules/, caches and any file >= 1 MiB. It runs on Core at 1:40 AM and on app4 at 5:10 AM, is namespaced per host in S3 (s3://hermes-vps-backups/docker-stack-defs/<host>-<date>.tar.gz), and was proven by downloading the archive back and byte-comparing all 9 compose files against live.

12.4 Duplicate Tailscale node for one physical box

Two Tailscale nodes exist representing the same physical app1-bu host: app1-bu (offline 61 days) and app1-bu-1 (idle, presumably re-registered under a new name at some point without removing the old entry). This is a cleanup item tied to the app1-bu retirement decision (Section 9, Q4) — the stale app1-bu node should be removed from the tailnet regardless of which way Q4 is decided, since it is dead weight either way.


Appendix A - Verified evidence log (2026-09-15)

Check Result
RTT Core -> app4 / core-bu 100.5 ms / 100.5 ms
RTT Core -> app2 (Manassas) / app1-bu (Ashburn) 0.5 ms / 1.6 ms
authorized_keys (root + ippadmin, both boxes) present, sha256 102c80e5..., 1 line each
Key login from Core root OK, ippadmin sudo -n -> root, both boxes
Password auth / non-allowlisted user refused on both
ufw status active, 22/80/443 + 9100 from Core/tailnet
Docker / Compose 29.8.0 / v5.5.1 both
node_exporter 200 from Core; refused from app2 (third host)
Prometheus up{job="node_exporter"} core, app4, core-bu = 1
First backup run upload + download/extract verify OK on both
Credentials file /root/.hermes/references/new-servers-2026-09-15.md, mode 600