What it is, and why it is built this way

Architecture

One laptop, seven containers, and one idea that decides almost everything else: there are two doors into this box, and not everyone gets both.

Machine
ThinkPad T470 · i5-7300U · 7 GiB
System
Ubuntu Server 26.04
Containers
7
Router ports open
0
01

The machine

A ThinkPad T470 with an i5-7300U, 7 GiB of RAM and a 238 GB NVMe drive. It sits on WiFi at a fixed address, has no monitor attached, and nobody ever logs in at a screen. Everything reaches it over the network.

No GPU, but a video engine

There is no discrete GPU, which is why a large local assistant waits on better hardware. But the Intel HD 620 has a Quick Sync engine that does video encode and decode in dedicated silicon — useless for language models, excellent for streaming. That asymmetry is why Jellyfin was built early and the assistant was not.

7 GiB is the real constraint

Seven containers, one of which loads a language model, on a box with 7 GiB and four threads. Every container therefore carries a memory ceiling, and ollama carries a CPU ceiling too. The point is not efficiency — it is that no single service can starve Jellyfin mid-playback. See §05, memory ceilings.

One early problem solved itself. The Ubuntu installer claimed only 100 GB of a 235 GB partition, leaving ~135 GB stranded in the volume group. Before that became worth fixing, a 931 GB external SSD arrived and the media library moved onto it. The internal disk now holds only the OS and the services, with room to spare, and nobody had to touch a partition table.

02

Two doors

One requirement drives the configuration of every service: roommates get media, and nothing else. No file shares, no admin panels, no shell.

That is enforced by which network interface each service listens on. Two people asking for two different things arrive on two different paths, and the box does not answer the wrong question on the wrong door.

Private door

Me, from anywhere

A laptop or phone on any network on earth, through an encrypted WireGuard tunnel, arriving on the tailscale0 interface at 100.64.0.10, where Caddy answers on :443, :8443, :8444 and :8445 with real HTTPS.

Requires membership in my Tailscale network. Nothing here is reachable from the house WiFi, and no port is forwarded on the router.

Shared door

Roommates, in the house

A phone or TV on the house WiFi, over plain HTTP, arriving on the wlp4s0 interface at 192.168.1.10, where Jellyfin answers on :8096 — and nothing else does.

Syncthing’s admin panel is not merely password-protected from here. It does not accept connections on this interface at all.

The trap this avoids

A firewall looks like the obvious way to enforce this, and it would not have worked. Docker writes its own iptables rules, evaluated before ufw’s, so a container that publishes a port is reachable from the whole network while the firewall reports itself active and set to deny everything. Trusting ufw status here produces a server that looks locked down and is not.

So each port is pinned to a specific address in docker-compose.yml instead, and every one is verified by connecting from both sides rather than assumed. bin/homie-check re-tests all of them on every run.

The four binding patterns

Every ports: entry in the compose file is one of these, and the rule is written at the top of that file so the next service does not have to re-derive it.

PatternWritten asUsed for
Tailnet only"${TAILNET_IP}:PORT:PORT" Admin UIs — Syncthing, qBittorrent, and Caddy’s own listeners
Genuinely LAN"PORT:PORT" Jellyfin on 8096, and Syncthing’s sync protocol on 22000
Localhost only"127.0.0.1:PORT:PORT" These docs, on 8446 — reachable solely through an SSH tunnel
No port at allno ports: block Best. n8n and ollama. Caddy reaches them by container name on the private Docker network, so there is no host socket to mis-bind.

The fourth row is the strongest form of the rule and is where new services should land by default. A port that is never published cannot be published to the wrong interface.

03

The seven services

Each is a block in a single docker-compose.yml. They address each other by name on a private Docker network; only the ports below are published outward.

ServiceJobPublished onWho reaches it
Caddy Reverse proxy and HTTPS front door tailnet :443 :8443 :8444 :8445
local :8446
Me only
Jellyfin Media streaming, one login per person all :8096 Me + roommates
Syncthing Keeps my School/Work folder identical across my own devices tailnet :8384 admin
all :22000 sync
Admin: me only
Sync: my devices
Gluetun Holds the VPN tunnel, and owns the network namespace qBittorrent lives in tailnet :8080, on qBittorrent’s behalf Me only
qBittorrent Downloads, and files what it downloads into the library Publishes nothing — it has no network of its own Me only
n8n Runs the school and work automations: Canvas deadlines into Google Calendar and a morning brief Nothing. Caddy reaches it as n8n:5678 Me only
ollama Local language model, CPU only — for reading dates out of instructor prose and composing the brief Nothing. n8n reaches it as ollama:11434 n8n only

Where things live on disk

