# Manual Cloudflare tunnel setup — human runbook

Step-by-step recovery path for when the Maxy automation fails or is unavailable. Every command below is isolated in its own code block — nothing to parse from prose. Every path is brand-scoped so multiple brands can coexist on the same Linux user.

Prerequisites: `cloudflared` installed; the brand's VNC running on its own X display + rfbport + websockify + CDP port (per `brand.json`'s `vncDisplay`/`rfbPort`/`websockifyPort`/`cdpPort` fields — Tasks 553 + 924; defaults Maxy=`:99`/`5900`/`6080`/`9222`, Real Agent=`:100`/`5901`/`6081`/`9223`, Maxy-2=`:101`/`5902`/`6082`/`9224`, Maxy-3=`:102`/`5903`/`6083`/`9225`, Maxy-4=`:103`/`5904`/`6084`/`9226`); SSH access to the device. The defaults table is historical — runtime readers (`paths.ts`, admin/MCP, `vnc.sh`, `test-laptop-vnc-boot.sh`) loud-fail with `reason=cdp-port-unresolved` if any of `rfbPort`/`websockifyPort`/`cdpPort` is missing from the live `brand.json`; the values above show what each brand's `brand.json` contains, not what the readers fall back to.

---

## Why two hostnames (admin and public)

Each Maxy installation exposes two distinct surfaces from the same device and the same local port. The platform UI distinguishes them by the inbound `Host:` header.

- **admin hostname** — the operator's private chat + admin panel. PIN-protected. Only the owner uses it.
- **public hostname** — the public-facing agent (customer chat, marketing agent, WhatsApp webhook target). Reachable by anyone on the internet.

Two tunnel hostnames, one connector, one local service. The tunnel's `ingress:` block routes each hostname to `http://localhost:${PORT}`; the platform UI handles the rest.

---

## Two modes — which are you in?

### Mode A — you own the domain (this runbook)

You have a Cloudflare account and a domain already on that account. You run every step below on your own device using your own cert.pem. Your hostnames are:

- `admin.<yourdomain>`
- `public.<yourdomain>`

### Mode B — you don't own a domain (Maxy-provided subdomain)

First-time users without a Cloudflare account or domain use a subdomain of the Maxy-operated domain (`maxy.bot`). Maxy owns the CF account and issues each user a token for a pre-created tunnel. Hostnames are:

- `<username>.maxy.bot` — admin surface (e.g. `neo.maxy.bot`, `joel.maxy.bot`)
- public surface is optional and, when enabled, uses a second Maxy-issued subdomain.

Mode B skips Steps 1–5 of this runbook entirely — the user receives a token from Maxy and only runs Step 6 (the connector) with `--token <X>`. Mode B is documented separately in `managed-tunnel-setup.md`.

**The rest of this runbook covers Mode A only.**

---

## How this runbook is used

This runbook is the contract the agent follows when setting up a Cloudflare tunnel. The agent reads the step it is about to execute, runs the `cloudflared` command (or writes the config / starts the service) via the Bash tool, and streams the literal output back to the operator. There is no shell-script wrapper, no orchestrator state machine, no MCP-tool surface — the PTY itself is the operator-visible surface, and cloudflared's stdout/stderr is the evidence.

The setup is done when, and only when, `curl -I https://<admin-hostname>` issued from outside the local network returns `HTTP/2 200` (or `HTTP/1.1 200 OK`). The agent must paste the curl response into chat before claiming success. No service-active or `cloudflared exit 0` claim substitutes for that response.

Apex hostnames (e.g. `maxy.chat`) cannot be routed by `cloudflared tunnel route dns` — see §Step 4. The agent quotes the ACTION REQUIRED block from §Step 4 verbatim and the operator edits the apex CNAME in the dashboard.

---

## Step 0 — Set brand context

Run this once per shell session. Every subsequent step reads these variables. The `BRAND` value determines the on-disk directory (`${HOME}/.${BRAND}/cloudflared/`) so multiple brands on the same Linux user do not collide.

For Maxy:

```
export BRAND=maxy
export PORT=19200
export CFG_DIR="${HOME}/.${BRAND}/cloudflared"
mkdir -p "${CFG_DIR}"
```

For Real Agent:

```
export BRAND=realagent
export PORT=19500
export CFG_DIR="${HOME}/.${BRAND}/cloudflared"
mkdir -p "${CFG_DIR}"
```

