One box, thirty services, zero open ports
There's a quiet machine in my living room that runs about thirty-five services: the family's photos, our files, the movies, a groceries app, an AI gateway, my side projects, and the DNS for the whole house. It has exactly one inbound port open to the internet, and it isn't a web port.
This post is a tour of its architecture. Not the code — the shape. Every section explains one idea well enough that you can steal it for your own setup, whatever you build it with: Docker Compose, Kubernetes, a pile of systemd units. The stack matters much less than the decisions.
Names, domains, and addresses throughout are placeholders — the shapes are real, the identifiers aren't. Four properties organize everything that follows:
- No open web ports. The internet reaches this box without a single HTTP port-forward.
- One login. Every web UI sits behind one passkey — no passwords, no per-app accounts.
- Three watchers. Every job and route is monitored by nets that catch different failure shapes.
- Recoverable. If the machine burned down tonight, recovery is four steps.
Here's the whole thing. Click any node to see what it is and why it's there — or press play on a journey and watch a real request cross the map: your photos loading from a café, a question reaching the AI, a movie arriving. Each section below zooms into one region of this map.
01 · Two front doors
Start with the question every homelab has to answer: how does a request reach a service? This box answers it twice, because there are two kinds of visitor.
From the couch, the path never leaves the house. The box runs the LAN's DNS (Pi-hole), and it answers every *.home.example name with the server's own LAN address. The request lands on a reverse proxy (Traefik) listening on 443 with a real wildcard certificate — not a self-signed one you have to click through. The trick is the DNS-01 ACME challenge: you prove domain ownership by publishing a TXT record, so you can get a browser-trusted certificate for names that only exist inside your house. No certificate warnings, ever, for services the internet can't even see.
From outside, the path is stranger: there is no way in, so the box goes out. A Cloudflare Tunnel keeps a persistent outbound connection to Cloudflare's edge; public hostnames resolve to Cloudflare, TLS terminates there, and requests ride the tunnel back down into a proxy entrypoint that is reachable only through the tunnel. The router forwards nothing. Port-scan the house and the web surface simply isn't there.
Same hostname, two different answers depending on where you ask from — that's split-horizon DNS, and it's what makes one URL work seamlessly from the couch and from a café. Trace a request end to end from either origin — watch where DNS diverges, where TLS terminates, and where the login gets spliced in:
The one genuinely open port is WireGuard, for when I need to be on the LAN remotely — that, plus dynamic DNS to keep the home IP findable, is the entire inbound surface.
The stealable idea: you don't need a single open HTTP port to serve the internet.
02 · One passkey to rule them all
Thirty web UIs means thirty login screens, thirty passwords, thirty places to have a weak one. The fix is a tiny self-hosted identity provider (Pocket ID — a single small binary) that does exactly one thing: OIDC with passkeys only. No passwords exist. TouchID, FaceID, or a hardware key, for everything on the box.
The interesting part is that apps vary wildly in how much SSO they can do, so there are three integration tiers, in strict order of preference:
- Tier 1 — native OIDC. The app speaks OIDC itself; it becomes a client of the identity provider and the login button goes straight to the passkey. Best case.
- Tier 2 — trusted header. The app can't do OIDC but can trust a header like
Remote-User. The proxy runs a forward-auth middleware: it does the OIDC dance itself, then injects the identity as a header. The app auto-logs-you-in. No second login screen. - Tier 3 — bare forward-auth. The app's own auth is disabled entirely, and the proxy's gate is the only lock on the door. Fine for internal dashboards.
And an honest fourth tier: apps that keep their native auth because their clients can't cope — the media server's TV apps can't do an OIDC redirect, so it stays on its own accounts. Pragmatism beats purity.
Two details carry the security weight. First, machine traffic must never see a login page: API calls, webhooks, e-reader syncs all bypass the gate — by header or path — and the proxy strips those same identity headers from anything the client sent, so a bypassed path can't be used to forge an identity. Second, authorization happens at the identity provider, not in the apps: each app is its own OIDC client with an allowed-groups list, so the family group sees the photo and grocery apps, and only admins ever reach the dashboards and the download stack. A family member who tries an admin app is refused before the app even hears about it.
Step through all three flows — a browser doing the passkey dance, an API caller skipping the gate, and an attacker trying to spoof the identity header:
There's one more trick for Tier 2, and the attacker lane above runs into it. An app that blindly trusts a header is only safe if nothing except the proxy can talk to it — so every header-trusting app lives on its own isolated network where the proxy is the only other member. "Just dial the app directly" fails not because a rule forbids it, but because from the attacker's position no route to the app exists.
The stealable idea: SSO everything, authorize at the door, and network-isolate anything that trusts the proxy.
03 · The fail-closed VPN box
The download stack routes through a commercial VPN. The naive way to do this — point each app's traffic at a VPN interface — has a horrible failure mode: when the VPN drops, traffic quietly falls back to your ISP. It doesn't stop; it leaks. And you find out never.
The architecture that fixes it: one container (gluetun) owns a network namespace and the VPN tunnel inside it, with firewall rules that only permit traffic through the tunnel. Every download app joins that container's network rather than having a network of its own. They can't route around the VPN because, from where they stand, no other network exists. VPN down means their packets have nowhere to go — traffic stops instead of leaking. Fail-closed, structurally.
Kill the tunnel yourself and watch the difference:
Nice-to-haves that fall out of the same design: the VPN provider's port-forwarding gets fed to the torrent client automatically, each concern gets its own tunnel (the download stack and my network-scanner side project don't share an exit), and the VPN key's expiry date emails me a month before it becomes an outage.
The stealable idea: don't route apps through a VPN — put them inside one.
04 · Data that survives
Everything above is stateless plumbing that could be rebuilt in an afternoon. The data is the part that actually matters, so it gets the most deliberate design on the box: two ZFS pools with different jobs. A fast NVMe pool holds every app's state and databases; a big two-disk mirror holds the bulk — photos, files, media.
The policy that makes it interesting: snapshots are tiered by how replaceable the data is. App state snapshots every 15 minutes with a week of dailies. Personal files hourly. And the media dataset — terabytes of it — is snapshotted never, because it's re-downloadable by design and snapshotting it would burn mirror space protecting the one thing that doesn't need protecting. On top of that, the NVMe pool replicates hourly onto the mirror, so app state survives its own disk dying.
Scrub through time — delete a file, find which snapshots still hold it, kill a disk and follow the recovery path:
Honesty required by the architecture: a mirror in the same house protects against disk failure, not fire, theft, or ransomware — off-site replication is a known, priced, consciously deferred gap. And snapshots faithfully preserve logical corruption: a bad app migration survives the restore. Snapshots are a time machine, not a backup strategy.
One more data decision: instead of fifteen apps each dragging a database sidecar along, one shared Postgres serves them all — each app gets its own database and role, auto-provisioned on first boot. One thing to tune, back up, and monitor, instead of fifteen.
The stealable idea: tier your snapshot policy by how replaceable each dataset is.
05 · Secrets without a vault server
Every credential the box needs — API tokens, VPN keys, admin passwords — lives in the config repo itself, encrypted (sops + age), decrypted into RAM at boot. No vault service to run, no secret store to back up separately: the secrets travel with the config.
The design decision worth stealing is that every secret is encrypted to two keys: the machine's own SSH host key (so the box decrypts its secrets with something it inherently has — nothing to provision), and my personal key, which lives in the password manager. That second recipient is the disaster-recovery plan: any future machine plus that one key can become this server.
A second class of secrets — internal database passwords, session keys — is machine-generated on first boot and never known to anyone. Rotation is deletion: remove the file, reboot, everything re-rolls and reconnects.
The stealable idea: two recipients per secret — one for the machine, one for the human.
06 · Nobody watches the watchmen, so three things do
Monitoring fails in a specific, sneaky way: the kinds of failure are different shapes, and any single tool only catches one shape. So the box runs three nets:
- Email on failure catches jobs that run and fail — every scheduled job (backups, upgrades, cert renewals) fires an email with its logs when it exits non-zero.
- A dead-man's switch catches jobs that stop running — each job pings a self-hosted healthchecks instance when it completes; if the ping stops arriving, that's the alert. A crashed timer can't email you about itself. Silence is the signal.
- An outside-in prober catches things that look broken from the user's side — it hits every service's public route like a visitor would, and checks certificate expiry while it's there. Config perfect, service up, route broken: this is the net that notices.
Dashboards, metrics, and logs exist too (Prometheus, Grafana, Loki), but they're for the autopsy. The three nets are what wake me up.
The stealable idea: you need all three nets — ran-and-failed, stopped-running, and looks-broken-from-outside.
07 · The AI floor
The server doesn't run AI models — it's the wrong machine for that. The gaming PC across the house has a 24 GB GPU that sits idle most of the time. So the architecture splits the roles: the gaming PC serves models (chat, embeddings, speech-to-text, text-to-speech, image generation) over the LAN, and the server fronts it with a gateway (LiteLLM) that speaks the OpenAI API.
Everything on the box that wants AI talks to the gateway and nothing else. That one indirection buys a lot: any OpenAI-compatible app works unmodified; API keys are minted and metered per consumer; a cloud model can be slotted in next to the local ones under the same API; and the chat UI (Open WebUI), the RAG pipeline, and a private meta-search engine (SearXNG) for web-augmented answers all hang off the same endpoint. Tool servers (MCP) mount through the gateway too — the groceries app becomes a tool the models can call — so clients hold one key while the gateway holds all the upstream tokens.
The gateway's quietest consumer isn't a person — it's the automations. A workflow engine (n8n) runs the household's background jobs against the same endpoint, and the standout is the reading pipeline: overnight it fetches everything my RSS feeds published, hands the pile to the local chat model to read and rank, and files the few articles worth my time as tasks in the to-do app (TickTick) — through the same MCP mounting, so the workflow holds one gateway key and nothing else. This is the part free tokens actually change: "have the AI read everything" stops being a cost decision, and a whole category of automation opens up that you'd never pay a cloud API for. AI you use every day but never chat with.
Fire each kind of request and watch it route. Notice how the RAG flow touches three models and a database while the app upstream sees a single API call — how the model slots light up on the rig as they load into VRAM — and how the overnight automation runs the same rails with no human anywhere:
The stealable idea: put a gateway in front of your GPU and make everything speak one API.
08 · Television on autopilot
The media stack is the most automated corner of the box — a pipeline where the only human step is asking for a movie.
A request app (Jellyseerr) takes the ask. An indexer manager (Prowlarr) searches. The deciders (Radarr and Sonarr) pick a release using quality profiles synced from community-maintained ones — encoding opinions I don't have to maintain. Acquisition happens inside the VPN box from section 03. The import step is a hardlink, not a copy: the same file appears in the library instantly, no duplicate bytes. The media server (Jellyfin) serves it — deliberately outside the VPN, because streaming to the couch shouldn't route through another country — using the CPU's integrated GPU for transcoding. Subtitles arrive on their own (Bazarr, plus a local speech-to-text model that generates them when none exist).
Then the janitors: one watches the download queue for stalled or poisoned grabs; another handles retention — and that one runs double-fenced in dry-run, config saying simulate and a read-only mount making deletion physically impossible until I trust it.
The stealable idea: hardlink imports, community-synced quality profiles, and janitors you dry-run for months.
09 · A two-minute PaaS for side projects
Every developer has half-finished side projects; mine deploy themselves. Declaring a new app to the platform is three facts: a name, whether it needs Postgres, and whether it's public or lab-stage. The platform materializes the rest — container, database and credentials, routes on both front doors, SSO, a dashboard tile, and a deploy loop.
The deploy loop is deliberately boring: a timer polls the container registry every two minutes. New image → restart the app → health-check it through the real edge — the same proxy, DNS, and TLS a user would hit, because "the container is up" and "the site works" are different facts. Then it emails — but only on transitions: working→broken and broken→working. A thing that breaks at 3 a.m. and heals at 3:02 is Tuesday; a thing that breaks and stays broken is one email.
There's deliberately no auto-rollback — deploy-and-report suits a solo operator; rollback is me pushing the previous tag. The end-to-end experience: git push, and about two minutes later the change is live behind SSO with a passkey on its login page.
The stealable idea: a timer, a health check through the real edge, and transition-only emails are 90% of a PaaS.
10 · The part where I admit it's all one git repo
Everything above — every route, tier assignment, snapshot policy, monitored job — is declared in a single repository. The box runs NixOS, but that's the least interesting fact in this post: the architecture works on any stack. What declarativeness buys is cheap honesty — the running system can't drift from the described one, because the description is the input, not the documentation. Updates arrive weekly as revertible commits (staged for next boot, never auto-rebooting); rolling back the OS is git revert.
And it's why recovery is four steps: install the OS, clone the repo, restore the one personal key from section 05, rebuild. Data comes back from the mirror. If you want the machinery-level tour — the module system, the option-merging tricks, the container quirks — tell me, and I'll write it. It's a post of its own.
11 · The ledger
What this costs: a hobby's worth of weekends, and a SPOF list I maintain honestly — the box runs the house DNS, so the box being down means the internet is down for the family until a phone hotspot and an SSH session fix it. What stays consciously unbuilt, each with a written trigger to revisit: off-site backup (priced, deferred), a connection pooler the shared Postgres doesn't need yet, memory caps on the heavy containers. Engineering is choosing what not to build, and writing down why.
What it buys back: the four properties from the top, now all explained. No open web ports — two front doors, both closed. One passkey — three tiers and a bouncer at the identity provider. Three watchers — for three shapes of failure. And recoverable — because the whole thing is a description that happens to be running.
Steal any of it.