# Public exposure via Cloudflare Tunnel Castle is LAN-only by default: services with `proxy: true` are reachable at `.` (e.g. `.civil.payne.io`) through split DNS, and nothing is on the public internet. This document sets up a **per-service public toggle** — flip `public: true` on a service and it's also reachable from the public internet at `.`, via a Cloudflare Tunnel. ## How it works - **Default private.** `public` defaults to `false`; a service is public only when you say so, and `public: true` requires `proxy: true` (it projects an already-routed subdomain). - **Separate public zone.** Public services publish at a *different* zone (`.pub.payne.io`), so internal subdomain names never appear in public DNS. - **The tunnel bridges public → internal.** `cloudflared` dials **outbound** to Cloudflare (no inbound holes, no public IP needed) and forwards each public hostname to the gateway on `:443`, rewriting the Host header and TLS SNI to the *internal* name so Caddy routes it and its wildcard cert validates. The public surface is exactly the set of `public: true` services — `castle apply` generates the ingress from the registry. - **One kill switch.** Stop `castle-tunnel` → instantly nothing is public. ``` foo.pub.payne.io ──(Cloudflare edge, public cert)──▶ cloudflared (outbound) └─▶ https://localhost:443, Host/SNI = foo.civil.payne.io ──▶ Caddy ──▶ service ``` ## One-time setup (owner steps — needs Cloudflare login) ```bash # 1. Install cloudflared (Debian/Ubuntu) — it's NOT in the default repos, so add # Cloudflare's apt repo first: curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \ | sudo tee /etc/apt/sources.list.d/cloudflared.list sudo apt-get update && sudo apt-get install -y cloudflared # 2. Authenticate and create the tunnel (opens a browser to pick the zone) cloudflared tunnel login cloudflared tunnel create castle # prints a tunnel UUID + writes creds JSON # 3. Move the credentials into Castle's secret store (path the generator expects) mkdir -p ~/.castle/secrets/cloudflared TID= mv ~/.cloudflared/$TID.json ~/.castle/secrets/cloudflared/$TID.json chmod 600 ~/.castle/secrets/cloudflared/$TID.json # 4. Tell Castle the public zone + tunnel id (in ~/.castle/castle.yaml) # gateway: # ... # public_domain: pub.payne.io # tunnel_id: ``` Then create the tunnel deployment at `~/.castle/deployments/castle-tunnel.yaml` (`manager: systemd` → kind: service): ```yaml description: Cloudflare tunnel — public exposure for public:true services manager: systemd run: launcher: command argv: - cloudflared - tunnel - --no-autoupdate - --config - /home/payne/.castle/artifacts/specs/cloudflared.yml - run manage: systemd: {} ``` Bring it online: ```bash castle apply # writes cloudflared.yml from public services castle apply castle-tunnel # start the tunnel ``` ## Using the toggle Mark a service public in its `deployments/.yaml`: ```yaml proxy: true # required — the service must be routed public: true # also expose at .pub.payne.io via the tunnel ``` Then just deploy: ```bash castle apply ``` `castle apply` regenerates `~/.castle/artifacts/specs/cloudflared.yml` from the current set of public services and restarts `castle-tunnel`. Flip `public` back to `false` (or remove it) and redeploy to un-expose — the hostname drops out of the ingress immediately. ### DNS: automatic (with a token) or manual Each public host needs a CNAME `.pub.payne.io → .cfargotunnel.com`. Castle can manage these for you. Create a Cloudflare API token with a single **`DNS:Edit`** permission scoped to the **public zone** — this is exactly Cloudflare's built-in *"Edit zone DNS"* template. That one permission is sufficient: it resolves the zone by name *and* creates/deletes records (no separate `Zone:Read` is needed — verified against `domain0.org`). An account-owned token (`cfat_…`) works (that's what this was confirmed with). Drop it into the secret store: ```bash printf %s "" > ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN chmod 600 ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN ``` With it present, every `castle apply` **reconciles** the public CNAMEs against the current public set — creating missing ones and deleting removed ones — and prints a one-line summary. It only ever touches records pointing at *this* tunnel (`.cfargotunnel.com`), so hand-managed records in the same zone are safe. This token is separate from `CLOUDFLARE_API_TOKEN` (which is the ACME token for the internal zone); the public zone is usually a different zone/account. Without the token, `castle apply` instead prints the exact command to run per host: ```bash cloudflared tunnel route dns .pub.payne.io ``` ### Publishing on a different domain (`public_host`) By default a public deployment is projected at `.` — `public_domain` is the *default* public zone. To publish a specific deployment on a **different** domain, or at an **apex** (e.g. `payne.io`, which can't be expressed as `.`), set an exact `public_host` on the deployment: ```yaml # deployments/statics/payne-io.yaml manager: caddy program: payne-io root: public reach: public public_host: payne.io # exact FQDN — overrides . ``` `public_host` is an exact hostname (no scheme/port/path) and only applies with `reach: public`. When set: - **Tunnel ingress** maps that hostname to the tunnel; the origin still bridges to the deployment's *internal* host (`.`), so Caddy routes it and the internal wildcard cert validates — same as the default path. - **Public DNS** reconcile routes the CNAME into whichever accessible Cloudflare zone is the longest suffix of the host (so `payne.io` lands in zone `payne.io`, `x.other.org` in `other.org`). It reconciles across **every** zone the `CLOUDFLARE_PUBLIC_DNS_TOKEN` can see, so the token must have `DNS:Edit` on each target zone. Cloudflare flattens the apex CNAME automatically. - **LAN-direct HTTPS.** The gateway also serves the custom host directly as its own Caddy site, obtaining that host's cert via DNS-01 (the global `acme_dns`). This requires the gateway's `CLOUDFLARE_API_TOKEN` to have `DNS:Edit` on the host's zone too, and LAN DNS to resolve the host to this node — for an apex add e.g. `address=/payne.io/` on the LAN resolver (the `*.` wildcard doesn't cover a foreign apex). A deployment with `public_host` publishes even if no node-wide `public_domain` is configured. Prerequisites in one place, for `payne-io` → `https://payne.io`: | need | why | |------|-----| | `CLOUDFLARE_PUBLIC_DNS_TOKEN` has `DNS:Edit` on `payne.io` | the proxied apex CNAME → tunnel | | `CLOUDFLARE_API_TOKEN` (gateway) has `DNS:Edit` on `payne.io` | the LAN-direct apex cert (DNS-01) | | LAN DNS `address=/payne.io/` | LAN browsers resolve the apex to the gateway | ## The part that isn't the tunnel Reachability is the easy half. Anything public also needs, per service: - **Auth.** "Public" rarely means "no auth." Put login in front — Cloudflare Access at the edge, an auth proxy, or the app's own (Supabase GoTrue). A public app whose privacy matters must actually enforce it (auth-gated shell + signed storage URLs, not just row-level security). - **Hardening.** Rate-limiting / WAF (free on Cloudflare), and never expose admin or substrate-Studio surfaces. ## Notes - Requires `gateway.tls: acme` (the tunnel forwards to the gateway's real-cert `:443` host sites). On an `off` (plain-HTTP) gateway the origin bridge doesn't apply. - Cloudflare terminates TLS at the edge (it can see plaintext). For a no-third-party-in-path variant, the same `public: true` model can drive a self-hosted VPS + WireGuard edge instead — the toggle and generator stay; only the transport changes.