- migration-plan-app4-core-bu-2026-09-15.md: 8-phase plan (Nuremberg decision, provider-diversity gap, acceptance criteria, rollback, DNS/Caddy checklist) - core-service-inventory-2026-09-15: verified Core inventory, ~30 customer-facing services (the Aug 15 plan listed 5), 3 DocuSeal instances, TimeTrex Postgres, dead Caddy routes - reference-update-matrix-2026-09-15: 52 artifacts that name a host - fix naming collision: 6 files called the Hetzner box core-bu, the name core-bu now claims; app1-bu = 5.161.225.131, core-bu = 159.195.204.203 (netcup Nuremberg) - correct the false provider-diversity claim (the standby is now netcup too) - supersede app4-migration-plan.md (wrong region reported, silent on core-bu)
17 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.
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). ippadminuser with NOPASSWD sudo;itpp-infrakey installed for bothrootandippadmin.ufwactive (22/80/443, plus 9100 only from Core and from the tailnet).fail2banactive,unattended-upgradesactive.- Docker CE 29.8.0 + Compose v5.5.1 (upstream repo, matching app1's
docker-compose-plugin 5.3.1~trixiefamily). node_exporterlistening 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:
- DocuSeal is recorded on Core by the Aug 15 draft but on App1 by
backup-plan.md(4:00 AM job). - 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.comandcore-bu.itpropartner.comA/AAAA records, added in the correct panel (SiteGround foritpropartner.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 -Fcper database, restore on app4, then compare row counts per major table, not a spot check. SQLite:sqlite3 .backup, nevercp. - New backup jobs on app4 for every database it now hosts (
*.dbis 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 validatethe 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, thencurl -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)
- Core hosts no customer-facing app; every moved domain answers from app4 with a valid TLS cert.
- Every moved service has: a data migration that was verified by counts/sizes, a health check, and a tested rollback.
- Every database on app4 has its own backup job with a performed restore test (a green cron entry is not evidence).
core-bufailover and failback both demonstrated; exactly one standby armed at any time.- Documentation matrix closed out: no live surface names a decommissioned host or a stale IP.
app1-buis either retired (with the provider-diversity decision recorded) or explicitly retained as the provider-diverse standby.
6. DNS and Caddy change checklist
dig NSevery 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.80in app4's Caddy global block.- TTL 60-300 at least 24 h before each flip.
- Flip per domain; verify
dig +short @1.1.1.1andcurl -sI https://<domain>. - Keep Core blocks as 301s for the soak window; then targeted removal + caddy-audit hook.
- Update Http->Https and any
CNAME/wwwrecords 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.
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.
Q3 - Vaultwarden: the CLI is locked. Unlock it (or tell me when) and I will file the two new server items.
Q4 - app1-bu: retire it, or keep it as the provider-diverse standby? Section 3.2 - this is the only remaining non-netcup box.
Q5 - app4 standby scope: accept S3-restore RTO, or should core-bu carry a secondary copy? Section 3.3.
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 |