PathHolds
~/homeserver/ The repo — what defines the server. docker-compose.yml (every service), Caddyfile (every route), series-map.conf (which show goes in which folder), bin/ (health check, backup, filing hook, previews), systemd/ (the timers), docs/ (these pages)
~/homeserver/.env Host values and every secret. Never committed
~/homeserver/config/ Live state: databases, keys, certificates. Never committed
~/data/school-work/ The synced folder. Deliberately outside the repo
/mnt/t7/ The external SSD: Movies/, TV/, downloads/, backups/

Keeping content outside the repo paid off literally. When the library moved from the internal disk to the external SSD, one line of the compose file changed and nothing else did — no re-adding libraries, no reconfiguring Jellyfin, no lost watch history. Services address storage by container path, so the host can rearrange itself underneath them.

The same indirection has a sharp edge

Containers see their own filesystem, not the host’s. Jellyfin’s library is /media/movies from the inside even though it is /mnt/t7/Movies from the outside. Typing the host path into a service’s settings is the single most common way these setups fail to find their own files.

And Docker’s default, when a folder it was told to mount does not exist, is to create it empty. With the SSD unplugged, that default would hand Jellyfin two empty folders and let it conclude the entire library had been deleted. Every bind onto the T7 therefore sets create_host_path: false, so the container refuses to start instead. A service that will not start is a five-second diagnosis; a library that quietly erased its own watch history is not.

04

Real certificates, with nothing exposed

Certificates are normally proven by answering a challenge from the public internet, which is impossible here — nothing is exposed to the public internet, and that is the point.

The way out: Tailscale issues genuine Let’s Encrypt certificates for the machine’s private network name. Caddy talks to the Tailscale daemon through a socket shared into its container, collects the certificate, and renews it on its own. No domain was purchased, port 80 is never opened, no renewal job is scheduled, and nothing was added to the LAN. The result verifies against the normal certificate authorities — confirmed by connecting without the flag that skips verification.

Why the URLs have port numbers

Tailscale issues a certificate for one exact name, with no wildcard. The usual trick of giving each service its own subdomain cannot work, because those names would have no valid certificate.

The fallback — putting services on paths like /syncthing — fails for a different reason: Syncthing’s interface calls its own API at absolute paths and breaks underneath a prefix. So services are separated by port on the single certified name. Slightly ugly URLs, in exchange for real certificates and working interfaces.

This turned out to matter beyond tidiness. Google refuses http:// redirect URIs for anything that is not localhost, so no plain LAN address could ever hold a Google Calendar credential. The Tailscale certificate on :8445 is what makes n8n’s OAuth redirect legal at all.

05

Decisions worth defending

Each of these was a fork with a real alternative. The alternative and the cost are named, because a decision recorded without its cost is just an assertion.

Exception to the rules

Tailscale runs on the host, not in a container

Everything else here is containerised. This is not, because it is the remote-access lifeline: if it lived in Docker and Docker failed to start after a reboot, the way back into a headless machine would be gone with it. Containerising it also requires handing the container broad network privileges, which removes most of the isolation that made containerising it appealing.

Containment, not configuration

The VPN is a sidecar container, not a host client

A VPN client on the host would route everything through NordVPN, breaking Tailscale and the roommates’ access to Jellyfin. Scoping it back to one process would mean hand-written policy routing and a hand-written kill switch.

Instead Gluetun owns a namespace whose only way out is the tunnel, and qBittorrent joins that namespace rather than having one of its own. The kill switch is structural: if the tunnel dies, qBittorrent has no interface to fall back to. Networking §04 has the mechanism and the one non-obvious consequence.

Constraint, not preference

Downloads land on the external SSD, beside the library

The internal disk looked obvious, because it supports hardlinks — the trick that lets a download appear in the library without being copied. The T7 is exFAT for Mac compatibility and cannot do that.

It turned out not to matter: the final hop from internal disk to external SSD crosses drives and copies anyway, so the feature would be paid for and never used. Putting downloads on the same drive as the library instead makes filing an instant rename that moves no bytes at all. Free space settled the rest.

Forced by 7 GiB

Every container has a memory ceiling

Added when n8n and ollama arrived. On a 7 GiB box, one leaking service takes down playback for everyone, so each limit is set from a measured figure rather than a guess — and deliberately generous where being wrong is expensive. Jellyfin gets 2 GiB because a limit that is too tight does not throttle a transcode, it kills it mid-playback.

Node needs telling twice: it does not read the cgroup limit, so n8n also caps its own heap, and its forked task runner caps a second heap that does not inherit the first setting. ollama is capped at 3 of 4 CPU threads so a long generation can never starve a transcode.

Latency does not matter here

The language model runs locally, slowly, for free

The jobs that need it run at 05:10 and 17:10; Jellyfin is used in the evenings. A model that takes a minute to think costs nothing, where a hosted API costs money and ships school content to a third party.

OLLAMA_KEEP_ALIVE=0 unloads the weights the instant a request finishes — the default holds ~2 GB resident for five minutes, which on this box is the difference between coexisting and swapping. Swapping to a hosted model later is three lines in .env, because every workflow calls an llm-complete sub-workflow rather than an API directly.

