---
title: "Our Own DNS: From a Committed Alias to a Private TLD"
date: 2026-10-25
type: systems
description: "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."
tags: [dns, tailscale, coredns, ssh, self-hosted]
canonical: https://octa.page/doc/our-own-dns/
---

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?

## 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.

## 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.

## 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
```

## 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.

## 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.

## 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://<public-ip>/`. The proxy has to check the source address, not just the name.

## 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.

```json
"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.

## What a private namespace buys you

Once names are cheap, things that used to need a public port or a login stop needing one:

- **Admin panels off the internet.** `velmora.com` blocks `/admin`. `velmora.alt`, bound to the Tailscale IP, serves the same app with the route open.
- **Indirection.** `DATABASE_URL=postgres://db.alt/shop`, set once with `pyx add`, then moved between machines by editing a line in `hosts`.
- **Query logs as an inventory.** Before turning something off, `journalctl -u coredns | grep old-db.alt` shows who still uses it.
- **A canary.** A name like `vault.alt` that nobody should ever look up. If it shows up in the logs, something is poking around.
- **Go links.** An Apache `RewriteMap` reading a file in the same repo, with `alt` as a Tailscale search domain, gives you `go/grafana`.
- **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.