A conceptual companion to registry.md's field-level gateway reference. Covers the two questions (resolve / trust) an internal-only LAN service must answer: - DNS: gateway-as-ingress, path-prefix vs host routes, LAN name resolution, wildcard records, split-horizon (internal names, no public A records), and the private-TLD vs public-subdomain zone styles with the worked router/wild-central topology. - TLS: the three gateway.tls modes (off / internal / acme) with when-to-use, the internal-CA distribution pain vs the acme DNS-01 "no client setup" win, the secure-context (WebCrypto) rationale, the put-a-service-on-HTTPS recipe, operational prerequisites (443/80 bind, plugin caddy, DNS token, staging), and a decision table. Linked from CLAUDE.md's docs list.
11 KiB
DNS & TLS in Castle
How services on a Castle node become reachable by name and trusted over HTTPS — while staying internal-only (no external exposure). This is the conceptual companion to the field-level gateway reference in registry.md.
Two independent questions decide whether https://foo.example/ works from a
browser on your LAN:
- Resolve — does the name
foo.examplepoint at the Castle node? (DNS) - Trust — is the certificate the node serves one the browser accepts? (TLS)
Castle answers #1 by leaning on your LAN's own DNS, and #2 with a per-node choice of three TLS modes. They're orthogonal: you pick a resolution strategy and a trust strategy, and any working combination is fine.
The gateway is the single ingress
Every reachable service goes through the Caddy gateway (:9000 by default). A
gateway route maps a public address to a target. Two address shapes matter
for DNS and TLS:
- path prefix (
/foo) — reached athttp://<node>:9000/foo/. Shares the node's own name/port; needs no per-service DNS. Caddy strips the prefix, so this only suits apps that don't assume they live at the origin root. - host route (
foo.lan,foo.example.com) — reached athttps://foo.…/. A whole hostname proxied to the backend root, nothing stripped. This is the shape that gets its own DNS name and its own TLS cert.
Rule of thumb: a service that needs HTTPS, a real origin, WebSockets, or root-relative asset URLs wants a host route. Path prefixes are for simple, prefix-agnostic backends. See registry.md for the failure modes of putting a root-based app under a stripped prefix.
Only host routes are the subject of the rest of this document — they're what DNS and TLS act on.
DNS: making a name resolve to the node
A host route does nothing until foo.… resolves to this node on the clients
that will use it. Castle does not run DNS; it relies on whatever already serves
your LAN. Two facts shape the approach:
- Resolve on the clients that matter. A name only needs to resolve for the devices that browse it. That's usually your LAN's DHCP/DNS authority — often the router — not a central or mesh resolver.
- One wildcard beats many records. A single wildcard entry routes every subdomain of a zone to the node, so each new host-routed service works with no further DNS edits.
Split-horizon: internal names, no public exposure
The names Castle serves resolve only inside the LAN. For a private zone this is
automatic; for a public domain you own, it's deliberate split-horizon — your LAN
resolver answers with the node's private IP, and the public zone has no A
records for the services, so nothing is reachable from the internet.
Two zone styles
| Zone style | Example | Who's authoritative | Wildcard record |
|---|---|---|---|
| private TLD | *.civil.lan |
the LAN router/DHCP server (owns .lan) |
address=/civil.lan/<node-ip> |
| subdomain of a public domain | *.civil.payne.io |
your public DNS host (e.g. Cloudflare), but answered internally by a LAN resolver | address=/civil.payne.io/<node-ip> |
Both give the same result — every *.<zone> name resolves to the node's LAN IP.
The difference is which TLS modes each can use (below): a private TLD like .lan
cannot get a publicly-trusted cert (it isn't a real domain), while a real
subdomain can.
Worked topology (this network)
- The router (
192.168.8.1, a GL.iNet box) owns the.lanzone: it auto-registers DHCP hostnames (civil.lan) and answers*.lan. Unknown.lannames are not forwarded upstream — the router keeps that zone to itself. A*.civil.lanwildcard therefore lives on the router. - The router forwards everything else (including
*.payne.io) to wild-central's dnsmasq. So a*.civil.payne.iowildcard lives on wild-central (/etc/dnsmasq.d/civil-payne.conf,address=/civil.payne.io/192.168.8.222), which the router already routes to.payne.io's public authority is Cloudflare, which holds noArecords for these names — socivil.payne.ioservices resolve on the LAN and nowhere else.
Pin the node's IP with a DHCP reservation — the wildcard hardcodes it, so a drifting dynamic lease would break every host route at once.
TLS: three trust modes
gateway.tls (in castle.yaml) picks how host routes are served. It's a per-node
choice; the modes are mutually exclusive.
gateway.tls |
What the browser gets | Client setup | Use when |
|---|---|---|---|
off (default) |
plain HTTP on :9000 |
none | you don't need HTTPS; localhost-only tools |
internal |
HTTPS from Caddy's local CA | install & trust the CA on every device | LAN with a private .lan zone, few devices you control |
acme |
HTTPS from a real Let's Encrypt wildcard | nothing | you own a domain; multiple devices (phones, etc.) |
off — plain HTTP
The gateway generates auto_https off and listens on a bare :9000. Reach it at
http://<node>:9000/. Simple, but a non-localhost HTTP page is not a browser
"secure context" (see below), and there's no encryption.
internal — Caddy's local CA
Each host route becomes its own tls internal HTTPS site, signed by a CA Caddy
generates on the node. Browsers get a real secure context — but only if they
trust that private CA, which means distributing the root cert to every device's
system/browser trust store. That's the catch: some platforms (notably Android
browsers, and Firefox everywhere, which uses its own store) make installing a
custom CA painful or impossible. Castle helps by exposing the public root at
GET /gateway/ca.crt with a dashboard download button — but the per-device trust
step is unavoidable, and it's why internal doesn't scale past a handful of
machines you fully control.
Good fit: a .lan zone (which can't get a public cert anyway) with a couple of
trusted laptops.
acme — real Let's Encrypt wildcard via DNS-01
The gateway obtains a genuine, publicly-trusted wildcard cert (*.<domain>)
from Let's Encrypt using a DNS-01 challenge. Every browser and phone trusts it
with zero setup — while the services stay internal-only.
How it stays internal:
- DNS-01 proves ownership without exposure. Caddy writes a transient
_acme-challenge.<domain>TXT record to the public zone via your DNS provider's API; Let's Encrypt reads it over public DNS and issues the cert. No inbound connection, no open port, no publicArecord for any service is ever needed. (HTTP-01 can't validate a wildcard, so DNS-01 — and thus a provider API token — is mandatory here.) - Only LAN DNS points at the node. The public zone stays
A-record-free; your LAN resolver answers*.<domain>with the private IP. Public internet sees nothing.
One *.<domain> site means a single cert covers every host route, and Caddy
auto-renews it — adding a service needs no new cert and no DNS-01 round trip.
Host-route subdomains are derived from the service name: a service opts into a
host route with proxy.caddy.host, and it's published at <service>.<domain>.
This is the recommended mode when you own a domain and want to reach services from arbitrary devices.
Why HTTPS at all — the secure-context requirement
Beyond eavesdropping protection, HTTPS unlocks browser capabilities gated to a
secure context: crypto.subtle (WebCrypto), service workers, and anything
built on them (device identity, end-to-end crypto). Browsers treat only https://
and http://localhost as secure — a plain-HTTP page on a LAN hostname is not,
so such apps break there. That's the concrete reason to move a host route to
internal or acme rather than leaving it on off.
Note: a host served over HTTPS has its own origin (https://foo.example, no
port). An app that allowlists origins, or an OAuth/token flow, must include the new
origin — moving a service between modes changes its origin.
Putting a service on trusted HTTPS — the recipe
- Give it a host route. In the service's
proxy.caddy, sethost:(drop anypath_prefix). The literal host value is used as-is ininternalmode; inacmemode the published name is derived as<service>.<domain>. - Make the name resolve. Add (or rely on) the LAN wildcard for the zone
(§DNS). Verify:
dig +short <service>.<zone>→ the node's IP. - Pick a trust mode on the gateway (
gateway.tls), plus the operational prerequisites for it (below). - Deploy & reload:
castle deployregenerates the Caddyfile and reloads Caddy. - Update the app's origin allowlist if it has one (§secure context).
Operational prerequisites
Both HTTPS modes need the gateway to bind privileged ports; acme also needs a
plugin-enabled Caddy and a DNS token.
- Bind
:443/:80. Caddy serves HTTPS on:443(and redirects:80). A user-level gateway can't bind privileged ports underNoNewPrivileges, so lower the floor once:net.ipv4.ip_unprivileged_port_start=80, persisted in/etc/sysctl.d/. (This beatssetcap, whichNoNewPrivileges=truewould void.) acmeonly — a DNS-plugin Caddy. Stock Caddy has no DNS-provider modules. Build one:./install.sh --with-dns-plugin=<provider>(usesxcaddy, installs to/usr/local/bin/caddy, which precedes the apt binary onPATH, so the gateway picks it up on the next deploy). Castle now owns updates to that binary.acmeonly — a provider API token. Store it as a secret (~/.castle/secrets/<TOKEN_NAME>, scope: the DNS provider's "edit DNS records" permission for your zone) and map it into the gateway service env inservices/castle-gateway.yaml(defaults.env), so Caddy reads it as{env.<TOKEN_NAME>}.castle deploywarns if the domain, env var, or secret is missing.acme— stage first. SetCASTLE_ACME_STAGING=1at deploy to use Let's Encrypt's staging CA (generous rate limits) while verifying issuance, then unset it and redeploy for a browser-trusted production cert.
Choosing a combination
| You have… | Zone (DNS) | Trust (TLS) | Result |
|---|---|---|---|
| a quick internal tool, HTTP is fine | path prefix or .lan host |
off |
http://node:9000/tool/ |
a .lan LAN, a couple of trusted machines |
*.node.lan on the router |
internal |
HTTPS, install the CA per device |
| a domain you own + many devices (phones) | *.sub.domain on the LAN resolver |
acme |
HTTPS, no client setup, internal-only |
The last row is the sweet spot for a multi-device personal LAN, and what this node
runs today: *.civil.payne.io (wild-central DNS) + a Let's Encrypt wildcard via
Cloudflare DNS-01, so e.g. https://openclaw.civil.payne.io/ is trusted on any
device with nothing to install.
See also
- registry.md —
proxy, gateway routes, and thegateway.tlsmodes — the field-level reference (Caddyfile shapes, exact config keys, the CA-download endpoint).