Checkpoint: Cloudflare tunnel + public toggle, and static-runner frontends

Two features built this session, committed together as a checkpoint before the
deployment-model refactor (they share files, so kept as one rollback point):

- Public exposure via Cloudflare tunnel: a `public` toggle on services (requires
  proxy), gateway.public_domain/tunnel_id config, a cloudflared ingress generator
  (generators/tunnel.py) that maps <name>.<public_domain> to the gateway's
  internal host, deploy wiring, and docs/tunnel-setup.md.
- Frontends as `static`-runner services: a RunStatic runner served by Caddy
  (file_server), no systemd unit (like `remote`); the Caddyfile generator now
  derives static routes from services instead of a behavior==frontend branch.
This commit is contained in:
2026-07-01 06:44:23 -07:00
parent 2cafcbb372
commit 5f6afbf007
10 changed files with 465 additions and 40 deletions

View File

@@ -118,6 +118,12 @@ class GatewayConfig:
domain: str | None = None
acme_email: str | None = None
acme_dns_provider: str = "cloudflare"
# Public exposure via the Cloudflare tunnel (optional). `public_domain` is the
# separate zone public services are published at (e.g. "pub.payne.io" →
# <service>.pub.payne.io), kept distinct from `domain` so internal subdomain
# names never appear in public DNS. `tunnel_id` is the cloudflared tunnel UUID.
public_domain: str | None = None
tunnel_id: str | None = None
@dataclass
@@ -265,6 +271,8 @@ def load_config(root: Path | None = None) -> CastleConfig:
domain=gateway_data.get("domain"),
acme_email=gateway_data.get("acme_email"),
acme_dns_provider=gateway_data.get("acme_dns_provider", "cloudflare"),
public_domain=gateway_data.get("public_domain"),
tunnel_id=gateway_data.get("tunnel_id"),
)
# repo: field points to the git repo for repo-relative sources

View File

