Every dev server gets a name
localhost:4321 only works on the machine it runs on and means nothing a week later. One wildcard on the always-on box, a second Caddy on the desktop, and a thirty-line helper give every dev server a URL that works from any machine in the house.

Part six of a series on wiring my machines together. Previously: leaving zsh, keeping the prompt. Next: deploying to the always-on box with one word.
By the end of part five, the desktop felt like my machine from wherever I was sitting. Except for one thing, which I noticed the first time I started this blog’s dev server and wanted to read a draft on the laptop.
The dev server said http://localhost:4321. On the laptop, localhost is the laptop. And that’s only the first of a few problems with ports:
- They only mean something on one machine. To see a dev server from the couch, I’d have to bind it to
0.0.0.0and type the desktop’s IP and the port. - Binding to
0.0.0.0exposes everything. Dev servers have no auth, and some of them run with real API keys in their.env. “Listen on every interface” shouldn’t be how I make one visible to one other machine. - Dev servers check the hostname. Vite and Astro reject requests for hostnames they don’t recognise, which is a sensible defence against DNS rebinding, and annoying when the hostname is your own.
- Nobody remembers ports. Was the demo app 4100 or 4200? Was the blog 4321 or 4322?
What I wanted: start a dev server on the desktop, run one command, and get a name that works from the desk and the couch.
What was already there
The Mac mini has had this for its own services for a long time. The router’s DNS answers every *.lan name with the mini’s address, and a Caddy container there maps each name to a port: grafana.lan, plex.lan, and so on. I wrote about that setup in the herdr post.
It also had a few dev servers. Back when I wrote code on the mini, nateparker.lan was this blog’s preview and doorman.lan was a demo app. Each one was a hand-written block in the mini’s Caddyfile pointing at a port on the mini.
Now the code lives on the desktop, so the dev servers do too. I had three options:
- Repoint those names at the desktop, one hand-written block per server, in the mini’s config. That works, but it means hand-editing the always-on server’s config every time I start something on a different machine, and the mini’s config should describe the mini.
- Use
*.localhost. Browsers resolve it to the local machine with no DNS at all. But it only works on the desktop itself, so the couch is out. - Give the desktop its own namespace and let it manage its own names.
I went with the third.
Delegation, at the HTTP layer
The mini’s Caddy gets one new block, written once and never touched again:
http://*.grim.lan {
reverse_proxy 192.168.1.144:8480
}
Anything ending in .grim.lan is forwarded to the desktop — grimmauld, hence grim — on port 8480, with the original Host header intact. The DNS needed no change at all: the router’s *.lan wildcard already covers nateparker.grim.lan, so it reaches the mini like every other .lan name, and the mini passes it along.
It’s the same idea as delegating a DNS subdomain to another nameserver: the parent zone keeps a single pointer and the child decides what lives under it. It’s done in HTTP instead of DNS, because the reverse proxy is the piece I already control on both ends.
On the desktop, a second Caddy listens on 8480 and maps names to local ports. It runs as a systemd user service, so there’s no root and no port 80. Its whole config is a few global settings, an import, and a fallback:
{
http_port 8480
auto_https off
admin localhost:2019
}
import {$HOME}/.local/state/lan/*.caddy
http:// {
respond "Nothing registered as {host} on grimmauld. Run: lan <name> <port>" 404
}
Each registered name is one small file in that state directory. The directory isn’t in my dotfiles repo, because which dev servers I’m running is state, not configuration.
The helper
Registering a name is one command:
$ lan nateparker 4321
http://nateparker.grim.lan -> localhost:4321
$ cd ~/Work/0x4rch/doorman
$ lan 4100 # name comes from the folder
http://doorman.grim.lan -> localhost:4100
$ lan
http://nateparker.grim.lan :4321 up
http://doorman.grim.lan :4100 up
lan writes this file and reloads Caddy:
http://nateparker.grim.lan {
reverse_proxy localhost:4321 {
header_up Host {upstream_hostport}
}
}
The header_up Host line solves two problems from the list at the top. Caddy rewrites the hostname to localhost:4321 on the way in, so the dev server sees the hostname it expects and its host check passes without a line of project config. And because the only thing connecting to the dev server is a Caddy on the same machine, the dev server can stay bound to 127.0.0.1. Nothing new listens on the network except the proxy.
Live reload works too. Vite’s client opens its websocket against whatever host served the page, so the upgrade goes from browser to mini to desktop to dev server, and comes back 101 Switching Protocols. Edit a post, and the laptop on the couch updates.
The firewall is the access list
The desktop runs a firewall that drops inbound connections by default, so after the first test the name returned a 502. That’s the correct outcome. The fix is one rule, scoped as tightly as it can be:
sudo ufw allow from 192.168.1.96 to any port 8480 proto tcp
Only the mini can reach the proxy. Every other machine on the network goes through the mini, which means there’s exactly one door, and it’s the same door every other .lan service already uses.
To be clear about what this is: anything I register is reachable from the whole LAN, without auth, over plain HTTP. That’s the same exposure the mini’s services have always had. It’s fine for drafts of a blog and a demo app. It’s not where I’d put an admin panel with production credentials behind it, and lan rm is one command for the moment I’m done looking at something.
The bind mount that pinned an old file
The mini’s Caddy runs in a container, with its Caddyfile bind-mounted in as a single file. When I added the *.grim.lan block, I appended it to the file and reloaded, and it worked.
Later that evening, another change landed on the mini through git pull, and Caddy’s reload failed. A single-file bind mount is pinned to the file’s inode. Appending to a file keeps the inode, so my edit showed up. git pull writes a new file and renames it into place, so the container was still looking at the old one, a file that no longer existed at that path, even though the path looked fine from both sides.
The fix is to mount the directory instead of the file. A directory mount sees whatever is at the path now. This had been waiting in that compose file since the day I wrote it. It took a deploy flow based on git pull to trip it.
What moved
The same evening, the handful of side-project dev servers that used to live on the mini moved to the desktop and picked up .grim.lan names. The mini’s Caddyfile lost twenty-six lines of hand-written dev blocks and gained a two-line comment pointing at the wildcard. The server’s config describes the server now, and the desktop describes itself.
What’s still rough
A sleeping desktop is a 502. Part two made the desktop suspend when nobody’s using it, and the idle check looks at SSH sessions, agents and load — not HTTP traffic. So reading a draft from the laptop doesn’t keep the desktop awake, and a page loaded after it sleeps fails. The mini is always on and is already the one receiving the request, so the obvious fix is for the mini to send a wake packet when its forward to the desktop fails, and retry. I haven’t built it yet.
It’s LAN-only. Same as everything else here, until Tailscale goes in.
For now: start a dev server, type lan, open the name on any machine in the house. I’ve stopped typing port numbers, and that turns out to be one of those small frictions you only notice once it’s gone.