For approximately one year, I used my own software through numbers from memory. The media server was one four-digit port. The download client was another. The dashboard was a third. I knew approximately six ports from memory. My wife knew zero. Zero is the correct number of port numbers for a person to know.

That is the true problem with many small self-hosted apps. Each one works well alone. Together they are unusable, because the address plan is “the port that was free that day”.

The repair took three parts: a wildcard domain that points to a private address, real certificates for a server no certificate authority can visit, and a deploy that corrects itself. No part is exotic. Each part took me longer than it should.

What I tried first, and why it lost

The first version used path-based routing. There was one hostname. Each app hung from a path prefix, for example /dashboard and /media. A strip-prefix middleware rewrote each request on the way through.

It worked, in a technical sense. I will not do it again.

Path-based routing damages each app you want to install. A service worker’s scope binds to a path. So an app under a prefix controls only its own subtree. Each scope bug you find is really a rewrite bug in costume. A web app manifest’s start_url and scope have the same constraint. So “add to home screen” produces a result with subtle errors. Cookies do not isolate by path in a reliable way. So each app shares one cookie namespace with each other app. Content Security Policy applies per origin. So one app’s policy is each app’s policy. And the app itself must know that it lives under a prefix. Most self-hosted software has a “URL base” setting because this mistake is common. The setting is an apology from a developer who met this problem first. Each of those settings is one more chance for an error.

A subdomain for each app solves all of that for free. Each app is its own origin. It has its own cookie jar, its own service worker scope, its own CSP, and its own install identity. The app needs no knowledge of how you reach it.

I also examined the mesh network’s own built-in share feature. The feature is good, but it was not correct here. It gives one hostname for each machine. All my apps live on a single box. So that plan puts you back into subpaths, or into one sidecar for each service. I also rejected a public tunnel. This content — media automation, API keys, download control — must not be 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 each subdomain. The record itself is public. Any person can look it up. But it points to an address that exists only inside my private network. So the answer is useless to a person outside. To publish “here is my house” is safe when nobody can reach the house. This works because the network layer underneath does its job.

The best consequence: a new app needs zero DNS work. New name, new proxy block, done. The wildcard already covers it. The certificate already covers it. That is a real reason the ninth app shipped fast. I wrote about that across the fleet.

Real certificates for a server nobody can visit

Of all parts of this post, give this part to another self-hoster. The naive answer is bad. The good answer is not obvious.

The usual way to get a free certificate is the HTTP challenge. The certificate authority sends a request to your domain over the public internet. It looks for a file it told you to place. Here, that is impossible by structure. The design goal is that nothing answers from the public internet. There is no request for the authority to make.

The obvious fallback: become your own certificate authority, and install your root certificate on each device. I calculated the cost of that plan and stopped. Six family devices, plus each new phone, plus each guest. Plus a renewal cycle where things break in silence, until a person reinstalls a certificate they do not understand. You did not solve TLS. You hired yourself as a helpdesk.

The DNS challenge is the exit. You do not prove control of a server. You prove control of the domain. The proxy talks to my DNS provider’s API and publishes a temporary record. The authority verifies the record. A true wildcard certificate comes back. No inbound connection occurs. The unreachable server is irrelevant, because the server was never part of the proof.

The result: each app has a real certificate from a real authority. Each browser and phone on the network trusts it. No warnings. No install step. No private CA. My family does not know this system exists. That is the highest compliment available to infrastructure.

Two practical notes. Most reverse proxies do not include the necessary DNS-provider plugin in the stock build. So you will build a custom image: a two-stage Dockerfile, approximately four lines. And scope the API token to DNS-record edits on exactly the necessary zones, and nothing more. That token can now prove domain ownership.

The proxy is a convenience, not a dependency

One design property came without a plan, and I like it a lot: when the proxy dies, nothing stops.

Each app still listens on its own port. Each app is still reachable directly from inside the network. You lose the friendly name and the certificate. That is an inconvenience, not an outage. The proxy is a convenience layer over a system that functions without it.

I keep some tools outside the proxy for that exact reason. The container manager is not behind the proxy. It is the tool you need when the proxy is broken. To route your recovery tool through the thing on fire is a bootstrap hazard. The same rule applies to the storage system’s own admin interface. When you build a convenience layer, keep the repair tools independent of it.

Deploy: push to a remote, and the box catches up

Deploys were a manual copy of a work directory onto the server. That model failed. I want to describe the failure, because it failed in silence, and for weeks.

The site’s code drifted into four versions: the remote, two local checkouts on different branches, and the server. The server ran an old commit, plus a large uncommitted change that a person made in place. The build baked the server’s dirty tree. So a change I committed and pushed weeks before never went live. Nothing gave an error. The server served the old thing, in silence.

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

In-place edits on the server do not survive. The hard reset removes them on each cycle. That sounds hostile. That is the full point. It makes the old model’s drift impossible by structure.

The swap is atomic. A failed build never replaces the live version. So a broken commit leaves the previous version in service. It does not take the site down. (There is a matched hazard. A build that fails again and again freezes the site on old content, and the site looks healthy. Whatever hard-fails your build deserves a loud alarm.)

One endpoint answers “what is live now”. It returns a small JSON response with the code commit, the content commit, and the build timestamp. One request settles each “did that deploy?” argument. That includes the arguments I have with myself.

Two things that cost me an afternoon each

Directive order is not file order. My reverse proxy sorts its directives by internal priority, not by your write sequence. Take a path-matched route, declared loose in a block that also has a catch-all. The proxy evaluates it after the catch-all. So the catch-all answers and returns a clean 404. The proxy never contacts your upstream. The symptom is an empty successful response, with no error and no log line at the backend. That is the worst class of bug: each part reports success. Put path-matched routes in their own explicit block. The order problem disappears.

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

And the one that froze deploys for weeks: the reconcile script fetched, then made a reset to the remote tracking ref. But its fetch method never updated that ref. So the script made a reset to the same stale commit, forever, and reported success on each cycle. Deploys stopped. No part of the system said so. I found the problem only because a new endpoint I shipped did not appear.

Each bug looked tidy from the outside. The proxy returned a clean 404. The deploy reported success. The certificate was valid. The build was green.

The useful result is much less dramatic. My wife opens a familiar name and the right app appears. I keep one live-version check for the moments when that ordinary behavior stops. Nobody else has to learn the port numbers that started this whole project.