@@ -28,6 +28,10 @@ from castle_core.generators.caddyfile import (
generate_caddyfile_from_registry,
service_proxy_targets,
)
from castle_core.generators.tunnel import (
generate_tunnel_config,
public_hostnames,
)
from castle_core.generators.systemd import (
SECRET_ENV_DIR,
generate_timer,
@@ -82,6 +86,8 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
gateway_domain=config.gateway.domain,
acme_email=config.gateway.acme_email,
acme_dns_provider=config.gateway.acme_dns_provider,
public_domain=config.gateway.public_domain,
tunnel_id=config.gateway.tunnel_id,
)
# Load existing registry to preserve entries not being redeployed,
@@ -134,6 +140,9 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
caddyfile_path.write_text(caddyfile_content)
result.messages.append(f"Caddyfile written: {caddyfile_path}")
# Generate the cloudflared tunnel ingress from the registry's public services.
_write_tunnel_config(registry, result.messages)
# acme mode needs a domain + a DNS-provider token; warn (don't fail) if the
# prerequisites the operator must set up by hand are missing.
if (config.gateway.tls or "").lower() == "acme":
@@ -184,6 +193,46 @@ def _acme_preflight(config: CastleConfig, messages: list[str]) -> None:
)
_TUNNEL_NAME = "tunnel" # the cloudflared service is castle-tunnel
def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
"""Write the cloudflared ingress config from the registry's public services.
No public services (or no tunnel configured) → remove any stale config and
leave the tunnel down. Otherwise write it, list the hostnames that still need a
DNS route, and restart the tunnel service if it's running so it takes effect.
"""
config_path = SPECS_DIR / "cloudflared.yml"
content = generate_tunnel_config(registry)
if content is None:
if config_path.exists():
config_path.unlink()
messages.append("No public services — removed cloudflared config.")
return
config_path.write_text(content)
hosts = public_hostnames(registry)
messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)")
# DNS is not automatic: each public host needs a CNAME → the tunnel. Surface the
# exact commands rather than silently assuming they're routed.
tid = registry.node.tunnel_id
for h in hosts:
messages.append(f" public: {h} (route once: cloudflared tunnel route dns {tid} {h})")
tunnel_unit = unit_name(_TUNNEL_NAME)
active = subprocess.run(
["systemctl", "--user", "is-active", tunnel_unit], capture_output=True, text=True
)
if active.stdout.strip() == "active":
subprocess.run(["systemctl", "--user", "restart", tunnel_unit], check=False)
messages.append("Tunnel reloaded.")
else:
messages.append(
"Tunnel not running — enable it with 'castle service enable tunnel'."
)
def _reload_gateway(messages: list[str]) -> None:
"""Reload Caddy if the gateway is running, so new routes take effect."""
gw_unit = unit_name(_GATEWAY_NAME)
@@ -287,14 +336,19 @@ def _build_deployed_service(
# /data/castle/protonmail). Falls back to the service name.
config_key = svc.program or name
managed = run.runner != "remote"
# `remote` and `static` are caddy-managed (or unmanaged) — no local process, no
# systemd unit. The gateway is their runtime.
managed = run.runner not in ("remote", "static")
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False
# Routing comes from the shared deriver, so the registry written here and the
# Caddyfile computed from castle.yaml stay in lockstep. `expose` is the
# checkbox; the subdomain is the service name.
# checkbox; the subdomain is the service name. A `static` service is inherently
# served (that's its whole purpose), so it's always exposed.
expose, port, base_url = service_proxy_targets(name, svc)
if run.runner == "static":
expose = True
health_path = None
if svc.expose and svc.expose.http:
health_path = svc.expose.http.health_path
@@ -329,6 +383,11 @@ def _build_deployed_service(
)
stop_cmd = _build_stop_cmd(name, run, source_dir)
# A static service serves <program-source>/<run.root> via the gateway.
static_root = None
if run.runner == "static" and source_dir is not None:
static_root = str(source_dir / run.root) # type: ignore[union-attr]
# Resolve stack from referenced program
stack = None
if svc.program and svc.program in config.programs:
@@ -346,6 +405,8 @@ def _build_deployed_service(
port=port,
health_path=health_path,
subdomain=(name if expose else None),
public=bool(getattr(svc, "public", False) and expose),
static_root=static_root,
base_url=base_url,
managed=managed,
)
@@ -549,6 +610,9 @@ def _build_run_cmd(
return cmd
case "remote":
return []
case "static":
# No process — the gateway file_servers this service's root.
return []
case _:
raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr]

View File

@@ -66,24 +66,46 @@ def service_proxy_targets(name: str, svc: ServiceSpec) -> ProxyTargets:
return expose, port, base_url
def _local_exposures(
def _local_routes(
config: CastleConfig | None, registry: NodeRegistry
) -> list[tuple[str, ProxyTargets]]:
"""Each local service's (expose?, port, base_url), name-sorted.
) -> list[tuple[str, str, str]]:
"""Each local service's route as ``(name, kind, target)``, name-sorted.
Prefers ``castle.yaml`` (``config.services``) as the source of truth so a
regenerated Caddyfile always reflects the current spec; falls back to the
deployed registry snapshot when config isn't available.
``kind`` is ``static`` (file-serve a built dir) or ``proxy`` (reverse-proxy a
port/base_url). Prefers ``castle.yaml`` (``config.services``) as the source of
truth so a regenerated Caddyfile always reflects the current spec; falls back to
the deployed registry snapshot when config isn't available.
"""
out: list[tuple[str, str, str]] = []
services = getattr(config, "services", None)
if services is not None:
return sorted(
(name, service_proxy_targets(name, svc)) for name, svc in services.items()
)
return sorted(
(name, (d.subdomain is not None, d.port, d.base_url))
for name, d in registry.deployed.items()
)
for name, svc in sorted(services.items()):
if svc.run.runner == "static":
src = _program_source(config, svc.program)
if src is not None:
out.append((name, "static", str(src / svc.run.root)))
continue
expose, port, base_url = service_proxy_targets(name, svc)
if expose and (port or base_url):
out.append((name, "proxy", base_url or f"localhost:{port}"))
return out
# No config → route from the deployed registry snapshot.
for name, d in sorted(registry.deployed.items()):
if d.static_root:
out.append((name, "static", d.static_root))
elif d.subdomain and (d.port or d.base_url):
out.append((name, "proxy", d.base_url or f"localhost:{d.port}"))
return out
def _program_source(config: CastleConfig | None, program: str | None):
"""Absolute source dir of a referenced program (already resolved), or None."""
if config is None or not program:
return None
prog = getattr(config, "programs", {}).get(program)
if prog and prog.source:
return Path(prog.source)
return None
def compute_routes(
@@ -107,24 +129,12 @@ def compute_routes(
node = registry.node.hostname
routes: list[GatewayRoute] = []
# Exposed services → a subdomain reverse-proxy (address = service name).
for name, (expose, port, base_url) in _local_exposures(config, registry):
if expose and (port or base_url):
target = base_url or f"localhost:{port}"
routes.append(GatewayRoute(name, "proxy", target, name, node))
# Static frontends — a behavior=frontend program with build outputs and no
# service of its own; served in place from <source>/<dist> at <name>.<domain>.
if config is not None:
for name, prog in sorted(config.programs.items()):
if prog.behavior != "frontend" or not prog.source:
continue
if not (prog.build and prog.build.outputs):
continue
if name in config.services: # self-serving frontend → already a proxy route
continue
serve_dir = str(Path(prog.source) / prog.build.outputs[0])
routes.append(GatewayRoute(name, "static", serve_dir, name, node))
# Every route comes from a service. `static`-runner services file-serve their
# built dir; everything else that's exposed reverse-proxies its port/base_url.
# (Static frontends are `runner: static` services now — no separate program
# branch, so routing derives from one place.)
for name, kind, target in _local_routes(config, registry):
routes.append(GatewayRoute(name, kind, target, name, node))
return routes

View File

@@ -0,0 +1,95 @@
"""Cloudflare tunnel (cloudflared) ingress config generation.
Projects ``public: true`` services onto the public internet at
``<subdomain>.<public_domain>``, bridging each to the gateway's existing internal
host site so the tunnel reuses all of Caddy's routing and TLS. The public zone and
the internal zone are deliberately different (``pub.payne.io`` vs
``civil.payne.io``) so internal subdomain names never appear in public DNS; the
ingress rewrites the Host header and TLS SNI to the *internal* name so Caddy routes
the request correctly and its wildcard cert validates.
The generated config is locally-managed (a ``config.yml`` with an explicit ingress
list), not a remotely-managed token tunnel — so the public surface is exactly the
set of ``public: true`` services, generated here, not something edited in a
dashboard.
"""
from __future__ import annotations
from pathlib import Path
import yaml
from castle_core.config import SECRETS_DIR
from castle_core.registry import Deployment, NodeRegistry
# Cloudflared tunnel credentials (from `cloudflared tunnel create`) live here as a
# secret, one JSON per tunnel id. The generated config references this path.
TUNNEL_CREDENTIALS_DIR = SECRETS_DIR / "cloudflared"
# In acme mode Caddy serves each host site on :443 with a publicly-valid cert, so
# the tunnel origin is the gateway on 443 (not the :<gateway_port> redirect site).
_GATEWAY_ORIGIN = "https://localhost:443"
def tunnel_credentials_path(tunnel_id: str) -> Path:
"""Path to a tunnel's credentials JSON (kept in the secret store)."""
return TUNNEL_CREDENTIALS_DIR / f"{tunnel_id}.json"
def public_deployments(registry: NodeRegistry) -> list[tuple[str, Deployment]]:
"""The deployed services flagged public (and actually routed), name-sorted."""
return sorted(
(name, d)
for name, d in registry.deployed.items()
if d.public and d.subdomain
)
def public_hostnames(registry: NodeRegistry) -> list[str]:
"""The public hostnames that need a DNS route (``<sub>.<public_domain>``)."""
dom = registry.node.public_domain
if not dom:
return []
return [f"{d.subdomain}.{dom}" for _, d in public_deployments(registry)]
def generate_tunnel_config(registry: NodeRegistry) -> str | None:
"""Render the cloudflared ``config.yml``, or None if there's nothing to serve.
Returns None when the tunnel isn't configured (no ``tunnel_id`` /
``public_domain`` / ``gateway_domain``) or no service is public — deploy then
removes any stale config and leaves the tunnel down.
"""
node = registry.node
if not (node.tunnel_id and node.public_domain and node.gateway_domain):
return None
pubs = public_deployments(registry)
if not pubs:
return None
ingress: list[dict] = []
for _name, d in pubs:
public_host = f"{d.subdomain}.{node.public_domain}"
internal_host = f"{d.subdomain}.{node.gateway_domain}"
ingress.append(
{
"hostname": public_host,
"service": _GATEWAY_ORIGIN,
# Bridge the public name to the internal host: Caddy routes by this
# Host and its wildcard cert is issued for this SNI.
"originRequest": {
"originServerName": internal_host,
"httpHostHeader": internal_host,
},
}
)
# Cloudflared requires a terminal catch-all; anything unmapped is refused.
ingress.append({"service": "http_status:404"})
config = {
"tunnel": node.tunnel_id,
"credentials-file": str(tunnel_credentials_path(node.tunnel_id)),
"ingress": ingress,
}
return yaml.dump(config, default_flow_style=False, sort_keys=False)

View File

@@ -76,8 +76,23 @@ class RunRemote(RunBase):
health_url: str | None = None
class RunStatic(RunBase):
"""A static site served by the gateway (Caddy ``file_server``), no process.
Like ``remote``, this is a service with no local process and no systemd unit —
the gateway *is* its runtime. ``root`` is the built directory to serve, resolved
relative to the referenced program's source (e.g. ``dist`` or ``public``).
Building that directory is the program's concern; this only serves it.
"""
runner: Literal["static"]
root: str = "dist" # served dir, relative to the program source
RunSpec = Annotated[
Union[RunCommand, RunPython, RunContainer, RunNode, RunCompose, RunRemote],
Union[
RunCommand, RunPython, RunContainer, RunNode, RunCompose, RunRemote, RunStatic
],
Field(discriminator="runner"),
]
@@ -255,6 +270,10 @@ class ServiceSpec(BaseModel):
# Expose this service at <service-name>.<gateway.domain> through the gateway
# (the subdomain is the service name). False → reachable only at its host:port.
proxy: bool = False
# Also expose this service to the public internet via the Cloudflare tunnel, at
# <service-name>.<gateway.public_domain>. Default False — public is opt-in and
# explicit. Requires proxy (the tunnel projects an already-routed subdomain).
public: bool = False
manage: ManageSpec | None = None
defaults: DefaultsSpec | None = None
@@ -263,6 +282,8 @@ class ServiceSpec(BaseModel):
if self.manage and self.manage.systemd and self.manage.systemd.enable:
if self.run.runner == "remote":
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
if self.public and not self.proxy:
raise ValueError("public requires proxy (a service must be routed to be exposed).")
return self

View File

@@ -27,6 +27,9 @@ class NodeConfig:
gateway_domain: str | None = None # acme: zone for wildcard cert + host subdomains
acme_email: str | None = None
acme_dns_provider: str = "cloudflare"
# Cloudflare tunnel: public services publish at <subdomain>.<public_domain>.
public_domain: str | None = None
tunnel_id: str | None = None
def __post_init__(self) -> None:
if not self.hostname:
@@ -55,6 +58,12 @@ class Deployment:
# Exposed at <subdomain>.<gateway.domain> (the subdomain is the service name),
# or None when the service is reachable only at its host:port.
subdomain: str | None = None
# Also projected to the public internet via the tunnel at
# <subdomain>.<gateway.public_domain>. Requires subdomain.
public: bool = False
# For `static` runner services: the absolute dir the gateway file_servers.
# Set → the route is `static` (file_server) rather than `proxy` (reverse_proxy).
static_root: str | None = None
base_url: str | None = None
schedule: str | None = None
managed: bool = False
@@ -94,6 +103,8 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
gateway_domain=node_data.get("gateway_domain"),
acme_email=node_data.get("acme_email"),
acme_dns_provider=node_data.get("acme_dns_provider", "cloudflare"),
public_domain=node_data.get("public_domain"),
tunnel_id=node_data.get("tunnel_id"),
)
deployed: dict[str, Deployment] = {}
@@ -123,6 +134,8 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
subdomain=comp_data.get("subdomain"),
public=comp_data.get("public", False),
static_root=comp_data.get("static_root"),
base_url=comp_data.get("base_url"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
@@ -157,6 +170,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
data["node"]["acme_email"] = registry.node.acme_email
if registry.node.acme_dns_provider and registry.node.acme_dns_provider != "cloudflare":
data["node"]["acme_dns_provider"] = registry.node.acme_dns_provider
if registry.node.public_domain:
data["node"]["public_domain"] = registry.node.public_domain
if registry.node.tunnel_id:
data["node"]["tunnel_id"] = registry.node.tunnel_id
for name, comp in registry.deployed.items():
entry: dict = {
@@ -180,6 +197,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["health_path"] = comp.health_path
if comp.subdomain:
entry["subdomain"] = comp.subdomain
if comp.public:
entry["public"] = comp.public
if comp.static_root:
entry["static_root"] = comp.static_root
if comp.base_url:
entry["base_url"] = comp.base_url
if comp.schedule:

View File

@@ -16,6 +16,7 @@ from castle_core.manifest import (
HttpInternal,
ProgramSpec,
RunPython,
RunStatic,
ServiceSpec,
)
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
@@ -105,17 +106,16 @@ class TestAcmeMode:
def test_static_frontend_is_a_file_server_subdomain(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Static frontends come from config.programs, so hand the generator a config.
# A static frontend is a `runner: static` service serving <source>/<root>.
import castle_core.config as config_mod
cfg = _config(
services={},
programs={
"castle": ProgramSpec(
behavior="frontend", source="/data/repos/castle/app",
build=BuildSpec(outputs=["dist"]),
services={
"castle": ServiceSpec(
program="castle", run=RunStatic(runner="static", root="dist")
)
},
programs={"castle": ProgramSpec(source="/data/repos/castle/app")},
)
monkeypatch.setattr(config_mod, "load_config", lambda *a, **k: cfg)
cf = generate_caddyfile_from_registry(_acme({}))

70
core/tests/test_tunnel.py Normal file
View File

@@ -0,0 +1,70 @@
"""Tests for cloudflared tunnel ingress generation."""
from __future__ import annotations
import yaml
from castle_core.generators.tunnel import (
generate_tunnel_config,
public_hostnames,
)
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
def _registry(
*,
tunnel_id: str | None = "tid-123",
public_domain: str | None = "pub.payne.io",
gateway_domain: str | None = "civil.payne.io",
deployed: dict[str, Deployment] | None = None,
) -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(
hostname="civil",
gateway_tls="acme",
gateway_domain=gateway_domain,
public_domain=public_domain,
tunnel_id=tunnel_id,
),
deployed=deployed
or {
"app": Deployment(runner="python", run_cmd=["x"], port=9001,
subdomain="app", public=True),
"private": Deployment(runner="python", run_cmd=["y"], port=9002,
subdomain="private", public=False),
},
)
def test_public_service_maps_public_zone_to_internal_host() -> None:
cfg = yaml.safe_load(generate_tunnel_config(_registry()))
assert cfg["tunnel"] == "tid-123"
rules = {r.get("hostname"): r for r in cfg["ingress"] if "hostname" in r}
# only the public service is mapped
assert set(rules) == {"app.pub.payne.io"}
r = rules["app.pub.payne.io"]
assert r["service"] == "https://localhost:443"
# public zone → internal host (Host + SNI rewritten so Caddy routes + cert validates)
assert r["originRequest"]["httpHostHeader"] == "app.civil.payne.io"
assert r["originRequest"]["originServerName"] == "app.civil.payne.io"
def test_terminal_catch_all_present() -> None:
cfg = yaml.safe_load(generate_tunnel_config(_registry()))
assert cfg["ingress"][-1] == {"service": "http_status:404"}
def test_private_service_not_in_public_dns() -> None:
assert public_hostnames(_registry()) == ["app.pub.payne.io"]
def test_none_when_no_public_services() -> None:
only_private = {
"x": Deployment(runner="python", run_cmd=["x"], subdomain="x", public=False)
}
assert generate_tunnel_config(_registry(deployed=only_private)) is None
def test_none_when_tunnel_unconfigured() -> None:
assert generate_tunnel_config(_registry(tunnel_id=None)) is None
assert generate_tunnel_config(_registry(public_domain=None)) is None

View File

@@ -279,6 +279,21 @@ service is reachable only at its own `host:port`.
proxy: true # expose at <service-name>.<gateway.domain>
```
### `public` — Also expose to the public internet (opt-in)
`public: true` additionally projects a proxied service to the public internet via a
Cloudflare tunnel, at **`<service-name>.<gateway.public_domain>`** (a separate zone,
so internal subdomain names stay out of public DNS). Defaults to `false` — public is
explicit — and **requires `proxy: true`**. `castle deploy` generates the cloudflared
ingress from the set of public services. Needs `gateway.public_domain` +
`gateway.tunnel_id` set and the `castle-tunnel` service running; see
@docs/tunnel-setup.md for the one-time setup.
```yaml
proxy: true
public: true # also reachable at <service-name>.<gateway.public_domain>
```
The subdomain is always the service name — there's nothing to customize (rename the
service to change it). There are **no path-prefix routes**: a whole subdomain maps
to the backend root, so root-relative asset URLs and `window.location`-derived

121
docs/tunnel-setup.md Normal file
View File

@@ -0,0 +1,121 @@
# Public exposure via Cloudflare Tunnel
Castle is LAN-only by default: services with `proxy: true` are reachable at
`<name>.<gateway.domain>` (e.g. `<name>.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 `<name>.<gateway.public_domain>`, 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
(`<name>.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 deploy` 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=<the-uuid-from-step-2>
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: <the-uuid>
```
Then create the tunnel service at `~/.castle/services/castle-tunnel.yaml`:
```yaml
description: Cloudflare tunnel — public exposure for public:true services
run:
runner: command
argv:
- cloudflared
- tunnel
- --no-autoupdate
- --config
- /home/payne/.castle/artifacts/specs/cloudflared.yml
- run
manage:
systemd: {}
```
Bring it online:
```bash
castle deploy # writes cloudflared.yml from public services
castle service enable castle-tunnel # start the tunnel
```
## Using the toggle
Mark a service public in its `services/<name>.yaml`:
```yaml
proxy: true # required — the service must be routed
public: true # also expose at <name>.pub.payne.io via the tunnel
```
Then deploy and route DNS (a CNAME `<name>.pub.payne.io → <tunnel>.cfargotunnel.com`,
created once per public host — `castle deploy` prints the exact command):
```bash
castle deploy
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io
```
`castle deploy` 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.
## 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`/`internal` 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.