---

## Step 1 — OAuth login

The DISPLAY value is per-brand. Read it from the brand manifest so the chromium tab opens in the correct VNC:

```
export VNC_DISPLAY=":$(jq -r '.vncDisplay // 99' "${HOME}/${BRAND}/platform/config/brand.json")"
DISPLAY="${VNC_DISPLAY}" cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel login
```

**Why:** Produces the account-level credential that authorises creating tunnels and routing DNS. One-time per device per brand per account.

**Success:** VNC browser opens on the "Authorize Cloudflare Tunnel" page. Click any zone. Terminal prints `You have successfully logged in.`

**`cloudflared` quirk — cert.pem lands at the default path regardless of `--origincert`.** The login subcommand always writes to `~/.cloudflared/cert.pem`. Move it into the brand-scoped directory immediately so every subsequent step works off `${CFG_DIR}/cert.pem`:

```
mv ~/.cloudflared/cert.pem "${CFG_DIR}/cert.pem"
```

**If the login fails with `You have an existing certificate`:** cert.pem already exists at the default path from a prior login. Either skip to Step 2 (if the existing cert is correct), or remove it and re-run:

```
rm ~/.cloudflared/cert.pem
```

**If the browser does not open:** confirm the VNC display exists:

```
ls /tmp/.X11-unix/
```

You should see `X99`. If not, start VNC before retrying Step 1.

### States the consent page can render

The dashboard at `dash.cloudflare.com/argotunnel?...` is `cloudflared`'s native consent surface. The automation wrapper does not inspect or auto-click it; the operator drives every variation manually on the VNC.

1. **Pre-authorize — the Authorize button is visible.** Single-zone account. Click **Authorize**. Cloudflare's callback fires and `~/.cloudflared/cert.pem` lands a second or two later. Continue with the `mv` above.

2. **Multi-zone picker — "Please select the zone you want to add a Tunnel to" with a list of zones.** Multi-zone account. Click the zone row you want first; the page renders the Authorize button. Click **Authorize**. Same callback path as state 1.

3. **Post-success — "Cloudflared has installed a certificate" modal.** The bound account already has a cert authorised for the zones the dashboard knows about. The OAuth callback is idempotent — navigating to a fresh `cloudflared tunnel login` URL still fires the callback, so `~/.cloudflared/cert.pem` will land. Wait a second or two, then run the `mv`.

   If the cert *also* exists at `${CFG_DIR}/cert.pem` from a prior partial run, Step 1 is already complete — skip to Step 2.

4. **Blank or button-less — neither the Authorize button nor the success modal is on the page.** The browser may be mid-load, signed out of Cloudflare, blocking on captcha, or showing some other state. Sign in to Cloudflare in the same browser, navigate back to the URL `cloudflared tunnel login` printed, and complete the click manually. If the URL has expired, kill `cloudflared` (`Ctrl+C`) and re-run Step 1 to get a fresh URL.

---

## Step 2 — List existing tunnels

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list
```

**Why:** Shows every tunnel on the brand's account by name and UUID. Use before creating (to avoid name collision) and before deleting (to find the UUID).

**Success:** A table of NAME, ID (UUID), CREATED, CONNECTIONS. An empty list is fine.

**If it fails with `cert.pem not found`:** run Step 1.

---

## Step 3 — Create the tunnel

Choose a tunnel name following the convention `{product}-{device}` (e.g. `maxy-neo`, `maxy-pi`, `realagent-laptop`). Then:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel create "${BRAND}-$(hostname -s)"
```

**Why:** Creates a new tunnel on the brand's CF account. Writes the connector credentials file (distinct from cert.pem: this is per-tunnel runtime auth; cert.pem is account auth). The credentials filename is the tunnel UUID.

**Success:** Output contains:
```
Created tunnel <name> with id <UUID>
```
Note the UUID — every subsequent step needs it.

