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:

TLDNotes
.internalReserved by ICANN in 2024 for exactly this
.altRFC 9476. Never delegated, but reserved for non-DNS namespaces (Tor, GNS), so a strict resolver may answer NXDOMAIN without asking anyone
a subdomain you ownThe only option that can get publicly trusted certificates
.localBelongs to mDNS. On macOS it fails in slow, confusing ways
.lan, .corp, made-upNot 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.

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