Bassam Ismail
Personal

How I reach a home server with no public address

15 min read

I type jellyfin.home.example.dev into my phone, on cellular, three countries from home, and it loads instantly with a valid lock icon. There is no port number, no IP, no VPN prompt I have to answer. The server it just reached has no public address at all. It is a laptop on a shelf in my house, and from the open internet it does not exist.

This is the second part of a six-part series about running a home server on a spare MacBook. The first part made a promise: nothing on the home server is exposed to the public internet, and yet I reach it from anywhere with real hostnames and real TLS. This part is how that promise is kept. It is three pieces that fit together neatly once you stop trying to make any one of them do the whole job: a mesh VPN for reachability, a wildcard certificate for trust, and one reverse proxy to turn ports into names.

TL;DR

Reaching a home server with no public IP address requires three cooperating pieces: a mesh VPN (Tailscale) for private reachability, a public wildcard DNS record pointing at the server's private tailnet address, and a reverse proxy (Caddy) that routes by hostname and obtains a real TLS certificate via the DNS-01 challenge. The DNS-01 challenge is what makes "private server, trusted certificate" work together: the certificate authority validates domain ownership by reading a DNS TXT record, so the server never needs to be reachable from the public internet. One Cloudflare API token scoped to Zone:Read and DNS:Edit is all the external exposure required. The result is real hostnames and a valid lock icon on every device, with zero open ports.

The address problem, and the wrong ways to solve it

A server people can reach usually has a public IP and an open port. That is exactly what I did not want, because a public port is a standing invitation and my home IP is behind carrier-grade NAT anyway, so I could not reliably forward one even if I wanted to.

The traditional fixes are all some flavor of "punch a hole." Port forwarding, dynamic DNS, an SSH tunnel, a cloud relay. Each one re-creates the thing I was avoiding: a path from the public internet to a machine holding my photos and my password vault.

The move that makes the rest easy is to stop treating reachability as a networking problem and treat it as a membership problem. I run Tailscale, a mesh VPN built on WireGuard. Every device I own joins one private network, the tailnet. The server has a stable address on that network, somewhere in the tailnet's 100.64.0.0/10 CGNAT range, and no presence anywhere else. The question stops being "which ports are open to the world" and becomes "is this device a member of my network," which has a much better default answer: no.

HOW A REQUEST REACHES A SERVER WITH NO ADDRESSmy phoneon the tailnetDNSname to tailnet IPCaddyTLS, by hostnameservicelocal port[ none of this is reachable off the tailnet ]

The wildcard that resolves for everyone and routes for no one

The DNS half is a small trick that looks wrong until it doesn't. *.home.example.dev is a real, public DNS A record. It points at the server's private tailnet address, the 100.x.y.z one from the CGNAT range above. In Cloudflare the record is DNS-only, the grey cloud, not proxied, because there is nothing for Cloudflare to proxy to: the address it returns is not routable from Cloudflare's network or anyone else's.

A public record pointing at a private IP feels like a leak until you trace what actually happens. The name resolves for everyone on earth. The address it resolves to only routes for devices on my tailnet. Everyone else gets a perfectly valid DNS answer and an IP they cannot reach, which is the same as getting nothing, except it cost me one record instead of a split-horizon DNS setup. I did not have to run an internal resolver, and I did not have to keep a private zone in sync with a public one. There is one zone, it is public, and it is useless to anyone who is not already a member.

Names, not ports: one reverse proxy

Tailscale gives the server a reachable address, but an address is not a name, and I was not going to memorize that media is on :8096 and the photos are on :2283. So a single reverse proxy (Caddy) sits in front of everything and routes by hostname. Every subdomain of home.example.dev lands on it.

The config has two shapes. There is an apex block for home.example.dev itself, which is where the TLS configuration lives and where the dashboard answers, and there are per-service blocks that match a hostname and hand it to a container. The apex looks like this:

home.example.dev {
  tls {
    dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    resolvers 1.1.1.1 8.8.8.8
    propagation_timeout 5m
  }
  reverse_proxy glance:8080
}

The tls block is doing the load-bearing work and I will come back to it. The reverse_proxy glance:8080 line just says the bare dashboard at the apex is a Glance instance. Everything else is a subdomain, and the pattern for each one is a host matcher plus a handle:

@jellyfin host jellyfin.home.example.dev
handle @jellyfin { reverse_proxy jellyfin:8096 }
 
@photos host photos.home.example.dev
handle @photos { reverse_proxy immich-server:2283 }

@jellyfin is a named matcher that fires when the requested host is exactly jellyfin.home.example.dev; the handle block scoped to it proxies to the Jellyfin container on its internal port. Adding a service is two lines and a reload, which matters more than it sounds: friction is why home servers accrete a pile of :port bookmarks instead of names.