**If it fails with `already exists`:** a tunnel with that name exists. Either run Step 2 to find its UUID and skip to Step 4, or delete it:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel delete "${BRAND}-$(hostname -s)"
```

Then re-run Step 3.

**Capture the UUID as an env var so every subsequent step references it:**

```
export TUNNEL_ID=<UUID>
```

Replace `<UUID>` with the value from Step 3's output.

**Note — credentials JSON already lives in `${CFG_DIR}`.** Unlike cert.pem, `cloudflared tunnel create` writes its `<UUID>.json` credentials next to wherever `--origincert` points. Because Step 1's `mv` put cert.pem in `${CFG_DIR}`, the credentials landed there too. No move needed.

---

## Step 4 — Route DNS for each hostname

One command per hostname.

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" admin.maxy.bot
```

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" public.maxy.bot
```

**Why:** Creates a CNAME in Cloudflare DNS pointing each hostname at `<UUID>.cfargotunnel.com`. `--overwrite-dns` makes it idempotent.

**Success — any of these output shapes:** cloudflared emits one of three distinct messages on exit 0, all of which mean the hostname is routed correctly. Do not parse stdout to distinguish them — exit 0 is the signal.

1. New record: `Added CNAME <hostname> which will route to this tunnel`
2. Overwritten: `Added CNAME <hostname> which will route to this tunnel` (same shape as new; `--overwrite-dns` makes overwrite transparent)
3. Idempotent no-op: `<timestamp> INF <hostname> is already configured to route to your tunnel tunnelID=<UUID>`

Shape 3 is what a repeat run against an already-configured hostname emits — `cloudflared` exits 0 and the agent treats it as a no-op success.

**If it fails with `zone not found`:** the hostname's parent domain isn't on this brand's Cloudflare account. Either add it in the dashboard (Websites → Add a site) and re-run, or sign into the account that already owns the domain.

### Apex hostnames (e.g. `maxy.chat` as a bare root domain)

`cloudflared tunnel route dns` **cannot** create a CNAME at a zone apex — standard DNS forbids CNAME at apex (conflicts with SOA/NS), and Cloudflare's workaround (CNAME flattening) is only exposed via the CF API/dashboard, not the CLI. Running `tunnel route dns --overwrite-dns <UUID> maxy.chat` silently misroutes to the first non-matching zone it finds (observed: it created `maxy.chat.maxy.bot` under the wrong zone).

**Workflow for apex hostnames:**

1. **Manual — create the CNAME once in the dashboard.** CF dashboard → `<apex-zone>` → DNS → Add record. Type=CNAME, Name=`@` (zone apex), Content=`<UUID>.cfargotunnel.com`, Proxy=on. Cloudflare applies CNAME flattening automatically for apex CNAMEs under a CF-managed zone.
2. **Scripted — add the ingress rule in Step 5's config.yml** (same as any subdomain hostname). Without this, traffic arrives at the connector and falls through to `http_status:404`.
3. **Scripted — add the hostname to `alias-domains.json`** if the apex should render the public surface (see §Troubleshooting at the end of this runbook for the classification rule).

Steps 2 and 3 are part of the normal scripted flow. Only Step (1) is the irreducible manual action for apex hostnames.

---

## Step 5 — Write config.yml

The heredoc below expands `${BRAND}`, `${PORT}`, `${CFG_DIR}`, and `${TUNNEL_ID}` at write time. **If any of those variables is empty in the current shell, the file gets written with blank values and cloudflared refuses to start with the cryptic error `"cloudflared tunnel run" requires the ID or name of the tunnel`.** The most common cause is running Step 5 in a fresh shell where Step 0 wasn't re-exported.

**Fail-fast guard — run this first.** It exits non-zero if any required variable is unset or empty, so you cannot accidentally write a broken file:

```
: "${BRAND:?set Step 0}"; : "${PORT:?set Step 0}"; : "${CFG_DIR:?set Step 0}"; : "${TUNNEL_ID:?set via Step 3}"
```

Only if that line prints nothing (all vars set) do you proceed to write the config:

```
cat > "${CFG_DIR}/config.yml" <<EOF
tunnel: ${TUNNEL_ID}
credentials-file: ${CFG_DIR}/${TUNNEL_ID}.json
ingress:
  - hostname: admin.maxy.bot
    service: http://localhost:${PORT}
  - hostname: public.maxy.bot
    service: http://localhost:${PORT}
  - service: http_status:404
EOF
```

**Why:** Tells cloudflared at startup which tunnel to run, where its credentials are, and how to route each hostname to the brand's local service port. The trailing `http_status:404` line is the mandatory catch-all.

**Success:** Confirm with:

```
cat "${CFG_DIR}/config.yml"
```

**If it fails on YAML:** validate with:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" --config "${CFG_DIR}/config.yml" tunnel ingress validate
```

