Runbook: Hosting a public domain from home behind a port-forward (Home Port Forward Hosting)

**Note** from Bead: Home Port Forward Hosting · [canonical source](https://redfish.acequia.io/guerin/.agents/7049c694-e45b-4116-a887-49e64885ac8e/2026-06-13/notes/00-home-port-forward-hosting.md) · session 2026-06-13 · discussion: Talk: Home Port Forward Hosting

> Worked example: `https://stephenguerin.live/`. Captured 2026-06-13. > **Status legend:** ✅ DONE · 🟡 PROPOSED (awaiting Stephen's go-ahead) · ⚪ N/A-yet

## 0. The chain, end to end ``` GoDaddy (registrar) # owns the delegation │ NS delegation ▼ ns1/ns2.hostgo.com (DNS) # the editable zone — cPanel Zone Editor / UAPI │ A stephenguerin.live -> <home public IP> (the dyndns record) ▼ 174.56.31.65 (home public IP) # residential, DYNAMIC — drifts over time │ router WAN ▼ Home router # port-forward 80->host:80, 443->host:443 │ forwards :80/:443 ▼ This Windows machine ├─ Caddy (:80/:443) # ACME TLS termination + reverse proxy │ reverse_proxy localhost:3500 ▼ Node origin (:3500) # serves the actual site ``` Four independent moving parts, each its own failure domain: 1. **DNS / dyndns** — keep the A record pointed at the (changing) home IP. 2. **Router port-forward** — expose :80 and :443 from the WAN to this host. 3. **Caddy** — terminate TLS (Let's Encrypt via ACME) and reverse-proxy to the Node origin. 4. **Node origin** — actually serve `stephenguerin.live`'s content.

## 1. Facts captured at bead open (2026-06-13 snapshot) | Fact | Value | Source | |---|---|---| | Registrar | GoDaddy | Stephen | | Nameservers | `ns1.hostgo.com`, `ns2.hostgo.com` | Stephen | | ⇒ Zone editable via | HostGo cPanel (Zone Editor / UAPI) | derived | | cPanel account | `acequia` on HostGo (`guerin.acequia.io:2083`) | `.credentials/acequia.io-cpanel-token.json` | | cPanel auth | API token, bypasses 2FA, header `Authorization: cpanel acequia:TOKEN` | same | | Home public IP | `174.56.31.65` (residential, **dynamic**) | `api.ipify.org`, 2026-06-13 | | Caddy | installed via winget, on PATH (`...\WinGet\Links\caddy.exe`) | `where caddy` | | Existing Caddyfile | `C:\caddy\caddyfile.txt` already has `stephenguerin.live -> reverse_proxy localhost:3500` (+ `wiki.harvardviz.live -> :3000`) | read | | Listening on 80/443/3500 | **nothing** (Caddy + Node not running) | `Get-NetTCPListener` |

## 1b. Read-only verification results (2026-06-13) Verified via authoritative DNS query (the cPanel API path was blocked — see below): - **Zone IS HostGo-managed.** SOA `responsible = admin.hostgo.com`; authoritative NS resolve into HostGo's `216.37.42.x` block (`dns2.usethisnameserver.com = 216.37.42.4`). So the same cPanel realm owns the zone. - **Actual delegated nameservers = `dns1/dns2/dns3.usethisnameserver.com`** — *not* `ns1/ns2.hostgo.com` as recalled. Both hostnames answer authoritatively (HostGo's real NS brand is `usethisnameserver.com`), but the registrar delegation is to the `usethisnameserver.com` set. Minor correction; same provider, same cPanel. - **The A record already points home.** `stephenguerin.live` **and** `www.stephenguerin.live` both = `174.56.31.65` (the current home IP). SOA serial `2026061300` → the zone was edited **today**. So the dyndns *target* is already correct; the domain is delegated to the home machine right now. The site is only dark because the home stack (Node + Caddy + port-forward) isn't up yet. - **⚠️ cPanel UAPI automation is blocked by Imunify360 bot-protection** from this IP: `{"message":"Access denied by Imunify360 bot-protection. IPs used for automation should be whitelisted"}` (HTTP 200 body; the bare request without a UA got a 415 from the openresty edge first). The token (`acequia` account) is valid — the WAF rejects scripted calls. **Consequence for the dyndns updater (§2a): it cannot drive cPanel UAPI from this IP until the home IP is whitelisted in Imunify360** (cPanel → Imunify360, or WHM, or a HostGo support ticket), OR the update runs from an already-whitelisted IP/host, OR a non-cPanel update channel is used. **Still unconfirmed (lower priority now):** whether the zone is editable specifically by the `acequia` cPanel-account token vs. a sibling account — moot until Imunify360 is resolved, and moot for *today* since the A record is already correct. **Original open unknown (now resolved):** the zone is on HostGo; it is **not** in `.ai/hostgo-domains.md` and had no `.credentials/` entry, but DNS confirms HostGo hosting.

## 1c. LIVE-STATE CORRECTION (2026-06-13, 2nd check) — most of the chain already exists My first snapshot claimed "nothing listening on 80/443/3500." **That was wrong** — `Get-NetTCPListener` is absent in this shell and returned blank instead of erroring, so I misread it. `netstat` shows the real state, and it changes the whole plan: - **Caddy is already running** (PID 7500), listening on `0.0.0.0:80` and `0.0.0.0:443`. The router (**Google Nest**) is **already forwarding 80/443 to this machine** — set up previously, no UPnP/manual step needed now. - **Proof the public path works end to end:** `wiki.harvardviz.live` → DNS `174.56.31.65` (home) → Nest :443 → Caddy → `reverse_proxy localhost:3000` (server PID 49248) → **HTTP 302 with a valid Let's Encrypt cert** (`ssl_verify_result=0`). The apex `harvardviz.live` stays on HostGo (`216.37.42.226`); only `wiki` is home. - **`stephenguerin.live` is already 90% live:** resolves home ✅, **Caddy already issued a valid TLS cert for it** ✅ (curl `ssl_verify_result=0`), reverse-proxy to `localhost:3500` configured ✅. It returns **502 for exactly one reason: nothing is serving `:3500`** (`localhost:3500 → 000`). **Net effect:** §2a (DNS), §2c (port-forward), §2d (Caddy+TLS) are **already DONE**. The dyndns/UPnP/lockout machinery below is retained as reference / for *future* IP-drift handling, but is **not on the critical path**. The single remaining step is **§2b: stand up the origin on :3500.**

## 1d. STAGE 0 DONE — site is live (2026-06-13) `https://stephenguerin.live/` returns **200 with valid TLS** (verified: `code=200 ip=174.56.31.65 ssl=0`; title + placeholder served). The origin is [`artifacts/origin-server.mjs`](../artifacts/origin-server.mjs): dependency-free Node HTTP server bound to `127.0.0.1:3500` only (no TLS — Caddy wraps it), serves `./public/` if present else an inline dark-theme placeholder. Caddy admin API (`localhost:2019`) confirms the `stephenguerin.live` route is in the loaded config. **Persistence — DONE (2026-06-13).** Both halves survive reboot: - **Caddy** was *already* an NSSM auto-start Windows service (`caddy`, LocalSystem, AUTO_START) — discovered, not created. - **Origin** now installed as NSSM service `stephenguerin-live-origin` (LocalSystem, AUTO_START, `AppExit Default Restart`), mirroring the caddy service. Binary `C:\caddy\origins\stephenguerin.live\origin-server.mjs` (stable copy outside the bead tree so it doesn't ride bead syncs); installer + log at `C:\caddy\origins\stephenguerin.live\install-service.ps1` / `install.log`. Verified RUNNING, holding `127.0.0.1:3500`, site 200. - **dyndns prior art found:** existing Scheduled Task *"update wiki.harvardviz.live dyndns"* runs `curl -s https://harvardviz.live/cpanelwebcall/<token>` daily — cPanel's **Dynamic DNS webcall** (a public GET that sets the A record to the caller's IP). This path is **not** Imunify360-blocked (it's port 443 on the public site, not UAPI on :2083). **This is the clean model for a future stephenguerin.live dyndns task** — get/create its `cpanelwebcall` URL in cPanel and mirror the task. (Not done; A record is correct today.) **Next:** stage 1 — replace the placeholder with the bead-tree WebDAV server per [`01-origin-as-animator.md`](01-origin-as-animator.md).

## 2. Feasibility, component by component ### 2a. Dynamic DNS via cPanel — 🟡 feasible, but Imunify360 blocks the API path (see §1b) **Update 2026-06-13:** the A record already = home IP (set today), so no DNS change is needed *right now*. For ongoing automated updates, the cPanel UAPI path is **blocked by Imunify360 from this IP** — whitelist the home IP, run the updater from a whitelisted host, or use a non-cPanel channel. Original analysis retained below. cPanel has no first-class "DynDNS" service the way DynDNS.org does; you implement it as **a periodic UAPI call that edits the A record** when the home IP changes: - Read current IP (`https://api.ipify.org`). - `cPanel UAPI ZoneEdit / DNS` to set `A stephenguerin.live` (and `A www`) to that IP, only when it changed. - Run it on a schedule (Windows Task Scheduler) on this host. - Auth: `Authorization: cpanel acequia:<token>` against `https://guerin.acequia.io:2083/execute/...` (must use `guerin.acequia.io`, not `acequia.io`, because acequia.io is Cloudflare-proxied and CF blocks 2083 — per the token file's note). **Caveat / prerequisite:** confirm the zone is on this account first (§1 open unknown). A short TTL (e.g. 300s) on the A record makes IP changes propagate fast; set it when the record is created. A dyndns-updater script belongs in this bead's `skills/` once written; modeled loosely on the existing `santafetreehouse.dyndns.org` pattern (though that one is an Axis-camera dyndns.org hostname, not cPanel — different mechanism, same intent). ### 2b. Node origin on :3500 — 🟡 trivial mechanically; the *vision* makes it bigger Node v24.11.1 is installed. The Caddyfile already expects the origin at `localhost:3500`. Mechanically, any server works. But per Stephen's direction (2026-06-13) the origin is **not** a static placeholder — it should grow into the **OS-process animator** of the [bead-as-webdav-handler](https://redfish.acequia.io/guerin/.agents/fe7fbaf5-4c47-43b6-be1b-2f6ca15e8bfa/2026-06-11/notes/bead-as-webdav-handler.md) design. See companion note [`01-origin-as-animator.md`](01-origin-as-animator.md). Staging path: placeholder → static bead-tree server (the `uploads-server.mjs` lineage) → full animator. Don't overbuild before the public TLS chain is proven end-to-end. ### 2c. Router port-forward via UPnP ("plugpnp") — ✅ ALREADY DONE (Google Nest forwards 80/443; see §1c) **Already in place** — proven by `wiki.harvardviz.live` serving from the home IP. The analysis below applies only if the forward is ever rebuilt (e.g. router reset). - Forward WAN `:80 → thishost:80` and `:443 → thishost:443`. - UPnP IGD can do this programmatically (e.g. a small Node/`miniupnpc` call) **iff** UPnP is enabled on the router. Many routers ship it off, or only allow UPnP to map high ports, not 80/443. If UPnP can't map 80/443, fall back to a **manual static port-forward** in the router admin UI. - This is the step that **exposes this machine to the public internet.** It is a deliberate security decision, not a default. Implications: Caddy must be the only thing on 80/443; Windows Firewall should allow only those; the Node origin stays bound to `localhost` (never `0.0.0.0`) so it's only reachable *through* Caddy. - Also requires the WAN side to actually be a public IP (no carrier-grade NAT). `174.56.31.65` looks like a routable public IP — good sign — but confirm the router's WAN IP equals the observed public IP (no double-NAT). ### 2d. Caddy TLS + reverse proxy — ✅ ALREADY RUNNING with a valid cert for stephenguerin.live (see §1c) **Already live** — Caddy PID 7500 on 80/443; cert for `stephenguerin.live` already issued (curl TLS verify passes). Only the `:3500` upstream is down. Reference below retained. - Caddy auto-provisions a Let's Encrypt cert via ACME **once :80/:443 are reachable from the internet and DNS resolves to this host.** The existing Caddyfile block is already correct: ``` stephenguerin.live { reverse_proxy localhost:3500 } ``` - ACME HTTP-01 needs inbound :80; TLS-ALPN-01 needs :443. So Caddy can't get a cert until 2a (DNS) and 2c (port-forward) are live. Ordering matters. - On Windows, run Caddy as a service (or a Task-Scheduler-at-logon task) so it survives reboot. `caddy run --config C:\caddy\caddyfile.txt` (note: Caddy prefers a file literally named `Caddyfile`; `--config <path>` makes the `.txt` work, or rename/convert). **Correct bring-up order** (each gates the next): Node origin up → DNS A record set & propagated → router forwards 80/443 → start Caddy (it then fetches the cert). If you start Caddy before DNS+port-forward, ACME fails and Caddy may hit Let's Encrypt rate limits on repeated failures.

## 3. Lockout-risk analysis ("careful not to get locked out") The management plane and the thing being changed are entangled. Concrete risks: 1. **cPanel access flows through `guerin.acequia.io`.** Editing the *wrong* zone, or fat-fingering the `acequia` zone instead of `stephenguerin.live`, could break the very host (`guerin.acequia.io:2083`) used to manage DNS. **Mitigation:** before any write, `PROPFIND`/read the current `stephenguerin.live` zone and **save a verbatim backup** of every existing record to this bead's `artifacts/`. Touch only the `stephenguerin.live` A/www records; never the NS, MX, or any `acequia.io`/`guerin.acequia.io` record. 2. **API token is the only non-2FA path.** If the token is rotated/revoked mid-task, re-entry needs the cPanel web login (2FA). Don't revoke or regenerate it. Keep the web login path as the break-glass fallback. 3. **DNS change is destructive to availability.** Pointing `stephenguerin.live` at the home IP *before* Caddy+Node+port-forward are live takes the domain dark for the propagation window. **Mitigation:** stand up the home stack first, verify locally, then flip DNS last. Short TTL so a rollback propagates fast. Keep the prior A-record value recorded so rollback is a known, single edit. 4. **Residential IP drift = silent outage.** When the ISP changes the WAN IP and the updater isn't running (or this machine is off), the site dies until the next update. The dyndns updater + short TTL is the mitigation; monitor it. 5. **Exposing :80/:443 from home = attack surface.** Only Caddy listens publicly; Node binds localhost; firewall scoped to 80/443; keep Caddy updated. Consider fail2ban-equivalent / rate limiting later. **Standing rule for this bead:** no mutation of DNS, router, or live services without an explicit per-step imperative from Stephen, and not before the current state of the thing being changed is backed up into `artifacts/`.

## 4. What I need from Stephen before executing each step - [ ] **Confirm the go-ahead** to actually execute (this was opened from a feasibility question, so default is plan-only). - [ ] Confirm `stephenguerin.live`'s zone is on the `acequia` cPanel account (I can verify via UAPI read — read-only — if you want me to check now). - [ ] What should the Node origin on :3500 actually serve? (static content / an app / placeholder?) - [ ] Router make/model + whether UPnP is enabled, or preference for a manual static port-forward. - [ ] Confirm no carrier-grade NAT / double-NAT (router WAN IP == `174.56.31.65`). - [ ] OK to set a short TTL (300s) on the A record.

## 5. Suggested execution order (when greenlit) 1. Read + back up the existing `stephenguerin.live` zone → `artifacts/stephenguerin-live-zone-backup-2026-06-13.json`. 2. Stand up the Node origin on :3500; verify `http://localhost:3500` locally. 3. Configure router port-forward 80/443 → this host (UPnP attempt, else manual). 4. Set/lower the `A stephenguerin.live` (+ `www`) record to the current home IP, TTL 300. 5. Start Caddy with the existing Caddyfile; watch it fetch the ACME cert. 6. Verify `https://stephenguerin.live/` end to end (cert valid, content served). 7. Install the dyndns updater on Task Scheduler; install Caddy + Node as boot services. 8. Record final configs into `artifacts/`; `/bead-sync`.

## References (bead cross-links) - Bead: As Webdav Handler · [canonical](https://redfish.acequia.io/guerin/.agents/fe7fbaf5-4c47-43b6-be1b-2f6ca15e8bfa/)