Because every one of these is a subdomain of the same wildcard, they all share a single certificate, and that cert is issued and stored in a *.home.example.dev { ... } block that carries the same tls configuration. The per-service handle blocks live behind the wildcard site; one issuance covers the lot.

Most of the targets are containers, so Caddy reaches them by container name on a shared Docker network, never by IP. The full map, all real container:port pairs, is the boring backbone of the whole thing:

Subdomainreverse_proxy targetWhat it is
home (apex)glance:8080Dashboard
jellyfinjellyfin:8096Media server
photosimmich-server:2283Photo library
sonarrsonarr:8989TV management
radarrradarr:7878Film management
prowlarrprowlarr:9696Indexer manager
qbittorrentqbittorrent:8080Download client
seerrseerr:5055Request frontend
gatusgatus:8080Status / uptime
ntfyntfy:80Push notifications
home-assistanthomeassistant:8123Home automation
vaultvaultwarden:80Password vault

Two of those targets land on :8080 (Glance and Gatus, and qBittorrent too) and that is fine: the port is internal to each container's own network namespace, and Caddy disambiguates by container name, not by port. The collision only exists in my head, not on the wire.

The handful of services that run natively on the macOS host rather than in a container do not have a container name to address. For those, Caddy reaches back out through the Docker host gateway:

@llm host llm.home.example.dev
handle @llm { reverse_proxy host.docker.internal:1234 }

That host.docker.internal:1234 is the local LLM server running directly on macOS, which gets its own treatment in part six. The point here is just that the reverse proxy does not care whether a service is a container or a native process; it only needs a routable address and a port.

A real certificate for a site the internet cannot see

Here is the part I expected to be a fight and was, briefly. I wanted real TLS, the genuine lock icon, not a self-signed certificate that makes every device nag. But the standard way to get a free certificate, the HTTP-01 challenge, requires the certificate authority to reach your server over the public internet on port 80. The CA opens a connection, asks for a file it expects you to have placed, and issues if it is there. My server has no public anything. The whole point was that it is unreachable. HTTP-01 was dead on arrival.

The answer is the DNS-01 challenge. Instead of proving control of a server by answering an HTTP request to it, you prove control of the domain by writing a TXT record into its DNS. The CA tells you a value, you publish it under _acme-challenge.home.example.dev, the CA reads it back, and it issues. Nothing about the server has to be reachable from outside; the only thing exposed to the internet is a DNS record I was already publishing records into. All it needs is an API token for the DNS provider.

Caddy does not ship with the Cloudflare DNS plugin in its default image, so the proxy is a small custom build with xcaddy:

FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

The first stage uses Caddy's builder image to compile a Caddy binary with the caddy-dns/cloudflare module statically linked in. The second stage throws the build toolchain away and copies just that binary into a clean Alpine image. The result is a normal small Caddy image that happens to know how to talk to the Cloudflare API.

The token itself is scoped as narrowly as Cloudflare lets me: Zone:Read and DNS:Edit, on the one zone, and nothing else. It lives in ~/selfhost/.env, which is chmod 600, and Caddy reads it through {env.CLOUDFLARE_API_TOKEN} in the tls block. It is never in the Caddyfile and never in the image.

With that wired up, Caddy gets a wildcard certificate for *.home.example.dev by writing DNS records, renews it the same way before it expires, and the server stays invisible the entire time. One cert covers every subdomain in the table above.

A DNS-01 wildcard is the move that lets "private" and "properly trusted" stop being a tradeoff. The certificate authority never has to see your server; it only has to see your domain.

The handshake is worth seeing laid out, because "prove you control the domain without exposing the server" sounds like a contradiction until you trace who actually talks to whom:

DNS-01 WILDCARD ISSUANCErequest *.home certchallenge tokenwrite _acme-challenge TXTready, validateread the TXTissue wildcard certCaddyCloudflare DNSLet's EncryptCaddyCloudflare DNSLet's Encrypt

The DNS-01 issuance that hung, and why

The first issuance did not work. It hung, then failed, with no obvious cause, which is the worst kind of failure because there is nothing to read. The certificate request would sit there waiting on the challenge and eventually time out.

The cause was the propagation check. After Caddy writes the TXT record, it polls DNS to confirm the record is visible before telling the CA to go look. Two defaults were working against me. First, the resolver Caddy used by default did not reliably see the new record fast enough, because it was reading from a path that had a stale view. Second, the default wait for propagation was shorter than Cloudflare actually took to make the record globally visible. The fix was to pin both, which is the tls block exactly as it appears in the apex above:

tls {
  dns cloudflare {env.CLOUDFLARE_API_TOKEN}
  resolvers 1.1.1.1 8.8.8.8
  propagation_timeout 5m
}