### Off-LAN SSH and SMB ingress

The same tunnel can also carry SSH and SMB to the Pi so an off-LAN
operator can SSH in or mount the brand share without VPN or port
forwarding. Insert SSH/SMB ingress rules between the HTTP rules and the
catch-all `http_status:404`:

```
ingress:
  - hostname: admin.maxy.bot
    service: http://localhost:${PORT}
  - hostname: public.maxy.bot
    service: http://localhost:${PORT}
  - hostname: ssh.maxy.bot
    service: ssh://localhost:22
  - hostname: smb.maxy.bot
    service: tcp://localhost:445
  - service: http_status:404
```

Then route the new hostnames (Step 4 equivalent):

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" ssh.maxy.bot
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns "${TUNNEL_ID}" smb.maxy.bot
```

**Order matters:** route HTTPS hostnames first and rewrite `config.yml`
only after the HTTPS pass is durable, so a failure on `ssh` or `smb` route
DNS does not nuke the HTTPS ingress; the failing hostname is logged and
config.yml is rendered without it.

**SMB depends.** Before adding the SMB ingress, confirm the
brand stanza exists in `/etc/samba/smb.conf`:

```
grep -q "^\[${BRAND}\]" /etc/samba/smb.conf && echo present || echo missing
```

If `missing`, provision Samba first (the installer's post-install step
does this on every Pi). The script skips the SMB ingress with
`[tunnel-install] smb-ingress-skipped reason=samba-not-provisioned`
rather than failing.

**Access policy — API or dashboard.** `cloudflared` CLI has no
Access-application create subcommand, so author the policy either via
the Cloudflare API with a minted Access-scoped token (see
`references/api.md` § Access) or by hand in the dashboard. After the
ingress is in place, the dashboard path is:

```
Cloudflare → Zero Trust → Access → Applications → Add → Self-hosted
  Application name:    ssh.maxy.bot          (or smb.maxy.bot)
  Application domain:  ssh.maxy.bot          (or smb.maxy.bot)
  Policy → Action:     Allow
  Include → Emails:    <operator email>
```

Verify ingress validity:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel ingress validate "${CFG_DIR}/config.yml"
```

Then verify off-LAN reachability from a Mac:

```
cloudflared access ssh --hostname ssh.<brand>.<rootdomain>
cloudflared access tcp --hostname smb.<brand>.<rootdomain> --url tcp://localhost:4445
# In Finder: Cmd-K → smb://localhost:4445
```

---

## Step 5b — Write tunnel.state

