My home server is an old desktop with four cores and 8 GB of RAM running OpenMediaVault, and it has thirty three containers up right now. A third of them are the usual media stack, which the internet has documented to death, so this is about the interesting part: of those thirty three, only two decide what is reachable and by whom.
Nothing listens on my network Link to heading
Almost every compose file on that machine has the same shape:
services:
immich-server:
ports:
- "127.0.0.1:2283:2283"
That prefix is the entire security model. The port exists on loopback only, so no device on my LAN can reach the container by address, not even by accident, and a container I misconfigure cannot quietly become a public service.
Two containers break the rule on purpose. Pi-hole, because it has to answer DNS on port 53 for the whole house, and Nginx Proxy Manager, which owns 80 and 443. Everything else is reachable only if the proxy has been told a hostname for it.
HTTPS for names the internet cannot reach Link to heading
Internal services live on subdomains of a domain I own. Those DNS records are public, and they answer with the private address the server holds on my LAN. Anyone can resolve the name; nobody outside the flat can route to it.
That is also what makes the certificates work. Nginx Proxy Manager asks Let’s Encrypt over the DNS challenge, which proves I control the domain by writing a TXT record rather than by accepting an inbound connection. A service the internet cannot reach therefore still gets a genuine certificate: no self-signed warnings, no private CA to install on every phone, a normal padlock everywhere.
Pi-hole answers those names on the LAN and blocks ads for every device while it is at it, including the ones that will never run an extension.
Two things break this, and neither is in the tutorials.
The first is DNS rebinding protection. A resolver that guards against it throws away any answer pointing at a private address, which is exactly what my records do, so the name resolves to nothing and the service looks dead. Plenty of routers ship with the protection on, dnsmasq and Pi-hole can do the same, and the fix is to whitelist the one domain rather than to switch the whole thing off.
The second is the browser. Firefox and Chrome will happily send DNS over HTTPS to a public resolver, walking straight past the machine that knows the internal names. Everything works on one laptop and fails on another, and the difference is a setting nobody remembers changing.
The price of the DNS challenge is worth saying out loud too: an API token for the DNS provider now lives on the server. Scope it to the single zone it needs, never to the account.
The VPN is the way in Link to heading
Tailscale runs as a container advertising the route of my home subnet. My laptop and my phone are on the same mesh, so away from home they resolve and reach exactly the same hostnames as they do from the sofa, over WireGuard, with the certificates still valid.
The router has no port forwarding, no dynamic DNS and no UPnP. The attack surface of the whole setup is one Tailscale identity.
The one exception, and it dials out Link to heading
Webhooks are the case a VPN cannot solve: a third party has to POST to a URL that really exists on the internet. That single hostname goes through a Cloudflare tunnel. The container opens an outbound connection to Cloudflare, Cloudflare terminates TLS at the edge and pushes requests back down the same connection to n8n. One name is public, my address is not, and there is still nothing listening at home.
What runs behind all that Link to heading
Home Assistant is the container I would restore first. Every sensor, plug and light in the flat talks to it locally, so the house does not stop working when the line does, and a year of energy readings sits on my disk instead of in someone’s analytics pipeline. ESPHome compiles the firmware for the cheap microcontrollers I use as sensors, a Matter server handles the devices that speak it, and n8n deals with whatever has to leave the house.
The rest is what I open every day. Immich replaced Google Photos properly, faces and search included. Karakeep archives the links I mean to read, with Meilisearch behind it. Calibre-web feeds the Kindle. Five small containers watch the others: Portainer, Dozzle for logs, Beszel for CPU and temperature history, What’s Up Docker for image updates, and Homepage as the front page that ties them together.
The unglamorous half Link to heading
None of this matters if the disk dies, and exactly one container holds something I cannot download again. The photos are the only irreplaceable data on the machine; everything else is a compose file, a config directory and an afternoon.
So Immich is treated differently. Borg backs it up to a Hetzner storage box, encrypted before it leaves the house and deduplicated, so keeping a long history costs almost nothing and a bad run cannot quietly overwrite the good copies. The compose files ride along in the same archive, which is what turns “rebuild the server” from a project into a chore.
Two containers out of thirty three do the work that matters: the proxy that decides what exists and the VPN that decides who is inside. One backup covers the only data that cannot be recreated. Everything else is replaceable in an afternoon, which is the whole point of building it this way.