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
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.
Nginx Proxy Manager
self-hostedEvery service on my home server binds to loopback, so this proxy is the only way in and the only place that decides what exists. It terminates HTTPS with real Let’s Encrypt certificates issued over the DNS challenge, which means hostnames that resolve to a private address still get a genuine padlock instead of a self-signed warning. Adding a host takes two minutes in the web UI, which is the reason the loopback rule never gets bent.
HTTPS for names the internet cannot reach
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
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.
A WireGuard mesh across my laptop, phone and home server, so the self-hosted things stay off the public internet while remaining one hostname away from anywhere. It removed the last reason I had to open a port on my router.
The one exception, and it dials out
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
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.
Home Assistant
self-hostedEvery sensor, plug and light talks to a container in my flat rather than a vendor cloud, so automations keep working when the internet is down and the history stays on my disk. Integrations cover the devices I already owned, and ESPHome turns a five euro microcontroller into a first class sensor with a few lines of YAML.
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
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.
Immich
self-hostedThe photo library that stopped being rented.
BorgBackup
self-hostedThe only container whose failure would actually hurt.
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.
Home Assistant is the oldest thing on that machine by a decade. The lights, sensors and buttons it drives started on a Raspberry Pi in 2017 and moved here once the server existed, and that side of the house is written up separately under Home automation.