The model has to earn the call

A regex first, and the model only on what is left

Having a language model on the box creates a pull toward using it. The rule here is that every job gets a deterministic attempt first, and the model is reached for only where that provably fails — with the eval deciding, not the preference.

What survives for the model is the part no pattern reaches: a due date buried in an instructor's paragraph, and turning structured state into a brief worth reading.

Bought for one specific thing

n8n rather than a script and a timer

For the Canvas sync alone a script would be simpler, and this box already has the convention for one. What n8n actually buys is OAuth token lifecycle: storing Google refresh tokens, noticing a 401, re-exchanging, coping with revocation. That is the part that breaks silently three weeks later, and hand-rolling it is the most tedious piece of the job.

It is used thin on purpose. n8n owns credentials, scheduling and delivery; the real logic lives in Code nodes that can be read and version-controlled.

Cost accepted

Roommate traffic stays unencrypted

Giving roommates HTTPS would mean either buying a domain or installing a custom certificate authority on every one of their phones and laptops. Both were rejected in favour of leaving Jellyfin on plain HTTP inside the house, where the WiFi’s own encryption covers the wire. The trade is explicit: those accounts use throwaway passwords.

Under review

The media folder is mounted read-only

Jellyfin cannot write to the library, so no bug or bad setting can delete anything. This was my call, not a stated requirement. It also blocks Jellyfin from saving subtitles beside files, writing metadata sidecars, or deleting media from its interface.

It does not affect the downloader, which has its own writable mount onto the same folders — the boundary is drawn at Jellyfin’s door, not at the disk. One word to change if the trade is not wanted.

Deliberate omission

No Samba, no general file sharing

The obvious way to get files onto a home server is a network share. There is not one, because a share visible to the house is exactly what the two-door design exists to prevent. Media arrives by direct copy or from the downloader, neither of which puts a share on the house network.

Open question again

These pages are served on localhost, not the tailnet

Every other Caddy listener binds to the tailnet. This one binds to 127.0.0.1, chosen while the Mac could not route over the tailnet at all — a tailnet binding would have been unreachable from the one machine that wanted it. An SSH tunnel adds no network exposure and SSH already encrypts the hop, so plain HTTP here costs nothing.

That constraint is gone as of 2026-09-20, so the binding is now a free choice rather than a forced one. Moving it to the tailnet would drop the tunnel step; keeping it on localhost keeps the docs off the network entirely. Worth deciding deliberately rather than by inertia.

Caught in review

A password nearly went into git history

The build log contained a plaintext password in a note. It was found and removed before the first commit, which matters because git history is permanent — a private repository does not undo a committed secret, and deleting it later does not either. Real config now lives at the repo’s top level, while state and keys sit in an ignored directory.

06

The repo is the server

Everything that defines this machine is a small number of text files in a private repository: every service, every route, the build log, the helper scripts, the show-to-folder table, the timers, these pages, a secrets template, and the list of what to keep out. The machine itself is disposable — with the repo and a fresh Ubuntu install, it comes back.

What is deliberately not in there matters just as much: no passwords, no private keys, no databases, no media. .env.example lists which values are needed without containing any of them, so the repository is reproducible without being dangerous.

The cost of that safety

The excluded files exist only on this machine. Nothing in the repository could restore Syncthing’s private key, and losing it means every paired device has to be approved again by hand. bin/homie-backup exists to close exactly that gap — it archives the things git is right to refuse — and since 2026-09-20 a systemd timer runs it nightly rather than relying on anyone remembering.

One gap is still open: those backups land on the same physical drive as the media they are meant to outlive. That covers deleting a file by accident and nothing else. See the known gaps.

07

What is next to build

This is the roadmap, not the fault list. Things that are broken or missing today live in Operating manual §07.

ThingStateNotes
The school automations Infrastructure built n8n, ollama, the state database, the timers and the check script all run. Zero workflows exist yet, because each needs a credential only I can fetch by hand.
Indexer search Not started Downloading and filing are automatic; finding a release to hand the downloader is still done by hand. Prowlarr would sit in front of qBittorrent and must address it as gluetun, not qbittorrent.
A local assistant over these docs Designed, not built Ask “how do I restart the VPN?” and get the answer with the section it came from. Designed as retrieval, not fine-tuning: the corpus is small and changes weekly, so a fine-tune would bake in a stale snapshot and state it confidently. Keep the model deliberately dumb and make every fact it says text retrieved a half-second earlier.
The same idea over coursework Waiting on hardware Pointed at the synced School/Work folder. The retrieval half needs no GPU and could be built now; a model big enough to sit on top of it is what waits.
The stranded 135 GB Parked Moving media to the SSD left the internal disk holding little more than the OS, so the pressure that made this interesting is gone.