For about a year, using my own software meant remembering numbers. The media server was one four-digit port, the download client another, the dashboard a third. I knew maybe six by heart. My wife knew zero, which is the correct number of port numbers for a person to know.

That’s the actual problem with self-hosting a lot of small things. Each one works fine. Collectively they’re unusable, because the addressing scheme is “whatever port was free that day.”

The fix took three pieces: a wildcard domain that resolves somewhere private, real certificates for servers no certificate authority can visit, and a deploy that reconciles itself. None of it is exotic. All of it took me longer to get right than it should have.

What I tried first, and why it lost

The first version of this used path-based routing. One hostname, and every app hung off a path prefix — /dashboard, /media, and so on — with strip-prefix middleware rewriting requests on the way through.

It technically worked, and I would not do it again.

Path-based routing ruins anything you want to install. A service worker’s scope is bound by path, so an app served under a prefix can only control its own subtree, and every scope bug you hit is really a rewrite bug in disguise. A web app manifest’s start_url and scope inherit the same constraint, so “add to home screen” produces something subtly wrong. Cookies don’t isolate by path in any way you’d want to rely on, so every app shares a cookie namespace with every other app. Content Security Policy is defined per origin, so one app’s policy is every app’s policy. And the app itself has to know it’s living under a prefix — most self-hosted software has a “URL base” setting precisely because this is a common mistake, and every one of those settings is a chance to get it wrong.

Subdomain-per-app solves all of that for free. Each app is its own origin. Its own cookie jar, its own service worker scope, its own CSP, its own install identity. The app doesn’t need to know anything about how it’s being reached.

I also looked at the mesh network’s own built-in sharing feature, which is genuinely good and wasn’t right here: it gives you one hostname per machine. With everything on a single box, that puts you straight back into subpaths, or into running a sidecar per service. And I ruled out putting these behind a public tunnel, because this stuff — media automation, API keys, download control — has no business being reachable from the open internet.

The shape

  browser on the private network
        |
        v
  wildcard DNS record  ->  a private address
        |
        v
  one reverse proxy  ->  matches on hostname
        |
        v
  the app's own port on the same box

A single wildcard record covers every subdomain. The record itself is public — anyone can look it up — but it resolves to an address that only exists inside my private network, so the answer is useless to anyone standing outside. Publishing “here is where my house is” is fine when the house is unreachable. This works precisely because the network layer underneath is doing its job.

The nicest consequence: adding an app requires no DNS work at all. New name, new proxy block, done. The wildcard already covers it and the certificate already covers it. That is a real reason the ninth app shipped so fast, which I’ve written about across the fleet.

Real certificates for a server nobody can visit

Of everything in this post, this is the piece to hand to another self-hoster: the naive answer is bad, and the good answer isn’t obvious.

The usual way to get a free certificate is the HTTP challenge: the certificate authority makes a request to your domain over the public internet and checks for a file it told you to place. That is structurally impossible here. The design goal is that nothing answers from the public internet. There is no request for them to make.

The obvious fallback is to become your own certificate authority and install your root certificate on every device. I did the math on that and stopped. Six family devices, plus every new phone, plus every guest, plus a renewal cycle where things silently break until someone reinstalls a certificate they don’t understand. You haven’t solved TLS, you’ve hired yourself as a helpdesk.

The DNS challenge is the way out. Instead of proving you control a server, you prove you control the domain — the proxy talks to my DNS provider’s API, publishes a temporary record, the authority verifies it, and a genuine wildcard certificate comes back. No inbound connection ever happens. The server being unreachable is irrelevant, because the server was never part of the proof.

The result is that every app has a real certificate from a real authority, and every browser and phone on the network just trusts it. No warnings. No install step. No private CA. My family has no idea any of this exists, which is the highest compliment available to infrastructure.

Two practical notes. Most reverse proxies don’t ship the DNS-provider plugin you need in their stock build, so you’ll be building a custom image — a two-stage Dockerfile, about four lines. And the API token you hand it should be scoped to editing DNS records on exactly the zones involved and nothing else, because that token can now prove domain ownership.

The proxy is a convenience, not a dependency

A design property I didn’t plan and have come to like a lot: if the proxy dies, nothing actually stops working.

Every app is still listening on its own port. Every one is still reachable directly from inside the network. What you lose is the friendly name and the certificate — an inconvenience, not an outage. The proxy is a convenience layer over a system that functions without it.

I’ve kept a couple of things deliberately outside it for exactly that reason. The container manager isn’t proxied, because it’s the tool you need when the proxy is broken, and routing your recovery tool through the thing that’s on fire is a bootstrap hazard. Same for the storage system’s own admin interface. When you build a convenience layer, make sure the tools you’d use to repair it don’t depend on it.

Deploy: push to a remote, let the box catch up

Deploys used to be me copying a working directory onto the server. That model failed in a way I want to describe, because it failed silently and for weeks.

The site’s code drifted into four different versions: the remote, two local checkouts on different branches, and the server — which was running an old commit plus a large uncommitted change someone had made in place. The build baked the server’s dirty tree, so a change I’d committed and pushed weeks earlier had simply never gone live. Nothing errored. It just quietly served the old thing.

The replacement is pull-based. Push to the git remote; that is the deploy. A timer on the box fetches, hard-resets to the remote’s commit, and rebuilds only when the code hash actually moved. Three properties make it work:

In-place edits on the server do not survive. The hard reset wipes them every cycle. That sounds hostile and it’s the entire point — it makes the drift that broke the old model structurally impossible.

The swap is atomic. A failed build never replaces the live version, so a broken commit leaves the previous one serving rather than taking the site down. (With a matching hazard: a build that keeps failing means the site is frozen on old content while looking perfectly healthy. Whatever hard-fails your build deserves a loud alarm.)

One endpoint answers “what is actually live.” A tiny JSON response with the code commit, the content commit, and the build timestamp. One request settles every “did that deploy?” argument, including the ones I have with myself.

Two things that cost me an afternoon each

Directive order isn’t file order. My reverse proxy sorts its directives by internal priority, not by the sequence you wrote them in. A path-matched route declared loose in a block that also has a catch-all gets evaluated after the catch-all — so the catch-all answers, returns a clean 404, and your upstream is never contacted. The symptom is an empty successful response with no error anywhere and no log line at the backend, which is the worst kind of bug: everything reports success. Wrap path-matched routes in their own explicit block and the ordering problem disappears.

A machine’s own name doesn’t resolve inside its own containers. A container’s resolver points at upstream DNS, not the host’s name service, so a friendly hostname that works perfectly in your shell fails inside a container with a name-resolution error. I lost real time to this because the shell test and the container behavior disagreed, and I trusted the shell. Container-bound configuration needs a stable numeric address, not a nickname.

And the one that froze deploys for weeks: the reconcile script fetched, then reset to the remote tracking ref — but the way it fetched never updated that ref. So it kept resetting to the same stale commit, forever, reporting success every cycle. Deploys stopped landing, and no part of the system mentioned it. I found it only because a new endpoint I’d shipped wasn’t appearing.

That’s the theme of every bug in this post, really. None of them threw an error. The proxy returned a clean 404, the deploy reported success, the certificate would have been valid, the build was green. The expensive bugs never crash anything. They pass.