diff --git a/app/src/components/ServiceCard.tsx b/app/src/components/ServiceCard.tsx
index f1c1a9b..f0ac803 100644
--- a/app/src/components/ServiceCard.tsx
+++ b/app/src/components/ServiceCard.tsx
@@ -5,7 +5,6 @@ import { useServiceAction, useSetEnabled } from "@/services/api/hooks"
import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge"
import { StackBadge } from "./StackBadge"
-import { KindBadge } from "./KindBadge"
interface ServiceCardProps {
service: ServiceSummary
@@ -27,7 +26,15 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
>
{service.id}
- {health ? (
+ {/* A static site is served from disk by the gateway, so it's inherently
+ "up" — show a green pill in the same slot as a service's health, rather
+ than a separate kind badge, so statics and services read consistently. */}
+ {service.kind === "static" ? (
+
+
+ static
+
+ ) : health ? (
) : hasHttp ? (
@@ -35,8 +42,6 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
- {/* A static (caddy-served) "service" is distinguished from a systemd one. */}
- {service.kind === "static" && }
diff --git a/app/src/components/detail/CreateDeploymentForm.tsx b/app/src/components/detail/CreateDeploymentForm.tsx
index 8d74fb0..f1dad1e 100644
--- a/app/src/components/detail/CreateDeploymentForm.tsx
+++ b/app/src/components/detail/CreateDeploymentForm.tsx
@@ -5,7 +5,7 @@ import { X } from "lucide-react"
import { apiClient } from "@/services/api/client"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
-import { Field, TextField } from "./fields"
+import { Field, TextField, PublicHostRadios } from "./fields"
const SELECT =
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
@@ -57,6 +57,9 @@ export function CreateDeploymentForm({
const [health, setHealth] = useState("/health")
const [proxy, setProxy] = useState(true)
const [isPublic, setIsPublic] = useState(false)
+ // Optional exact public FQDN (apex / another zone) overriding ..
+ const [publicHost, setPublicHost] = useState("")
+ const [publicMode, setPublicMode] = useState<"default" | "custom">("default")
const [schedule, setSchedule] = useState("0 2 * * *")
const [busy, setBusy] = useState(null)
const [error, setError] = useState("")
@@ -81,7 +84,14 @@ export function CreateDeploymentForm({
...(description ? { description } : {}),
}
if (kind === "tool") return { ...base, manager: "path" }
- if (kind === "static") return { ...base, manager: "caddy", root }
+ if (kind === "static") {
+ const cfg: Record = { ...base, manager: "caddy", root }
+ if (isPublic) {
+ cfg.reach = "public"
+ if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
+ }
+ return cfg
+ }
// systemd (service or job)
const cfg: Record = {
@@ -103,7 +113,10 @@ export function CreateDeploymentForm({
}
}
if (proxy) cfg.proxy = true
- if (proxy && isPublic) cfg.public = true
+ if (proxy && isPublic) {
+ cfg.public = true
+ if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
+ }
return cfg
}
@@ -231,6 +244,17 @@ export function CreateDeploymentForm({
)}
+ {proxy && isPublic && (
+
+ ", publicDomain)}
+ />
+
+ )}
>
)}
@@ -239,14 +263,33 @@ export function CreateDeploymentForm({
)}
{kind === "static" && (
-
+ <>
+
+ ", domain)}. Enable to also publish it to the internet via the Cloudflare tunnel.`}>
+
+
+ {isPublic && (
+
+ ", publicDomain)}
+ />
+
+ )}
+ >
)}
diff --git a/app/src/components/detail/ServiceFields.tsx b/app/src/components/detail/ServiceFields.tsx
index 3f3d64b..947c88f 100644
--- a/app/src/components/detail/ServiceFields.tsx
+++ b/app/src/components/detail/ServiceFields.tsx
@@ -2,7 +2,7 @@ import { useState } from "react"
import type { ServiceDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
-import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
+import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields"
interface Props {
@@ -77,6 +77,13 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
const [reach, setReach] = useState(
(m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"),
)
+ // Optional exact public FQDN (apex or another zone) that overrides the default
+ // .. Only applies when reach is public; `publicMode` picks
+ // default vs a custom host, and `publicHost` holds the custom value.
+ const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
+ const [publicMode, setPublicMode] = useState<"default" | "custom">(
+ m.public_host ? "custom" : "default",
+ )
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record)
const { element: requiresEditor, value: requiresValue } = useRequires(
@@ -111,6 +118,12 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
}
// reach needs a port to route through the gateway; without one it's off.
config.reach = port ? reach : "off"
+ // public_host only applies to a public service using a custom domain; else
+ // clear it (send null so the PATCH merge drops any stale override).
+ config.public_host =
+ config.reach === "public" && publicMode === "custom" && publicHost.trim()
+ ? publicHost.trim()
+ : null
}
delete config.proxy
delete config.public
@@ -201,7 +214,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
/>
{!port && (
set a port to expose
)}
+ {port && (
+
+ {reach === "off" && (
+ localhost:{port}
+ )}
+ {reach === "internal" && (
+
+ {gatewayHost(service.id, domain)}
+
+ )}
+ {reach === "public" && (
+
+ )}
+
+ )}
>
)}
{requiresEditor}
diff --git a/app/src/components/detail/StaticFields.tsx b/app/src/components/detail/StaticFields.tsx
index 1d6aba1..64616bf 100644
--- a/app/src/components/detail/StaticFields.tsx
+++ b/app/src/components/detail/StaticFields.tsx
@@ -2,7 +2,7 @@ import { useState } from "react"
import type { DeploymentDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
-import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
+import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields"
interface Props {
@@ -29,6 +29,11 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
const [isPublic, setIsPublic] = useState(
((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public",
)
+ // Optional exact public FQDN (apex or another zone) overriding ..
+ const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
+ const [publicMode, setPublicMode] = useState<"default" | "custom">(
+ m.public_host ? "custom" : "default",
+ )
const { element: envEditor, merged } = useEnvSecrets(
obj(obj(m.defaults).env) as Record,
)
@@ -46,6 +51,10 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
config.description = description || null
config.root = root || "dist"
config.reach = isPublic ? "public" : "internal"
+ // public_host only applies to a public site using a custom domain; else clear
+ // (null) so the merge drops any stale override.
+ config.public_host =
+ isPublic && publicMode === "custom" && publicHost.trim() ? publicHost.trim() : null
delete config.public
config.requires = requiresValue()
const env = merged()
@@ -73,7 +82,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
/>
+
+ {isPublic ? (
+
+ ) : (
+ {gatewayHost(dep.id, domain)}
+ )}
+
{requiresEditor}
{envEditor}
.` (shown
+ * read-only); `custom` reveals a text input for an exact FQDN (apex or another
+ * zone) that becomes the deployment's `public_host`. */
+export function PublicHostRadios({
+ mode,
+ onModeChange,
+ value,
+ onChange,
+ defaultHost,
+}: {
+ mode: "default" | "custom"
+ onModeChange: (m: "default" | "custom") => void
+ value: string
+ onChange: (v: string) => void
+ defaultHost: string
+}) {
+ return (
+
+ An exact hostname — an apex (example.com) or a name in another zone. The
+ Cloudflare token(s) need DNS:Edit on that zone.
+
+
+ )}
+
+ )
+}
+
// A value that is *exactly* a secret ref → fully editable via SecretsEditor.
const SECRET_RE = /^\$\{secret:([^}]+)\}$/
// A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These
diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py
index 7bf89a6..0914fe1 100644
--- a/castle-api/src/castle_api/routes.py
+++ b/castle-api/src/castle_api/routes.py
@@ -920,15 +920,18 @@ def get_gateway() -> GatewayInfo:
except FileNotFoundError:
pass
- # Which local deployments are public → their public URL (.).
- # Scan ALL deployments, not just `config.services` — a public *static* (caddy)
- # is not a service, so filtering to services dropped its public_url (calculator).
+ # Which local deployments are public → their public URL. A `public_host`
+ # override (apex / another zone) wins; otherwise ..
public_domain = registry.node.public_domain
- public_names = {
- name
- for _k, name, dep in (config.all_deployments() if config else [])
- if getattr(dep, "public", False)
- }
+
+ def _public_url(r) -> str | None:
+ if not getattr(r, "public", False):
+ return None
+ if r.public_host:
+ return f"https://{r.public_host}"
+ if public_domain:
+ return f"https://{r.name}.{public_domain}"
+ return None
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
routes = [
@@ -938,11 +941,7 @@ def get_gateway() -> GatewayInfo:
target=r.target,
name=r.name,
node=r.node or registry.node.hostname,
- public_url=(
- f"https://{r.name}.{public_domain}"
- if public_domain and r.name in public_names
- else None
- ),
+ public_url=_public_url(r),
)
for r in compute_routes(registry, config, remote or None)
]
diff --git a/cli/src/castle_cli/commands/delete.py b/cli/src/castle_cli/commands/delete.py
index 67cba85..8050d0f 100644
--- a/cli/src/castle_cli/commands/delete.py
+++ b/cli/src/castle_cli/commands/delete.py
@@ -163,17 +163,20 @@ def run_delete(args: argparse.Namespace) -> int:
def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]:
- """The Cloudflare CNAMEs (.) of any public deployments
- being removed — surfaced so the operator can clean up DNS."""
+ """The Cloudflare CNAMEs (a ``public_host`` override, else
+ .) of any public deployments being removed —
+ surfaced so the operator can clean up DNS."""
gw = getattr(config, "gateway", None)
public_domain = getattr(gw, "public_domain", None) if gw else None
- if not public_domain:
- return []
hosts: list[str] = []
for kind, d in deployments:
spec = config.deployment(kind, d)
if spec is None or not getattr(spec, "public", False):
continue
- sub = getattr(spec, "subdomain", None) or d
- hosts.append(f"{sub}.{public_domain}")
+ override = getattr(spec, "public_host", None)
+ if override:
+ hosts.append(override)
+ elif public_domain:
+ sub = getattr(spec, "subdomain", None) or d
+ hosts.append(f"{sub}.{public_domain}")
return hosts
diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py
index 361bb22..1887e08 100644
--- a/core/src/castle_core/generators/caddyfile.py
+++ b/core/src/castle_core/generators/caddyfile.py
@@ -41,6 +41,9 @@ class GatewayRoute:
name: str | None = None # backing program/service
node: str | None = None
public: bool = False # also served under public_domain
+ # Optional exact public-facing FQDN override (apex allowed). When set, the
+ # public name is this instead of the derived ..
+ public_host: str | None = None
@property
def is_host(self) -> bool:
@@ -67,15 +70,15 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
def _local_routes(
config: CastleConfig | None, registry: NodeRegistry
-) -> list[tuple[str, str, str, bool]]:
- """Each local deployment's route as ``(name, kind, target, public)``.
+) -> list[tuple[str, str, str, bool, str | None]]:
+ """Each local deployment's route as ``(name, kind, target, public, public_host)``.
``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or
``proxy`` (a proxied systemd process). Prefers ``castle.yaml``
(``config.deployments``) so a regenerated Caddyfile reflects the current spec;
falls back to the deployed registry snapshot when config isn't available.
"""
- out: list[tuple[str, str, str, bool]] = []
+ out: list[tuple[str, str, str, bool, str | None]] = []
if config is not None:
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
# jobs/tools/references never do.
@@ -87,20 +90,22 @@ def _local_routes(
if kind == "static" and isinstance(dep, CaddyDeployment):
src = _program_source(config, dep.program)
if src is not None:
- out.append((name, "static", str(src / dep.root), bool(dep.public)))
+ pub_host = dep.public_host if dep.public else None
+ out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host))
elif kind == "service" and isinstance(dep, SystemdDeployment):
expose, port, base_url = service_proxy_targets(name, dep)
if expose and (port or base_url):
- out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public)))
+ pub_host = dep.public_host if dep.public else None
+ out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host))
return out
# No config → route from the deployed registry snapshot.
for _kind, name, d in registry.all():
if not d.enabled:
continue
if d.static_root:
- out.append((name, "static", d.static_root, d.public))
+ out.append((name, "static", d.static_root, d.public, d.public_host))
elif d.subdomain and (d.port or d.base_url):
- out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public))
+ out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host))
return out
@@ -141,8 +146,8 @@ def compute_routes(
# 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, is_public in _local_routes(config, registry):
- routes.append(GatewayRoute(name, kind, target, name, node, is_public))
+ for name, kind, target, is_public, pub_host in _local_routes(config, registry):
+ routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host))
if remote_registries:
routes.extend(_remote_routes(config, registry, remote_registries))
@@ -238,6 +243,34 @@ def _host_static_block(label: str, host: str, serve_dir: str) -> list[str]:
]
+def _public_site_block(host: str, kind: str, target: str) -> list[str]:
+ """A standalone Caddy site for a custom ``public_host`` (apex or another zone).
+
+ Unlike the ``*.`` wildcard site, an apex/foreign host isn't
+ covered by a wildcard cert, so it gets its own site. Caddy issues that exact
+ host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's
+ Cloudflare token can edit the host's zone."""
+ if kind == "static":
+ body = [
+ f" root * {target}",
+ " try_files {path} /index.html",
+ " file_server",
+ ]
+ elif kind == "remote":
+ body = [
+ f" reverse_proxy {target} {{",
+ " lb_try_duration 1s",
+ " fail_duration 30s",
+ " transport http {",
+ " dial_timeout 2s",
+ " }",
+ " }",
+ ]
+ else:
+ body = [f" reverse_proxy {target}"]
+ return [f"{host} {{", *body, "}", ""]
+
+
# Castle's own control plane: the dashboard frontend and the API it calls. These
# names are the subdomains they're published at in acme mode, and the pair served
# on the : site in off mode (no domain → no subdomains).
@@ -308,14 +341,21 @@ def generate_caddyfile_from_registry(
lines += _host_matcher_block(r.name or r.address, host, r.target)
lines.append("}")
lines.append("")
- # Public domain: public services are also reachable under a separate zone
- # (e.g. domain0.org) so LAN clients can access them by their public name.
- # Central passes TLS through; Caddy obtains a separate wildcard cert.
+ # Public exposure: public deployments are also reachable by their public
+ # name so LAN clients can use it directly. Two shapes:
+ # - default → ., all served by one wildcard site
+ # (*., its own wildcard cert), when a node-wide
+ # public_domain distinct from the internal zone is configured.
+ # - override → an exact `public_host` (an apex or a name in another zone),
+ # which a wildcard can't cover, so each gets a standalone site with its
+ # own cert (DNS-01 via the global acme_dns).
public_domain = node.public_domain
public_routes = [r for r in routes if r.public]
- if public_domain and public_domain != domain and public_routes:
+ default_pub = [r for r in public_routes if not r.public_host]
+ custom_pub = [r for r in public_routes if r.public_host]
+ if public_domain and public_domain != domain and default_pub:
lines.append(f"*.{public_domain} {{")
- for r in public_routes:
+ for r in default_pub:
host = f"{r.address}.{public_domain}"
label = f"{r.name or r.address}_pub"
if r.kind == "static":
@@ -326,6 +366,9 @@ def generate_caddyfile_from_registry(
lines += _host_matcher_block(label, host, r.target)
lines.append("}")
lines.append("")
+ for r in custom_pub:
+ if r.public_host: # always true (custom_pub filter); narrows the type
+ lines += _public_site_block(r.public_host, r.kind, r.target)
# Redirect the bare gateway port to the dashboard subdomain.
lines += [
f":{gw_port} {{",
diff --git a/core/src/castle_core/generators/dns.py b/core/src/castle_core/generators/dns.py
index 43e9eaf..64a984a 100644
--- a/core/src/castle_core/generators/dns.py
+++ b/core/src/castle_core/generators/dns.py
@@ -1,13 +1,15 @@
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
-Castle owns the CNAMEs in the public zone that point at its Cloudflare tunnel:
-on deploy it creates one per public service and deletes any that point at this
-tunnel but no longer correspond to a public service. It **only ever touches
-records whose content is `.cfargotunnel.com`** — never other records in
-the zone — so a hand-managed A/CNAME in the same zone is safe.
+Castle owns the CNAMEs — across every zone the token can see — that point at its
+Cloudflare tunnel: on deploy it creates one per public host (each routed to the
+accessible zone whose name is its longest suffix, so apex and multi-zone hosts both
+work) and deletes any that point at this tunnel but no longer correspond to a
+public service. It **only ever touches records whose content is
+`.cfargotunnel.com`** — never other records in a zone — so a
+hand-managed A/CNAME in the same zone is safe.
-Needs a Cloudflare API token with **DNS:Edit** on the public zone (Cloudflare's
-"Edit zone DNS" template — that single permission both resolves the zone by name
+Needs a Cloudflare API token with **DNS:Edit** on every target zone (Cloudflare's
+"Edit zone DNS" template — that single permission both lists the accessible zones
and edits records; no separate Zone:Read is needed), stored at
`~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent → this is a no-op and the
caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
@@ -43,65 +45,100 @@ def _api(token: str, method: str, path: str, body: dict | None = None) -> dict:
return json.loads(resp.read())
+def _zone_for(host: str, zones: list[dict]) -> dict | None:
+ """The visible zone whose name is the longest suffix of ``host`` (or None).
+
+ Longest-suffix so an apex (``example.com`` in zone ``example.com``) and a
+ subdomain in any accessible zone both resolve, even when zones nest.
+ """
+ matches = [
+ z
+ for z in zones
+ if host == z["name"] or host.endswith("." + z["name"])
+ ]
+ return max(matches, key=lambda z: len(z["name"])) if matches else None
+
+
def reconcile_public_dns(
- public_domain: str | None,
tunnel_id: str | None,
desired_hosts: list[str],
messages: list[str],
token: str | None = None,
) -> bool:
- """Make the public zone's tunnel CNAMEs exactly `desired_hosts`.
+ """Make the tunnel CNAMEs across every accessible zone exactly `desired_hosts`.
- Creates missing CNAMEs (proxied → the tunnel) and deletes castle-managed ones
- (content == `.cfargotunnel.com`) not in `desired_hosts`. Never
- touches records pointing elsewhere.
+ Each desired host is routed to the accessible zone whose name is its longest
+ suffix (so apex hosts and hosts in different zones are handled), then per zone
+ castle creates missing CNAMEs (proxied → the tunnel; Cloudflare flattens apex
+ CNAMEs) and deletes castle-managed ones (content == `.cfargotunnel.com`)
+ no longer desired. Never touches records pointing elsewhere. Scanning every
+ visible zone also cleans up stale CNAMEs after a host moves zones or all public
+ services are removed.
Returns True if reconciliation was attempted (a token was configured) — the
caller then suppresses the manual route hints — or False if skipped (no token /
- no tunnel / no public domain), so the caller can fall back to those hints.
+ no tunnel), so the caller can fall back to those hints.
"""
token = token or public_dns_token()
- if not (token and public_domain and tunnel_id):
+ if not (token and tunnel_id):
return False
target = f"{tunnel_id}.cfargotunnel.com"
try:
- zres = (_api(token, "GET", f"/zones?name={public_domain}").get("result")) or []
- if not zres:
+ zones = (_api(token, "GET", "/zones?per_page=50").get("result")) or []
+ if not zones:
messages.append(
- f"Warning: DNS token can't see zone '{public_domain}' — public "
- "CNAMEs not reconciled. The token needs DNS:Edit (Cloudflare's "
- f"'Edit zone DNS' template) scoped to {public_domain}."
+ "Warning: DNS token can't see any zone — public CNAMEs not "
+ "reconciled. The token needs DNS:Edit (Cloudflare's 'Edit zone "
+ "DNS' template) on the target zone(s)."
)
- return False
- zone_id = zres[0]["id"]
- # Castle-managed set = existing CNAMEs whose content is our tunnel.
- recs = _api(
- token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
- ).get("result") or []
- managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
+ return True
- desired = set(desired_hosts)
- created = sorted(desired - set(managed))
- removed = sorted(set(managed) - desired)
- for host in created:
- _api(
- token,
- "POST",
- f"/zones/{zone_id}/dns_records",
- {"type": "CNAME", "name": host, "content": target, "proxied": True},
- )
- for host in removed:
- _api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
+ # Route each desired host to its zone (longest-suffix match). Hosts with no
+ # accessible zone can't be created — surface them rather than silently drop.
+ desired_by_zone: dict[str, set[str]] = {z["id"]: set() for z in zones}
+ for host in desired_hosts:
+ z = _zone_for(host, zones)
+ if z is None:
+ messages.append(
+ f"Warning: no accessible Cloudflare zone for public host "
+ f"'{host}' — its CNAME was not created. The DNS token needs "
+ f"DNS:Edit on that host's zone."
+ )
+ continue
+ desired_by_zone[z["id"]].add(host)
+
+ created: list[str] = []
+ removed: list[str] = []
+ # Reconcile every visible zone (not just those with desired hosts) so a
+ # CNAME orphaned by a host moving zones / going internal is cleaned up.
+ for z in zones:
+ zone_id = z["id"]
+ recs = _api(
+ token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
+ ).get("result") or []
+ managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
+ desired = desired_by_zone[zone_id]
+ for host in sorted(desired - set(managed)):
+ _api(
+ token,
+ "POST",
+ f"/zones/{zone_id}/dns_records",
+ {"type": "CNAME", "name": host, "content": target, "proxied": True},
+ )
+ created.append(host)
+ for host in sorted(set(managed) - desired):
+ _api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
+ removed.append(host)
if created or removed:
parts = []
if created:
- parts.append(f"+{len(created)} ({', '.join(created)})")
+ parts.append(f"+{len(created)} ({', '.join(sorted(created))})")
if removed:
- parts.append(f"-{len(removed)} ({', '.join(removed)})")
+ parts.append(f"-{len(removed)} ({', '.join(sorted(removed))})")
messages.append(f"Public DNS reconciled: {' '.join(parts)}")
else:
- messages.append(f"Public DNS up to date ({len(desired)} CNAME(s)).")
+ messages.append(f"Public DNS up to date ({len(desired_hosts)} CNAME(s)).")
return True
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:200]
diff --git a/core/src/castle_core/generators/tunnel.py b/core/src/castle_core/generators/tunnel.py
index 093ac60..2056583 100644
--- a/core/src/castle_core/generators/tunnel.py
+++ b/core/src/castle_core/generators/tunnel.py
@@ -37,21 +37,42 @@ def tunnel_credentials_path(tunnel_id: str) -> Path:
return TUNNEL_CREDENTIALS_DIR / f"{tunnel_id}.json"
+def public_fqdn(d: Deployment, node) -> str | None:
+ """The public-facing hostname for a deployment, or None if it has none.
+
+ A deployment may override its public name with an exact FQDN (``public_host`` —
+ an apex like ``example.com`` or a name in another zone); otherwise it publishes
+ at ``.`` using the node-wide default public domain.
+ None when neither an override nor a default public domain is available.
+ """
+ if d.public_host:
+ return d.public_host
+ if node.public_domain and d.subdomain:
+ return f"{d.subdomain}.{node.public_domain}"
+ return None
+
+
def public_deployments(registry: NodeRegistry) -> list[tuple[str, Deployment]]:
"""The deployed services flagged public (and actually routed), name-sorted."""
return sorted(
- (name, d)
- for _kind, name, d in registry.all()
- if d.public and d.subdomain
+ (
+ (name, d)
+ for _kind, name, d in registry.all()
+ if d.public and d.subdomain
+ ),
+ key=lambda nd: nd[0],
)
def public_hostnames(registry: NodeRegistry) -> list[str]:
- """The public hostnames that need a DNS route (``.``)."""
- dom = registry.node.public_domain
- if not dom:
- return []
- return [f"{d.subdomain}.{dom}" for _, d in public_deployments(registry)]
+ """The public hostnames that need a DNS route.
+
+ Each is either a per-deployment ``public_host`` override or the default
+ ``.``; deployments with neither are skipped.
+ """
+ node = registry.node
+ hosts = [public_fqdn(d, node) for _, d in public_deployments(registry)]
+ return [h for h in hosts if h]
def generate_tunnel_config(registry: NodeRegistry) -> str | None:
@@ -62,7 +83,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
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):
+ # A public deployment needs a tunnel + an internal host to bridge to; the
+ # node-wide public_domain is only the *default* public name, so it isn't
+ # required (a deployment may carry its own public_host override instead).
+ if not (node.tunnel_id and node.gateway_domain):
return None
pubs = public_deployments(registry)
if not pubs:
@@ -70,7 +94,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
ingress: list[dict] = []
for _name, d in pubs:
- public_host = f"{d.subdomain}.{node.public_domain}"
+ public_host = public_fqdn(d, node)
+ if not public_host:
+ # public but no override and no default public domain — nothing to map.
+ continue
internal_host = f"{d.subdomain}.{node.gateway_domain}"
ingress.append(
{
@@ -84,6 +111,9 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
},
}
)
+ if not ingress:
+ # Every public deployment was skipped (no override, no default domain).
+ return None
# Cloudflared requires a terminal catch-all; anything unmapped is refused.
ingress.append({"service": "http_status:404"})
diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py
index f8d653b..9f3cc6b 100644
--- a/core/src/castle_core/manifest.py
+++ b/core/src/castle_core/manifest.py
@@ -33,6 +33,28 @@ class Reach(str, Enum):
PUBLIC = "public"
+def _validate_public_host(host: str | None, reach: Reach) -> None:
+ """Validate an optional ``public_host`` override on an exposable deployment.
+
+ A ``public_host`` only makes sense for a publicly-projected deployment, and
+ must be a bare hostname (no scheme, path, port, or whitespace) — it becomes a
+ tunnel ingress ``hostname`` and a Caddy site address verbatim.
+ """
+ if host is None:
+ return
+ if reach != Reach.PUBLIC:
+ raise ValueError(
+ f"public_host is only valid with reach: public (got reach: {reach.value})"
+ )
+ bad = any(c.isspace() for c in host) or any(
+ tok in host for tok in ("://", "/", ":")
+ )
+ if not host or host != host.strip(".") or bad:
+ raise ValueError(
+ f"public_host must be a bare hostname (e.g. example.com), got {host!r}"
+ )
+
+
# ---------------------
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
# ---------------------
@@ -420,6 +442,11 @@ class SystemdDeployment(DeploymentBase):
expose: ExposeSpec | None = None
# How far this process is exposed (off | internal | public). See `Reach`.
reach: Reach = Reach.OFF
+ # Optional public hostname override (an exact FQDN, e.g. `api.example.com` or an
+ # apex `example.com`). Only meaningful with `reach: public`; when set it is the
+ # public-facing name instead of the derived `.`.
+ # Unset → the node-wide public domain is the default. See docs/tunnel-setup.md.
+ public_host: str | None = None
manage: ManageSpec | None = None
@model_validator(mode="after")
@@ -448,6 +475,7 @@ class SystemdDeployment(DeploymentBase):
"reach: public for a raw-TCP service isn't supported yet "
"(see docs/tcp-exposure.md step 5); use reach: internal"
)
+ _validate_public_host(self.public_host, self.reach)
return self
# Derived, read-only back-compat accessors (not serialized) so existing
@@ -488,11 +516,15 @@ class CaddyDeployment(DeploymentBase):
# A static site is inherently served at its subdomain, so `reach` is
# `internal` or `public` (never `off`). `public` = also project via the tunnel.
reach: Reach = Reach.INTERNAL
+ # Optional public hostname override (exact FQDN, apex allowed). Only meaningful
+ # with `reach: public`; see SystemdDeployment.public_host.
+ public_host: str | None = None
@model_validator(mode="after")
def _validate_reach(self) -> CaddyDeployment:
if self.reach == Reach.OFF:
raise ValueError("a static (caddy) deployment is always served; reach must be internal|public")
+ _validate_public_host(self.public_host, self.reach)
return self
@property
diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py
index 7d9d13c..fe05dc0 100644
--- a/core/src/castle_core/registry.py
+++ b/core/src/castle_core/registry.py
@@ -83,6 +83,9 @@ class Deployment:
# Also projected to the public internet via the tunnel at
# .. Requires subdomain.
public: bool = False
+ # Optional public hostname override (exact FQDN, apex allowed). When set it is
+ # the public-facing name instead of the derived ..
+ public_host: str | None = None
# Raw-TCP exposure port (postgres, redis, …). Set → reachable at
# .: via bind + wildcard DNS (no Caddy route).
tcp_port: int | None = None
@@ -186,6 +189,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
health_path=comp_data.get("health_path"),
subdomain=comp_data.get("subdomain"),
public=comp_data.get("public", False),
+ public_host=comp_data.get("public_host"),
tcp_port=comp_data.get("tcp_port"),
static_root=comp_data.get("static_root"),
base_url=comp_data.get("base_url"),
@@ -263,6 +267,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["subdomain"] = comp.subdomain
if comp.public:
entry["public"] = comp.public
+ if comp.public_host:
+ entry["public_host"] = comp.public_host
if comp.tcp_port is not None:
entry["tcp_port"] = comp.tcp_port
if comp.static_root:
diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py
index 731f2f7..e23a995 100644
--- a/core/tests/test_caddyfile.py
+++ b/core/tests/test_caddyfile.py
@@ -41,6 +41,7 @@ def _make_registry(
gateway_tls: str | None = None,
gateway_domain: str | None = None,
acme_email: str | None = None,
+ public_domain: str | None = None,
) -> NodeRegistry:
reg = NodeRegistry(
node=NodeConfig(
@@ -49,6 +50,7 @@ def _make_registry(
gateway_tls=gateway_tls,
gateway_domain=gateway_domain,
acme_email=acme_email,
+ public_domain=public_domain,
),
)
for name, d in (deployed or {}).items():
@@ -68,9 +70,14 @@ def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "p
)
-def _acme(deployed: dict[str, Deployment], domain: str | None = "example.com") -> NodeRegistry:
+def _acme(
+ deployed: dict[str, Deployment],
+ domain: str | None = "example.com",
+ public_domain: str | None = None,
+) -> NodeRegistry:
return _make_registry(
- gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com", deployed=deployed
+ gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com",
+ public_domain=public_domain, deployed=deployed,
)
@@ -133,6 +140,52 @@ class TestAcmeMode:
assert "file_server" in cf
+class TestPublicExposure:
+ """Public deployments are served under the public zone; a `public_host`
+ override (apex / other zone) gets its own standalone site with its own cert."""
+
+ def _static_pub(self, name: str, public_host: str | None = None) -> Deployment:
+ return Deployment(
+ manager="caddy", run_cmd=[], subdomain=name,
+ static_root=f"/data/repos/{name}/public", public=True, public_host=public_host,
+ )
+
+ def test_default_public_uses_wildcard_site(self) -> None:
+ reg = _acme({"blog": self._static_pub("blog")}, public_domain="pub.example.org")
+ cf = generate_caddyfile_from_registry(reg)
+ assert "*.pub.example.org {" in cf
+ assert "@host_blog_pub host blog.pub.example.org" in cf
+
+ def test_public_host_override_is_standalone_apex_site(self) -> None:
+ reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")},
+ public_domain="pub.example.org")
+ cf = generate_caddyfile_from_registry(reg)
+ # An explicit apex site (not under the *.pub wildcard) so Caddy issues its
+ # own cert via DNS-01; file_server serves the same local dir directly.
+ assert "payne.io {" in cf
+ assert "root * /data/repos/payne-io/public" in cf
+ # The override host must NOT appear as a *.pub.example.org subdomain.
+ assert "payne-io.pub.example.org" not in cf
+
+ def test_override_and_default_coexist(self) -> None:
+ reg = _acme(
+ {"blog": self._static_pub("blog"),
+ "payne-io": self._static_pub("payne-io", "payne.io")},
+ public_domain="pub.example.org",
+ )
+ cf = generate_caddyfile_from_registry(reg)
+ assert "*.pub.example.org {" in cf # default wildcard block still present
+ assert "@host_blog_pub host blog.pub.example.org" in cf
+ assert "payne.io {" in cf # standalone apex site
+
+ def test_public_host_without_default_domain(self) -> None:
+ # No node-wide public_domain: only the override host gets a site.
+ reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")})
+ cf = generate_caddyfile_from_registry(reg)
+ assert "payne.io {" in cf
+ assert "*.None" not in cf
+
+
class TestOffMode:
"""No domain → HTTP-only control plane on :: dashboard at / + /api → castle-api.
Other services are port-only (not routed)."""
diff --git a/core/tests/test_dns.py b/core/tests/test_dns.py
new file mode 100644
index 0000000..efa8c07
--- /dev/null
+++ b/core/tests/test_dns.py
@@ -0,0 +1,110 @@
+"""Tests for multi-zone public DNS (Cloudflare CNAME) reconciliation."""
+
+from __future__ import annotations
+
+import castle_core.generators.dns as dns
+from castle_core.generators.dns import _zone_for, reconcile_public_dns
+
+TID = "tid-abc"
+TARGET = f"{TID}.cfargotunnel.com"
+
+ZONES = [
+ {"id": "z_payne", "name": "payne.io"},
+ {"id": "z_ex", "name": "example.org"},
+]
+
+
+class _FakeCloudflare:
+ """A minimal fake of the Cloudflare API used by reconcile_public_dns.
+
+ ``records`` maps zone_id -> {name: (record_id, content)}. Records POST/DELETE
+ calls so tests can assert exactly what castle created/removed.
+ """
+
+ def __init__(self, records: dict[str, dict[str, tuple[str, str]]]):
+ self.records = records
+ self.created: list[tuple[str, str]] = [] # (zone_id, name)
+ self.deleted: list[tuple[str, str]] = [] # (zone_id, record_id)
+ self._n = 0
+
+ def api(self, token: str, method: str, path: str, body: dict | None = None) -> dict:
+ if method == "GET" and path.startswith("/zones?"):
+ return {"result": ZONES}
+ if method == "GET" and "/dns_records" in path:
+ zone_id = path.split("/zones/")[1].split("/")[0]
+ recs = self.records.get(zone_id, {})
+ return {
+ "result": [
+ {"id": rid, "name": name, "content": content, "type": "CNAME"}
+ for name, (rid, content) in recs.items()
+ ]
+ }
+ if method == "POST":
+ zone_id = path.split("/zones/")[1].split("/")[0]
+ assert body is not None
+ self.created.append((zone_id, body["name"]))
+ return {"result": {"id": f"new{self._n}"}}
+ if method == "DELETE":
+ zone_id = path.split("/zones/")[1].split("/")[0]
+ rid = path.rsplit("/", 1)[1]
+ self.deleted.append((zone_id, rid))
+ return {"result": {"id": rid}}
+ raise AssertionError(f"unexpected call {method} {path}")
+
+
+def _run(fake: _FakeCloudflare, desired: list[str], monkeypatch) -> list[str]:
+ monkeypatch.setattr(dns, "_api", fake.api)
+ messages: list[str] = []
+ ok = reconcile_public_dns(TID, desired, messages, token="tok")
+ assert ok is True
+ return messages
+
+
+def test_longest_suffix_routes_host_to_its_zone() -> None:
+ assert _zone_for("payne.io", ZONES)["id"] == "z_payne" # apex
+ assert _zone_for("api.payne.io", ZONES)["id"] == "z_payne" # subdomain
+ assert _zone_for("app.example.org", ZONES)["id"] == "z_ex"
+ assert _zone_for("nope.other.net", ZONES) is None # no visible zone
+
+
+def test_creates_apex_and_subdomain_in_correct_zones(monkeypatch) -> None:
+ fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}})
+ _run(fake, ["payne.io", "app.example.org"], monkeypatch)
+ assert set(fake.created) == {("z_payne", "payne.io"), ("z_ex", "app.example.org")}
+ assert fake.deleted == []
+
+
+def test_deletes_managed_cname_no_longer_desired(monkeypatch) -> None:
+ # z_payne has a stale castle-managed CNAME + a hand-managed one pointing elsewhere.
+ fake = _FakeCloudflare(records={
+ "z_payne": {
+ "payne.io": ("r1", TARGET), # castle-managed, still desired
+ "old.payne.io": ("r2", TARGET), # castle-managed, now stale → delete
+ "keep.payne.io": ("r3", "other.example.com"), # NOT ours → never touched
+ },
+ "z_ex": {},
+ })
+ _run(fake, ["payne.io"], monkeypatch)
+ assert fake.created == []
+ assert fake.deleted == [("z_payne", "r2")] # only the stale managed one
+
+
+def test_empty_desired_cleans_all_managed(monkeypatch) -> None:
+ fake = _FakeCloudflare(records={
+ "z_payne": {"payne.io": ("r1", TARGET)},
+ "z_ex": {"a.example.org": ("r2", TARGET)},
+ })
+ _run(fake, [], monkeypatch)
+ assert set(fake.deleted) == {("z_payne", "r1"), ("z_ex", "r2")}
+
+
+def test_no_token_returns_false(monkeypatch) -> None:
+ monkeypatch.setattr(dns, "public_dns_token", lambda: None)
+ assert reconcile_public_dns(TID, ["payne.io"], [], token=None) is False
+
+
+def test_unresolvable_host_warns_but_still_reconciles_others(monkeypatch) -> None:
+ fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}})
+ msgs = _run(fake, ["payne.io", "x.unknown.net"], monkeypatch)
+ assert fake.created == [("z_payne", "payne.io")]
+ assert any("unknown.net" in m for m in msgs)
diff --git a/core/tests/test_manifest.py b/core/tests/test_manifest.py
index 0c4f11e..7b63446 100644
--- a/core/tests/test_manifest.py
+++ b/core/tests/test_manifest.py
@@ -176,6 +176,40 @@ class TestSystemdDeployment:
}
)
+ def test_public_host_override_accepted_with_reach_public(self) -> None:
+ base = dict(
+ manager="systemd",
+ run=RunCommand(launcher="command", argv=["x"]),
+ expose={"http": {"internal": {"port": 9001}}},
+ )
+ s = SystemdDeployment.model_validate(
+ {**base, "reach": "public", "public_host": "payne.io"}
+ )
+ assert s.public_host == "payne.io"
+
+ def test_public_host_requires_reach_public(self) -> None:
+ """public_host without reach: public is a no-op; reject it at load."""
+ base = dict(
+ manager="systemd",
+ run=RunCommand(launcher="command", argv=["x"]),
+ expose={"http": {"internal": {"port": 9001}}},
+ )
+ with pytest.raises(ValueError, match="public_host is only valid"):
+ SystemdDeployment.model_validate(
+ {**base, "reach": "internal", "public_host": "payne.io"}
+ )
+
+ def test_public_host_must_be_bare_hostname(self) -> None:
+ base = dict(
+ manager="systemd",
+ run=RunCommand(launcher="command", argv=["x"]),
+ expose={"http": {"internal": {"port": 9001}}},
+ reach="public",
+ )
+ for bad in ("https://payne.io", "payne.io/path", "payne.io:443", "payne .io"):
+ with pytest.raises(ValueError, match="bare hostname"):
+ SystemdDeployment.model_validate({**base, "public_host": bad})
+
def test_no_run_is_invalid(self) -> None:
"""A systemd deployment requires a run (launch) spec."""
with pytest.raises(Exception):
diff --git a/core/tests/test_resolution.py b/core/tests/test_resolution.py
index 4bd3bea..4c37f21 100644
--- a/core/tests/test_resolution.py
+++ b/core/tests/test_resolution.py
@@ -46,6 +46,31 @@ class TestSupabaseStackResolution:
assert "install" in actions and "uninstall" in actions
+class TestHugoStackResolution:
+ def test_hugo_provides_only_build_verbs(self) -> None:
+ """Hugo is build-only: it resolves build/install/uninstall but NOT the
+ lint/test/type-check/check verbs it has no native tooling for."""
+ p = ProgramSpec.model_validate({"source": "/tmp/x", "stack": "hugo"})
+ actions = available_actions(p)
+ assert "build" in actions
+ assert "install" in actions and "uninstall" in actions
+ for absent in ("lint", "test", "type-check", "check"):
+ assert absent not in actions
+
+ def test_declared_command_still_overrides_hugo(self) -> None:
+ """A hugo program can still declare a verb the stack doesn't provide —
+ declared commands are resolved regardless of the handler's `provides`."""
+ p = ProgramSpec.model_validate(
+ {
+ "source": "/tmp/x",
+ "stack": "hugo",
+ "commands": {"test": [["htmltest"]]},
+ }
+ )
+ actions = available_actions(p)
+ assert "test" in actions and "build" in actions
+
+
class TestResolution:
def test_stack_only_program_unchanged(self) -> None:
"""A program with a stack and no commands resolves all stack verbs."""
@@ -61,7 +86,11 @@ class TestResolution:
p = ProgramSpec.model_validate(
{
"source": "/tmp/y",
- "commands": {"lint": [["make", "lint"]], "test": [["make", "test"]], "run": [["./bin/y"]]},
+ "commands": {
+ "lint": [["make", "lint"]],
+ "test": [["make", "test"]],
+ "run": [["./bin/y"]],
+ },
}
)
actions = available_actions(p)
@@ -79,15 +108,24 @@ class TestResolution:
def test_hybrid_override_one_verb(self) -> None:
"""A stack program can override a single verb; the rest fall back to stack."""
p = ProgramSpec.model_validate(
- {"source": "/tmp/z", "stack": "python-cli", "commands": {"test": [["pytest", "-x"]]}}
+ {
+ "source": "/tmp/z",
+ "stack": "python-cli",
+ "commands": {"test": [["pytest", "-x"]]},
+ }
)
assert _declared_commands(p, "test") == [["pytest", "-x"]]
- assert _declared_commands(p, "build") is None # build still comes from the stack
+ assert (
+ _declared_commands(p, "build") is None
+ ) # build still comes from the stack
def test_build_declared_via_buildspec(self) -> None:
"""`build` is declared through BuildSpec.commands, not CommandsSpec."""
p = ProgramSpec.model_validate(
- {"source": "/tmp/w", "build": {"commands": [["make"]], "outputs": ["dist/"]}}
+ {
+ "source": "/tmp/w",
+ "build": {"commands": [["make"]], "outputs": ["dist/"]},
+ }
)
assert _declared_commands(p, "build") == [["make"]]
assert "build" in available_actions(p)
diff --git a/core/tests/test_tunnel.py b/core/tests/test_tunnel.py
index d52c216..5f72f4c 100644
--- a/core/tests/test_tunnel.py
+++ b/core/tests/test_tunnel.py
@@ -80,3 +80,43 @@ def test_public_static_frontend_gets_ingress() -> None:
hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"]
if "hostname" in r}
assert hosts == {"guestbook.pub.payne.io"}
+
+
+def test_public_host_override_used_as_ingress_hostname() -> None:
+ # An apex `public_host` overrides . for the public name,
+ # but the origin still bridges to the internal . host.
+ reg = _registry(deployed={
+ "payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
+ static_root="/data/repos/payne-io/public", public=True,
+ public_host="payne.io"),
+ })
+ cfg = yaml.safe_load(generate_tunnel_config(reg))
+ rules = {r["hostname"]: r for r in cfg["ingress"] if "hostname" in r}
+ assert set(rules) == {"payne.io"}
+ assert rules["payne.io"]["originRequest"]["httpHostHeader"] == "payne-io.civil.payne.io"
+ assert public_hostnames(reg) == ["payne.io"]
+
+
+def test_public_host_default_and_override_coexist() -> None:
+ reg = _registry(deployed={
+ "app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
+ subdomain="app", public=True),
+ "payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
+ static_root="/d/public", public=True, public_host="payne.io"),
+ })
+ assert set(public_hostnames(reg)) == {"app.pub.payne.io", "payne.io"}
+
+
+def test_public_host_works_without_default_public_domain() -> None:
+ # A deployment with its own public_host publishes even if the node has no
+ # default public_domain; the plain public service (no override) is skipped.
+ reg = _registry(public_domain=None, deployed={
+ "app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
+ subdomain="app", public=True),
+ "payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
+ static_root="/d/public", public=True, public_host="payne.io"),
+ })
+ assert public_hostnames(reg) == ["payne.io"]
+ cfg = yaml.safe_load(generate_tunnel_config(reg))
+ hosts = {r["hostname"] for r in cfg["ingress"] if "hostname" in r}
+ assert hosts == {"payne.io"}
diff --git a/docs/registry.md b/docs/registry.md
index 4ec3678..e94783e 100644
--- a/docs/registry.md
+++ b/docs/registry.md
@@ -370,6 +370,26 @@ to the backend root, so root-relative asset URLs and `window.location`-derived
WebSocket URLs just work (the failure mode of the old prefix-stripping `handle_path`
routes is gone). Caddy proxies WebSocket upgrades transparently.
+#### `public_host` — Publish on a different domain / apex (opt-in)
+
+`gateway.public_domain` is the **default** public zone. To project a specific
+deployment on a *different* domain, or at an **apex** (`payne.io`, which can't be a
+`.` subdomain), set an exact `public_host` FQDN on the deployment (only
+valid with `reach: public` / `public: true`):
+
+```yaml
+reach: public
+public_host: payne.io # exact hostname; overrides .
+```
+
+The tunnel origin still bridges to the internal `.` host, and
+the gateway also serves the custom host **LAN-direct** with its own DNS-01 cert. The
+public CNAME is reconciled into whichever accessible Cloudflare zone is the host's
+longest suffix, so the `CLOUDFLARE_PUBLIC_DNS_TOKEN` (and the gateway's
+`CLOUDFLARE_API_TOKEN`, for the cert) must have `DNS:Edit` on that zone. A
+deployment with `public_host` publishes even with no node-wide `public_domain`. Full
+prerequisites (tokens + LAN DNS for the apex): @docs/tunnel-setup.md.
+
**Gateway routes — one concept, three target kinds.** The gateway maps a public
**address** (always a subdomain host, `.`) to a **target**:
diff --git a/docs/tunnel-setup.md b/docs/tunnel-setup.md
index b39d618..444e463 100644
--- a/docs/tunnel-setup.md
+++ b/docs/tunnel-setup.md
@@ -129,6 +129,49 @@ Without the token, `castle apply` instead prints the exact command to run per ho
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: