One laptop, seven containers, and one idea that decides almost everything else: there are two doors into this box, and not everyone gets both.
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.
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.
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.
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.
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.
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.
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.
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.
| Pattern | Written as | Used 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 all | no 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.
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.
| Service | Job | Published on | Who reaches it |
|---|---|---|---|
| Caddy | Reverse proxy and HTTPS front door | tailnet :443 :8443 :8444 :8445local :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 adminall :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 |
| Path | Holds |
|---|---|
~/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
This is the roadmap, not the fault list. Things that are broken or missing today live in Operating manual §07.
| Thing | State | Notes |
|---|---|---|
| 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. |