resolvers 1.1.1.1 8.8.8.8 tells Caddy to confirm propagation against Cloudflare's and Google's public resolvers directly, instead of whatever the host's resolver path happened to be. propagation_timeout 5m gives the record up to five minutes to show up before giving up. With both set, issuance went from hanging indefinitely to completing in well under that window, and renewals have been quiet since.

The .dev domain adds a useful bit of pressure to all of this. The whole TLD is on the browsers' HSTS preload list, so HTTPS is mandatory, no plain-HTTP fallback exists, and a misconfiguration fails loudly with a browser error instead of silently downgrading to plain HTTP and pretending everything is fine. I would rather a broken cert stop me cold than let me ship something half-encrypted without noticing.

The two gotchas nobody warns you about

The architecture is clean. Getting it to actually resolve on every device was where the evening went, and neither problem was in any of the config above.

The first was the VPN's own DNS. Tailscale can serve names for the tailnet (MagicDNS), but a device only uses it if its resolver is pointed at Tailscale's internal 100.100.100.100. On a machine where it was not, the bare hostname homeserver simply would not resolve, even though the device was on the tailnet and could reach the 100.x.y.z address directly. The IP worked; the name did not. The fix was enabling MagicDNS in the admin console, and on one stubborn client, an explicit SSH config alias so I was not depending on name resolution for the one thing I needed most:

# ~/.ssh/config
Host homeserver
  HostName 100.x.y.z
  User you

With that, ssh homeserver works regardless of whether MagicDNS is cooperating, because it is pointed straight at the tailnet address.

The second was a DNS filter biting me. I run a network-wide DNS service that blocks ads and trackers, NextDNS-style, and it was quietly blocking my own *.home.example.dev lookups because the domain looked unfamiliar to its heuristics. The fix was to allowlist my own domain in the filter's profile. It is a small thing, but it is the kind of small thing that makes you doubt the entire setup at 11pm, because the failure mode is identical to "the server is down" when the server is fine and your DNS just decided not to tell you about it. Both of these gotchas share that property: the server is running, the config is correct, and a name resolution layer you forgot about is the thing in the way.

What this buys, and what it costs

The result is a server that behaves like a polished cloud service and is reachable like one, while being, physically, a laptop that talks to no one outside my own devices. I get names and a lock icon; an attacker gets a domain that resolves to an address they cannot route to.

The honest costs are two, and I will not pretend they are nothing. The first is the DNS-edit API token now living on the box. It is scoped to Zone:Read and DNS:Edit on one zone and locked to a chmod 600 file, but a token that can rewrite my domain's records is a meaningfully powerful secret to keep on a machine on a shelf, and I treat it as one.

The second is that the tailnet is now load-bearing. If Tailscale's coordination service is down, or I have not yet enrolled a new device, I cannot reach my own server even though it is running fine ten feet away. I have traded "an open port to the world" for "a hard dependency on one mesh VPN," and that is a real trade, not a free lunch. I decided it was the right one: a small, well-understood dependency I can reason about beats a standing invitation I cannot. After months of it I still think so.

The next part goes inside the box, to the least glamorous and most surprising layer: the macOS service manager, launchd, quietly running two dozen background jobs that turn this from a media server into something that runs my week.

FAQ

How do I get a trusted TLS certificate for a server with no public IP?

Use the DNS-01 ACME challenge instead of HTTP-01. The certificate authority validates your domain ownership by reading a TXT record you publish in DNS, so the server itself never needs to be reachable from the internet. Caddy supports this with a small custom build that includes the caddy-dns/cloudflare plugin, and a Cloudflare API token scoped to Zone:Read and DNS:Edit.

Why does Caddy DNS-01 certificate issuance hang or time out?

Two defaults work against you: Caddy may poll a resolver that has a stale view of the new TXT record, and the default propagation timeout can be shorter than Cloudflare actually takes to make the record globally visible. Fix both by setting explicit resolvers (1.1.1.1 and 8.8.8.8) and a propagation_timeout of 5m in the tls block.

Can I use a public DNS record that points to a private IP address?

Yes. A public A record pointing at a Tailscale CGNAT address (100.x.y.z) resolves for anyone on the internet but only routes for devices on your tailnet. Everyone else gets a valid DNS answer and an unreachable address, which is functionally equivalent to no answer, without requiring split-horizon DNS or an internal resolver.

How do I route multiple services by hostname instead of port number with Caddy?

Define a named host matcher for each subdomain and scope a handle block to it, proxying to the container by name and internal port. A single wildcard certificate block covers all subdomains, so adding a new service is two lines and a reload.

Why does my Tailscale hostname not resolve even though the device is on the tailnet?

Tailscale's MagicDNS only works if the device's DNS resolver is pointed at 100.100.100.100. Enable MagicDNS in the Tailscale admin console. As a fallback for SSH, add a Host alias in ~/.ssh/config pointing directly at the 100.x.y.z tailnet address so you are not depending on name resolution.