Octa Engineering Notes Christian Elías Request for Comments: OCTA-23 octa.page Category: Informational October 2026 Our Own DNS: From a Committed Alias to a Private TLD Abstract It started with keeping a server address out of a committed config file and ended with a private .alt TLD for a tailnet of friends: Tailscale split DNS, CoreDNS reading a hosts file from git, deploys on push, and a CA that can only sign .alt. Status of This Memo This memo documents systems notes for the Internet community. It does not specify an Internet standard of any kind. Distribution of this memo is unlimited. The canonical version lives at https://octa.page/doc/our-own-dns/ and the machine-readable source at https://octa.page/doc/our-own-dns.md pyx keeps a file called ".pyx.yml" in each project, and it gets committed. It has two lines: the project name and the server to ssh into. The second one is the problem. Write "deploy@203.0.113.10" there and every clone of the repo carries an address and a valid username. Neither is a password, and with key-only auth neither gets anyone in. But there's no reason to hand them out. The fix is old and boring: an alias in "~/.ssh/config". pyx passes "host: shop" to ssh untouched, and ssh resolves it from your own machine. # ~/.ssh/config Host shop HostName 203.0.113.10 User deploy "pyx init" warns when the answer isn't an alias and prints the "Host" block to paste. That covered one person. Then came the next question: how does the rest of the team get the same aliases without everyone typing the same block by hand? 1. Sharing ssh config, and the part that doesn't share OpenSSH has had "Include" since 7.3. Keep the hosts in a private repo, and everyone adds one line at the top of their config: Include ~/dev/team-ssh/config Two rules make it safe. The shared file has addresses, ports and jumps, never keys. And "Include" goes first, before any "Host" block, because ssh treats an "Include" that follows a "Host" line as part of that block. That leaves "User". Then I noticed pyx had already answered it: it refuses to write a ".env" owned by someone other than the connecting user. Everyone connects as the deploy user anyway, so "User deploy" isn't personal data. It belongs in the shared file, next to the address. Per-person accountability comes back through one key per person in "authorized_keys", or SSH certificates once the team grows. Still, a shared file is a file everyone has to pull. What I actually wanted was the DNS version of it. 2. DNS can't carry a username, but ssh can fill the gap No DNS record tells ssh which user to use. But ssh can complete a short name itself and then read its config again with the full one: CanonicalizeHostname yes CanonicalDomains alt CanonicalizeMaxDots 0 Host *.alt User deploy "ssh shop" becomes "shop.alt", the config is re-read, "Host *.alt" matches, and the user comes from there. "CanonicalizeMaxDots 0" means only dotless names get completed, so "github.com" and IPs are never touched. Each machine writes this once, and new servers need only a DNS record. That needed a DNS server only the team could reach. 3. Tailscale for reach, CoreDNS for names The machines were already on a tailnet I share with some friends. Tailscale's admin console has two ways to point at your own nameserver. A global nameserver gets every lookup from every device: "google.com", "github.com", all of it. If that box goes down, my friends can't browse. Split DNS sends one domain to one server and leaves everything else alone. The second was the obvious choice. It also meant no "/etc/resolver" files on anyone's laptop, because Tailscale pushes the setting to every device. The server is CoreDNS: one Go binary, around 30 MB of RAM, bound to the Tailscale IP and authoritative only for its own zone, with no forwarding. A resolver that recurses for strangers ends up in an amplification attack. This one answers for exactly one TLD. First lookup: $ dig @100.64.0.10 shop.alt ;; ->>HEADER<<- opcode: QUERY, status: REFUSED "REFUSED" from CoreDNS means the name isn't in any zone I serve. The Corefile said one domain, I had asked for another. After fixing it: ;; flags: qr aa rd; ANSWER: 2 shop.alt. 3600 IN CNAME app-01.alt. app-01.alt. 3600 IN A 100.64.0.10 4. Picking a TLD I went with ".alt", after looking at the options: | TLD | Notes | |---|---| | `.internal` | Reserved by ICANN in 2024 for exactly this | | `.alt` | RFC 9476. Never delegated, but reserved for *non-DNS* namespaces (Tor, GNS), so a strict resolver may answer NXDOMAIN without asking anyone | | a subdomain you own | The only option that can get publicly trusted certificates | | `.local` | Belongs to mDNS. On macOS it fails in slow, confusing ways | | `.lan`, `.corp`, made-up | Not reserved, can collide with a real TLD later | Split DNS routes ".alt" straight to CoreDNS, so the RFC caveat hasn't come up. The one surprise was Chrome: it doesn't recognize ".alt" as a TLD and sends "blog.alt" to search. Typing "http://blog.alt" once fixes it for good. 5. Editing the zone without logging in Asking "how do I edit this without ssh-ing into the server" is how pyx started, so it felt right to ask it again. A web UI was the first idea. Technitium does it well: zones, records, per-zone users so friends could manage their own. It also runs on .NET and idles at several times CoreDNS's memory, on a VPS that has real work to do. CoreDNS has no UI plugin. The in-tree "etcd" backend means adding etcd, plus a UI for etcd. That's two new services to replace a text file. So the zone stayed a text file. One change made that practical: swapping CoreDNS's "file" plugin for "hosts". The "file" plugin reloads only when the SOA serial changes, so forgetting the bump is a silent no-op. "hosts" reloads when the file changes and has no serial, SOA or NS to look after. # hosts # One Tailscale IP per line, followed by every name that points to it. 100.64.0.10 app-01.alt shop.alt blog.alt files.alt alt { bind 100.64.0.10 hosts /etc/coredns/zone/hosts { ttl 60 reload 10s } log } The file lives in a private repo. A push that touches "hosts" runs "floo-action", which tells the floo agent on the server to "git pull". Ten seconds later CoreDNS is serving the new names. The GitHub web editor turned out to be the UI I was looking for: it works from a phone, keeps full history, and undo is "git revert". Watch the "ttl 60". The "hosts" plugin defaults to 3600 seconds. You won't notice when adding a name, because nobody has it cached. You will notice the first time you move a name to another machine and clients keep the old address for an hour. 6. Names are not access Separate DNS zones for my projects and my friends' felt like separation. It isn't. Without ACLs, every device on a tailnet reaches every IP, whether it has a name or not. The separation comes from Tailscale tags. "tag:mine" and "tag:friends" decide who reaches which machines, and "tag:dns" lets everyone reach port 53 on the DNS box and nothing else there. There's an upside: inside the tailnet, WireGuard authenticates the source IP. On the internet, Apache's "Require ip" is a weak filter. Here, "Require ip 100.101.102.103" means "only my laptop", with no password. There's also a trap. Docker publishes ports on every interface and bypasses "ufw". A Coolify box whose proxy routes "velmora.alt" will also serve it to anyone who runs "curl -H "Host: velmora.alt" http:///". The proxy has to check the source address, not just the name. 7. HTTPS, next No public CA will issue for ".alt". The plan is step-ca handing out certificates over ACME: Caddy points "acme_ca" at it, and Apache's built-in "mod_md" speaks ACME too. One detail makes it acceptable to ask friends to trust it: the root carries a critical name constraint. "nameConstraints": { "critical": true, "permittedDNSDomains": ["alt"] } An unconstrained root in your trust store can sign "google.com". This one is useless outside ".alt", even if the key leaks. The key also stays offline, and the server only ever holds an intermediate. 8. What a private namespace buys you Once names are cheap, things that used to need a public port or a login stop needing one: o Admin panels off the internet. "velmora.com" blocks "/admin". "velmora.alt", bound to the Tailscale IP, serves the same app with the route open. o Indirection. "DATABASE_URL=postgres://db.alt/shop", set once with "pyx add", then moved between machines by editing a line in "hosts". o Query logs as an inventory. Before turning something off, "journalctl -u coredns | grep old-db.alt" shows who still uses it. o A canary. A name like "vault.alt" that nobody should ever look up. If it shows up in the logs, something is poking around. o Go links. An Apache "RewriteMap" reading a file in the same repo, with "alt" as a Tailscale search domain, gives you "go/grafana". o Things you'd never expose. "server-status" on "status.alt", and a WebDAV share on "files.alt" that mounts in Finder. None of it needed a new service. It's a binary, a text file, and a push. Christian Elías Informational [Page 1]