`resume-tunnel.sh` (the `ExecStartPre=` in the brand's service) reads `${CFG_DIR}/tunnel.state` to discover which tunnel to run. **Without this file the script exits 0 silently and no connector is spawned** — see lines 34-37 of `platform/scripts/resume-tunnel.sh`. The raw CLI flow in this runbook does not write `tunnel.state` automatically, so write it explicitly here:

Re-run the Step 5a fail-fast guard if your shell is new or if Step 5a was a while ago:

```
: "${BRAND:?set Step 0}"; : "${CFG_DIR:?set Step 0}"; : "${TUNNEL_ID:?set via Step 3}"
```

Then write the state file:

```
cat > "${CFG_DIR}/tunnel.state" <<EOF
{
  "tunnelId": "${TUNNEL_ID}",
  "tunnelName": "${BRAND}-$(hostname -s)",
  "domain": "maxy.bot",
  "configPath": "${CFG_DIR}/config.yml",
  "credentialsPath": "${CFG_DIR}/${TUNNEL_ID}.json",
  "sshHostname": "ssh.maxy.bot",
  "smbHostname": "smb.maxy.bot"
}
EOF
```

Omit `sshHostname` and `smbHostname` when those ingresses are not in
use. The fields are additive; the script's re-run path rehydrates them
on every invocation so a repeat setup run without
`SSH_HOSTNAME` / `SMB_HOSTNAME` set does not silently drop the
SSH/SMB ingress.

**Why each field:** `resume-tunnel.sh` reads `tunnelId`, `domain`, `configPath` (all used; domain is for logging, tunnelId for the log line, configPath is passed as `--config` to cloudflared). `credentialsPath` and `tunnelName` are not read by `resume-tunnel.sh` itself but are consumed by other tools (e.g. `tunnel-status` in the cloudflared plugin), so write them anyway.

**Success:**

```
cat "${CFG_DIR}/tunnel.state" | jq .tunnelId
```

Prints the UUID from Step 3. If it prints empty or null, the heredoc's env expansion failed — re-run the fail-fast guard.

**If you skip this step:** `systemctl --user status maxy.service` will show `ExecStartPre=resume-tunnel.sh (code=exited, status=0/SUCCESS)` (no error) but `ps -ef | grep '[c]loudflared'` will show no Maxy connector. Silent failure — the exact gap this step closes.

---

## Step 6 — Restart the brand's user-space service so it respawns the connector with the new config

You do **not** run `cloudflared` manually. The brand's existing user-space systemd unit (`~/.config/systemd/user/${BRAND}.service`) declares `ExecStartPre=/home/<user>/${BRAND}/platform/scripts/resume-tunnel.sh`, and that pre-start script reads `${CFG_DIR}/tunnel.state` and `${CFG_DIR}/config.yml` (the files Steps 5 and 5b just wrote) and spawns the connector in the user's cgroup. Restarting the brand service is what picks up the new config.

```
systemctl --user restart "${BRAND}.service"
```

**Cgroup trap — when the admin agent issues this command via the Bash tool, the Bash subprocess runs *inside* `${BRAND}.service`'s cgroup.** A direct `systemctl --user restart ${BRAND}.service` from that cgroup tells systemd to SIGTERM the entire cgroup — the node server, the claude subprocess, the Bash child, and the agent itself all die simultaneously. cgroup membership is inherited: `setsid`, `nohup`, `disown`, and `&` all stay in the caller's cgroup, and `systemd-run --scope` runs in the caller's scope. Only `systemd-run --user --unit=<name> --on-active=<N>s` creates a genuinely new transient unit with its own cgroup. The agent uses that primitive to arm the restart a few seconds after the Bash call returns:

```
systemd-run --user --unit=maxy-tunnel-restart-$(date +%s).service --on-active=3s --collect \
  /bin/systemctl --user restart "${BRAND}.service"
```

The transient timer fires from outside the service's cgroup — semantically identical to a direct `systemctl --user restart`, but the agent survives.

When walking through manually from an SSH shell you do **not** need `systemd-run` — your SSH shell already lives in a separate user-scope cgroup (`user@<uid>.service`), so the direct `systemctl restart` does not kill the caller. The extra indirection only matters when the caller *is* inside the service being restarted (the admin agent's case).

**Why:** `resume-tunnel.sh` is the deterministic, brand-scoped spawner. Running `cloudflared` manually duplicates the connector (two processes for one tunnel) and races the brand service on every service restart. The service path is the only correct production path.

**Success:**

```
systemctl --user status "${BRAND}.service" --no-pager
```

Shows `active (running)` with `ExecStartPre=...resume-tunnel.sh (code=exited, status=0/SUCCESS)`. Then confirm the connector is actually alive:

```
ps -ef | grep '[c]loudflared'
```

Expect one line per running brand, each with `--config ${CFG_DIR}/config.yml` under your Linux user (not root). Then verify end-to-end from any machine:

```
curl -I https://admin.maxy.bot
```

A non-530 response means the tunnel is live.

**If the connector is missing from `ps`**, the spawn failed silently. Check `${CFG_DIR}/logs/cloudflared.log` for the reason (most often: `config.yml` was written with empty `tunnel:` because Step 5's fail-fast guard was skipped).

**If the hostname returns `530`:** DNS has not propagated yet. Wait 30–60 seconds and retry.

**If the hostname returns `connection refused`:** your local service is not listening on `${PORT}`. Start it.

**If cloudflared exits with `tunnel not found`:** the `${TUNNEL_ID}` in config.yml does not match an existing tunnel on the brand's account. Re-run Step 2 to confirm.

---

## Step 7 — Production durability (user-space systemd via the platform service)

**Do NOT use `sudo cloudflared service install`.** It writes `/etc/systemd/system/cloudflared.service`, copies your config to `/etc/cloudflared/config.yml` (a system-wide path), and runs the connector as root — breaking brand isolation on any device that hosts more than one brand under the same Linux user. We tried this path on 2026-04-19; it created a duplicate connector parallel to the existing user-space one and required an explicit uninstall to undo.

The correct production pattern is already in place on every Maxy device: the brand's platform UI itself is a **user-space systemd service** at `~/.config/systemd/user/<brand>.service`, and that service spawns the cloudflared connector as an `ExecStartPre=` via `platform/scripts/resume-tunnel.sh`. When the user-space service restarts, the connector restarts with it. The connector runs as the Linux user (not root), its config is read from `${CFG_DIR}/config.yml` (brand-scoped, never `/etc`), and multiple brands coexist because each has its own unit file (`maxy.service`, `realagent.service`, etc.).

**Durability prerequisites (one-time per device):**

```
loginctl enable-linger "$(whoami)"
```

Lets user-space services survive logout/reboot. Without linger, user units only start at interactive login.

**Confirm the brand's unit is enabled:**

```
systemctl --user is-enabled "${BRAND}.service"
```

Expected: `enabled`. If `disabled`, enable it:

```
systemctl --user enable "${BRAND}.service"
```

**After any change to `${CFG_DIR}/config.yml`, restart the brand's service** — the `ExecStartPre=resume-tunnel.sh` respawns the connector with the new config:

```
systemctl --user restart "${BRAND}.service"
```

**Success:**

```
systemctl --user status "${BRAND}.service"
```

Shows `active (running)` with the brand's platform UI + cloudflared alive. A separate `ps -ef | grep '[c]loudflared'` confirms one connector per running brand, all under user `$(whoami)`, none under root.

**If you already ran `sudo cloudflared service install` before reading this** — undo it:

```
sudo cloudflared service uninstall
```

Removes the root-level systemd unit, `/etc/cloudflared/config.yml`, and the duplicate connector. The user-space service remains unaffected.

---

## Multi-ingress: adding hostnames for prototype services

Once the brand tunnel is up (admin + public + optionally SSH/SMB), additional service hostnames can be added without re-running the OAuth/cert/create-tunnel steps. This is the pattern the venture-studio `prototype-host` skill uses to expose hosted landing pages and wedge prototypes as `https://<surface>.<BRAND_HOSTNAME>`.

The flow is: append an ingress rule to `${CFG_DIR}/config.yml` immediately before the final `http_status:404` catch-all, validate the YAML, create the DNS row, restart the brand cloudflared service.

### 1. Append the ingress rule (awk, not sed)

`sed` cannot reliably insert before the last multi-line YAML rule. `awk` matches the literal catch-all line and inserts two lines before each match:

```
SURFACE="<surface>"            # DNS label, e.g. landing or prototype
PORT="<port>"                  # local port the dev server / file server binds to
HOSTNAME="${SURFACE}.${BRAND_HOSTNAME}"

# Idempotency — skip if this hostname is already in config.yml.
if grep -qE "^[[:space:]]*-[[:space:]]+hostname:[[:space:]]+${HOSTNAME}\b" "${CFG_DIR}/config.yml"; then
  echo "ingress-row-exists hostname=${HOSTNAME}"
else
  awk -v host="${HOSTNAME}" -v port="${PORT}" '
    /^[[:space:]]*-[[:space:]]+service:[[:space:]]+http_status:404[[:space:]]*$/ {
      print "  - hostname: " host
      print "    service: http://localhost:" port
    }
    { print }
  ' "${CFG_DIR}/config.yml" > "${CFG_DIR}/config.yml.new"
  mv "${CFG_DIR}/config.yml.new" "${CFG_DIR}/config.yml"
fi
```

After the edit the file looks like:

```yaml
tunnel: <TUNNEL_ID>
credentials-file: <CFG_DIR>/<TUNNEL_ID>.json
ingress:
  - hostname: admin.<BRAND_HOSTNAME>
    service: http://localhost:<PORT>
  - hostname: public.<BRAND_HOSTNAME>
    service: http://localhost:<PORT>
  - hostname: <surface>.<BRAND_HOSTNAME>
    service: http://localhost:<port>
  - service: http_status:404
```

### 2. Validate

```
cloudflared --origincert "${CFG_DIR}/cert.pem" \
  --config "${CFG_DIR}/config.yml" \
  tunnel ingress validate
```

Non-zero exit aborts the flow; cloudflared's stderr is the literal diagnostic.

### 3. Create the DNS row

```
TUNNEL_ID=$(jq -r '.id // .tunnelId' "${CFG_DIR}/tunnel.state")
cloudflared --origincert "${CFG_DIR}/cert.pem" \
  tunnel route dns --overwrite-dns "${TUNNEL_ID}" "${HOSTNAME}"
```

`--overwrite-dns` replaces any existing CNAME for the hostname (common after a prior install on the same account). Without it, `tunnel route dns` errors `record already exists`.

### 4. Reload the connector

```
systemctl --user restart "${BRAND}-cloudflared.service"
```

Cloudflared does not watch `config.yml` for changes (the brand service unit launches it without `--config-watch`). Restart is the deterministic way to pick up the new rule; the brief ~1s connector blip is the same one every cloudflared upgrade incurs.

### Verification

```
curl -I https://<surface>.<BRAND_HOSTNAME>
```

Anything other than `HTTP/2 200` (or the dev server's natural 200/304 response) means either the rule didn't land, the systemd unit serving `<port>` isn't running, or DNS hasn't propagated. Run `cloudflared --origincert "${CFG_DIR}/cert.pem" --config "${CFG_DIR}/config.yml" tunnel ingress validate` and `dig <surface>.<BRAND_HOSTNAME>` to isolate which.

### Removing a prototype hostname

Manual for now (no scripted removal — the `prototype-host` skill does not yet ship a remove companion):

```
# 1. Stop and disable the service unit.
systemctl --user disable --now "${BRAND}-prototype-<surface>.service"

# 2. Strip the ingress row from config.yml — match the literal hostname line plus the following service line.
awk -v host="${SURFACE}.${BRAND_HOSTNAME}" '
  $0 ~ "^[[:space:]]*-[[:space:]]+hostname:[[:space:]]+" host "[[:space:]]*$" { skip = 2; next }
  skip > 0 { skip--; next }
  { print }
' "${CFG_DIR}/config.yml" > "${CFG_DIR}/config.yml.new" && mv "${CFG_DIR}/config.yml.new" "${CFG_DIR}/config.yml"

# 3. Delete the DNS row (dashboard, since the cloudflared CLI has no "route dns remove").
#    Cloudflare → DNS → <zone> → delete CNAME for <surface>.<BRAND_HOSTNAME>.

# 4. Restart cloudflared.
systemctl --user restart "${BRAND}-cloudflared.service"
```

---

## Full reset

If everything is in a bad state and you want to start over. Assumes Step 0 has been run so `${BRAND}` and `${CFG_DIR}` are set.

Find the tunnel name:

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel list
```

Delete the tunnel (replace `<name>`):

```
cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel delete <name>
```

Remove the entire brand-scoped cloudflared directory:

```
rm -rf "${CFG_DIR}"
```

Order matters: delete the tunnel *before* removing cert.pem (delete needs the cert). Then start again from Step 0.

If the service was installed (Step 7), also uninstall it:

```
sudo cloudflared service uninstall
```

---

## Troubleshooting — admin hostname serves the public agent

If `admin.<yourdomain>` is rendering the public-agent UI, the tunnel is fine but the platform UI is treating the admin hostname as public. The platform UI classifies a host as public when either:

- the hostname starts with `public.`, or
- the hostname appears in `${HOME}/.${BRAND}/alias-domains.json` (note: brand root, **not** `${CFG_DIR}` — `alias-domains.json` lives one level above `cloudflared/`, because that's where the platform UI watches).

Pre-Task-548 sessions populated this file via the now-deleted `tunnel-add-hostname` MCP tool, which wrote every routed hostname — including `admin.*` — into the alias set. The pollution persists across installs.

**Diagnose:**

```
cat "${HOME}/.${BRAND}/alias-domains.json"
```

If `admin.<yourdomain>` appears in the array, remove it:

```
jq --arg H "admin.<yourdomain>" 'map(select(. != $H))' "${HOME}/.${BRAND}/alias-domains.json" > /tmp/alias.json && mv /tmp/alias.json "${HOME}/.${BRAND}/alias-domains.json"
```

Replace `<yourdomain>` with your actual domain. The platform UI watches the file — no restart required; the next request to the admin hostname routes to the admin surface.

Leave legitimate public aliases (e.g. `maxy.chat`, or `<yourdomain>` root acting as public) in the file.
