How to watch things, sync files, get in from outside, tell whether anything is wrong, and restart it without breaking it. Written for the people who use the machine, not the person who built it.
Everything makes more sense once you know there are two ways in, and that which one you are using decides what you can reach.
| Door | Who gets in | What it reaches | Encrypted? |
|---|---|---|---|
| the LAN Home WiFi |
Anyone on the house WiFi | Media only | No — plain HTTP |
| the tailnet Tailscale |
Only devices the owner approved | Everything, from anywhere in the world | Yes — real HTTPS |
Roommates use the first door and never need to install anything. The second exists so the owner can reach the machine from outside the house without opening a single port on the router. Architecture §02 explains why it is built this way.
On the home WiFi, your Jellyfin password travels unencrypted. The WiFi password protects it from the street, but not from other people in the house. Use a throwaway password here — never one you use anywhere else.
Sign in with the account you were given. Everyone gets their own login, so watch history, resume points and preferences stay yours.
The browser works, but the apps are better. Install one, then add the server manually when it asks:
The server converts video on the fly when your device cannot play a file directly. That conversion runs on a dedicated video chip rather than the processor, which is roughly three times faster and leaves the machine responsive for everyone else.
It is not unlimited — this is a laptop. Two people converting at once is fine; four is pushing it. If playback stutters, lower the quality in the player’s gear menu; that often lets your device play the file directly instead, which uses almost no server power at all.
Everything you can watch lives on an external drive. If that drive is unplugged the media server refuses to start rather than showing an empty library — so “Jellyfin will not load” and “the drive came loose” look like the same problem. The health check tells you which in one line.
Jellyfin works out what a file is almost entirely from its name and folder. Get the name right and the poster, cast and synopsis appear on their own within a minute. Get it wrong and you get a bare filename with no artwork — and no amount of rescanning fixes it.
/mnt/t7/Movies/Title (Year)/Title (Year).mp4
/mnt/t7/TV/Title (Year)/Season 02/Title (Year) - S02E01 - Episode Name.mp4
Season and episode numbers are two digits. Gaps are fine — a missing episode 3 stays missing rather than renumbering everything after it.
Add a torrent in qBittorrent, set its category to tv or
movies, and that is the whole job. When it finishes, the file is renamed
to the pattern above, moved into the library, and removed from the download list.
The category is the only field that matters. Everything else is already configured.
The filer refuses to guess. If it cannot tell which show a release belongs to, it
leaves the file exactly where it is and writes the reason — plus the one line you
need to add to series-map.conf — to its log:
~/homeserver/config/qbittorrent/qbt-file.log
To see why it decided what it did — what the name parsed to, whether
series-map.conf matched —
ask with a few words from the title. It finds the download, works out its category,
and replays the filing as a dry run with the reasoning switched on. Nothing moves:
bin/qbt-why big buck bunny
bin/qbt-why --as movies sintel # for a download added without a category
“0 kept as video” on a folder that is already in the library means it is leftovers —
artwork, a .txt, a Subs/ folder — which the filer leaves behind
on purpose.
If a filed torrent is still sitting in qBittorrent's list, bin/qbt-forget
<hash> takes it off without touching its files.
That is by design. A file that waits is easy to fix; a file misnamed into the wrong show is not.
For anything that did not arrive as a torrent — a file from a friend’s drive, or one copied over SFTP — a helper script does the naming so it cannot be got wrong by hand:
addmedia movie ~/inbox/somefilm.mp4 "Some Film" 1954
addmedia show ~/inbox/ep1.mp4 "Some Show" 1961 2 1 "Episode Name"
It builds the folder structure, moves the file into place, and nudges Jellyfin to scan. It also refuses to run at all if the external drive is unplugged, rather than filing your video somewhere that will vanish when the drive comes back.
Any file transfer app works — Cyberduck, FileZilla, or the scp command.
The media folder is mounted read-only on purpose, so a bug or a mis-set option cannot erase the collection. Deleting something means removing the file directly over SFTP. A little inconvenience, in exchange for a library that cannot delete itself.
Syncthing keeps a folder identical across your devices. Change a file on your laptop and the server has it moments later — no cloud service, no subscription, and the files never touch anyone else’s computer.
| Folder | On the server | Paired devices |
|---|---|---|
school-work | ~/data/school-work |
homie, admin MacBook |
Install Syncthing on the new device and open its web page.
Find Actions → Show ID and copy the long device ID.
On homie’s Syncthing, choose Add Remote Device, paste that ID,
then open the Sharing tab and tick school-work.
Skipping that tick is the single most common mistake — the device pairs but nothing ever syncs.
Back on the new device, accept both prompts: one to trust homie, one to accept the folder. Choose where the folder should live locally.
Pairing is mutual — both sides must agree. That is why the sync port can sit open on the house network safely: an unknown device can knock, but nothing transfers until someone approves it by ID.
Old versions of every changed or deleted file are kept on the server for 365 days
in a hidden .stversions folder inside the synced directory. Deleting a
file on your laptop does not destroy it here.
Versioning protects you from your own mistakes. It does not protect you from a disk failing — that would take every version with it. There is a real backup as well, running nightly (see §06), but read what it does and does not cover before relying on it.
Tailscale builds a small private network between approved devices. They can talk to each other from anywhere — a café, campus, another country — as if they were on the same WiFi. Nothing is exposed to the public internet and no ports are forwarded on the router.
Once a device is on it, these work from anywhere:
Those carry genuine certificates issued to the machine’s name and renewed automatically, so browsers show a normal padlock with nothing to click past.
The docs are the one exception — they are served on 127.0.0.1 only, so
they need an SSH tunnel rather than a tailnet address:
ssh -L 8446:localhost:8446 homie-lan
# then open http://localhost:8446
Why it is bound that way. In a terminal,
bin/homie-docs renders any page as text instead.
These addresses timed out from the Mac for weeks. The cause was never on either machine: the tailnet’s ACL policy granted only exit-node access and nothing device-to-device, so both nodes correctly dropped every real packet while handshakes kept succeeding. Fixed by adding an allow rule in the admin console — see the diagnosis if it ever recurs.
One command answers “is anything wrong?”. It only reads — it cannot change or break anything, so run it whenever you are curious.
~/homeserver/bin/homie-check
Thirty-nine assertions, grouped. What it covers:
| Group | What you want to see |
|---|---|
| Containers | All seven up, and the ones with health checks healthy |
| Port exposure | Media reachable on the house WiFi; every admin page not. Re-tested by connecting, because Docker bypasses the firewall |
| Memory limits | Every container capped, and none near its ceiling |
| Downloader | Password set, filing hook armed, and qBittorrent’s egress is the VPN, never the home address |
| Jellyfin config | Hardware acceleration on, throttling on, proxies declared |
| Library & disk | T7 mounted with free space; more than zero items indexed |
| Live transcode | Whether a playback happening right now is using the video chip or the processor |
The live-transcode row is the useful one: start something playing, run the check again, and it tells you whether the conversion actually landed on the video chip. A setting being switched on does not guarantee it is being used.
~/homeserver/bin/homie-automation-check
It checks n8n and ollama, then reads the workflow state database directly rather than asking n8n — so “n8n is dead” is not reported by a dead n8n. It emails on failure once SMTP is configured.
A workflow that has never run reports as information, not as a failure — an absence is not a fault when the thing was never built. Once a workflow has run, it gets a deadline: roughly 1.5× its interval, so one missed run is tolerated and two in a row are not.
| Timer | When | Does |
|---|---|---|
homie-backup.timer | Nightly, 03:30 (+ up to 15 min jitter) | Mirrors ~/data/school-work and archives the repo’s
gitignored config onto the T7. Keeps 7 archives |
homie-automation-check.timer | Every 6 hours, at :45 | The automation check above, emailing if something is stale or down |
Both are Persistent=true, so a run missed while the machine was off
happens on the next boot rather than being silently skipped. Confirm with
systemctl list-timers 'homie-*'.
Health checks tell you that something is wrong. Logs tell you why.
| When this misbehaves | Look here | What you will find |
|---|---|---|
| Any service won’t start, keeps restarting |
docker logs NAME --tail 100 |
Startup errors, crashes, config it refused. NAME is jellyfin,
syncthing, caddy, gluetun, qbittorrent, n8n or ollama |
| Playback stutters, won’t play, no artwork |
docker logs jellyfin --tail 100config/jellyfin/config/log/ |
Docker shows the recent picture; the log folder keeps a dated file per day, which is what you want for something that happened yesterday |
| A web address times out, or 502s |
docker logs caddy --tail 50 |
Every request and every certificate renewal. A 502 means Caddy is fine and the service behind it is not — check that service next |
| The VPN, downloads stalled | docker logs gluetun --tail 50 |
Tunnel connects, healthcheck failures, which server it picked. First place to look when downloads stop for no reason |
| Torrents won’t connect | docker logs qbittorrent --tail 50config/qbittorrent/qBittorrent/logs/ |
qBittorrent keeps and rotates its own log; the real reason for a refused connection is usually only in there, not in the web page |
| A download didn’t appear in the library |
config/qbittorrent/qbt-file.log |
The filing script writes here every run, including the reason it refused to
guess and the line to add to series-map.conf |
| A device won’t pair | docker logs syncthing --tail 50 |
Connection attempts and rejections, by device ID |
| An automation didn’t run | docker compose logs -f n8nbin/homie-automation-check |
n8n’s own execution errors; the check reports which workflow is stale |
| The whole machine — drive vanished, boot problem |
journalctl -b -p errdmesg | tail -30 |
Kernel and system errors. This is where a disconnected T7 shows up |
--tail 50 shows the last 50 lines instead of the entire history.
-f follows the log live, so you can watch what happens while you
reproduce the problem — docker logs -f caddy, then load the page that
fails. Ctrl+C stops watching.
This cost three wrong diagnoses across three sessions, so the test that actually settles it is written down here.
tailscale ping is not ping. A
tailscale ping is answered by the Tailscale daemon itself, in userspace,
before the packet ever reaches the operating system or the tailnet’s own packet
filter. It proves the two machines have negotiated a WireGuard session — and nothing
more. Ordinary ping has to survive the whole path, so it is the honest
test:
tailscale ping stevens-macbook-air # succeeds even when everything real is broken
ping -c3 100.64.0.20 # this is the one that tells you the truth
| Symptom | Where to look |
|---|---|
| Both succeed | Networking is fine. Look at the service and its port. |
tailscale ping works, ping does not, in both directions |
The tailnet ACL. Run tailscale debug netmap and read
PacketFilterRules — if it is empty, this node accepts nothing from
any peer, and no amount of poking at either machine will help. Fix it at
login.tailscale.com/admin/acls. |
| Fails one direction only | A host firewall. On homie that is
ufw; on macOS, System Settings → Network → Firewall, or
Tailscale’s own “allow incoming connections”. |
| Name fails, IP works | MagicDNS. Check the route to
100.100.100.100. |
autogroup:internet in an ACL does not mean “the internet is
allowed”. It means “may route out through an exit node”. A policy containing only
autogroup:internet destinations grants no device-to-device access at
all, which is exactly what had happened here — and it compiles to an empty packet
filter rather than to an error.
Stated plainly, so nobody wastes an afternoon on a known gap. This is the one place in the documentation where these are listed; the other pages link here.
| Item | Status | What it means for you |
|---|---|---|
| Backups share one drive | Real risk | Backups sit on the same external SSD as the media. That protects against deleting a file by accident — not against the drive failing, which would take both copies at once. The biggest open gap here. |
| Everything depends on one cable | Single point | Media and backups both live on the T7. Unplug it and there is nothing to watch and nowhere to back up to. |
| The n8n encryption key exists in one place | Back it up | It encrypts every stored credential. Lose it and they are not “log in
again”, they are unrecoverable. It is in .env, captured by the
nightly backup — which is on the same drive. Keep a copy off-box. |
| School automations | Zero workflows | n8n and ollama run and are healthy, but no workflow is built yet; each needs a credential fetched by hand. |
| Roommate accounts | One made | Two Jellyfin accounts exist: the owner’s and one roommate’s. Any other roommate still needs one created. |
| Most folders have messy names | Cosmetic | Most shows and films still sit in raw release-name folders. Newly filed episodes use the clean naming, so a show can briefly appear twice in Jellyfin until the old folder is renamed. |
| Media passwords on WiFi | Accepted trade | Unencrypted inside the house, so roommates need no setup. Use throwaway passwords. |
| Newest video format (AV1) | No hardware support | This chip predates it. AV1 files fall back to the processor and may stutter. |
| Searching for things to download | By hand | Downloading and filing are automatic; finding a release still means going and looking for one. |
| New episodes are not fetched on their own | Not built | Nothing watches your shows for you. When a new episode airs, you go and get it. |
| Downloads cannot accept incoming peers | By design | The VPN hides the home address and offers no way for strangers to connect in. Downloads are unaffected; you just share back to fewer people. qBittorrent shows a red “not connectable” dot, and that is expected — why. |
homie is a laptop that runs all the time. Mostly you never touch this — services restart themselves after a power cut, and the whole stack comes back when the machine boots. You need this section for three situations: the machine has to move, one service is misbehaving, or you changed something and need it to take effect.
Never yank the power. Downloads are mid-write and the media drive is exFAT, which is unforgiving about interrupted writes. Always shut down properly and wait for the machine to go quiet before unplugging anything.
Almost always the right move — it leaves everything else running. From ~/homeserver:
docker compose restart jellyfin # or syncthing, caddy, n8n, ollama
sleep 10 && bin/homie-check
qBittorrent has no network of its own; it lives inside the VPN container. Restart
or update gluetun and qBittorrent is left connected to nothing — it
looks fine and can reach nothing at all. It must be rebuilt afterwards, not
merely restarted:
docker compose up -d --force-recreate gluetun
docker compose up -d --force-recreate qbittorrent
Always both, always in that order. homie-check catches it if you
forget: it will say qBittorrent has no egress.
cd ~/homeserver
docker compose down — stops all seven containers. Data, settings
and library are untouched; only the running containers go away.
docker compose up -d — brings them back, in dependency order.
bin/homie-check — expect every check to pass.
Because down removes the containers and up creates fresh
ones, the VPN two-step is handled automatically here. That is why a full cycle is
sometimes simpler than fixing one service.
If anything is downloading, pause it in qBittorrent and let the current pieces finish writing.
cd ~/homeserver && docker compose down — stops services cleanly
rather than having them killed mid-write by the shutdown.
sudo shutdown -h now — needs your password.
Wait until the power light is off before moving or unplugging it. If the T7 is coming too, unplug it only after the machine is fully off.
Plug the T7 in first, then power on the laptop. This order matters: the media containers deliberately refuse to start if the drive is missing, so a missing drive is a loud failure instead of an empty library.
Everything starts by itself — Docker restores all seven containers. Nothing to type.
bin/homie-check to confirm, once you can reach it.
Nearly always the T7 was not mounted at boot. Check ls /mnt/t7 — if it
is empty, mount the drive, then
cd ~/homeserver && docker compose up -d.
Every service is pinned to an exact version on purpose. Nothing updates itself and nothing changes underneath you, which means an update is always something you chose, on a day you chose it, and can undo.
latest is a moving pointer, not a version. With it, the next restart
could quietly install a different program, and a break would arrive at random with
no record of what changed. Pinned versions turn upgrades into a decision with a
date attached.
Check it is healthy first. Never start an update from a broken state — you will not know afterwards which problem you were looking at.
bin/homie-check
Pick a real version. Look up the image’s releases and read what
changed. A version number, never latest.
Edit one line. In docker-compose.yml, change that
service’s image: tag. Leave the comment recording the old digest —
that is your way back.
Apply it to that service only.
docker compose up -d jellyfin
Verify twice. Run the health check, then actually use the thing — play a video, open the page. A passing check is not the same as a working app.
bin/homie-check
Commit it, saying what moved from what to what. This is what makes rolling back possible.
git add -A && git commit
Because the version lives in a committed file, rolling back is editing that line back
and re-running docker compose up -d. Or, if the commit contained nothing
else, git revert it and apply. Config and data are separate from the
container, so downgrading does not lose anything — with one caveat.
Some apps upgrade their own settings or database on first start, and once that has
happened the old version may refuse to read them. Jellyfin especially. Before a
jump like 10.11 → 10.12, run bin/homie-backup and skim the release
notes for anything about migrations. Small point releases are safe; major ones
deserve five minutes of reading.
Same as above, plus the two-step from §08 — a rebuilt gluetun always needs qBittorrent rebuilt after it:
docker compose up -d --force-recreate gluetun
docker compose up -d --force-recreate qbittorrent
bin/homie-check
The line to look for reads qBittorrent egress is the VPN. If it names the home address instead, stop and fix it before downloading anything.
Same procedure, with one thing to check first: never change
N8N_ENCRYPTION_KEY. Every stored credential is encrypted with
it, and a new key turns them into unrecoverable ciphertext rather than a re-login.
n8n also occasionally changes defaults between versions and warns at startup; the
compose file pins those explicitly, so read its startup log after an upgrade.
sudo apt update && sudo apt upgrade
Separate from the services, which carry their own dependencies inside their containers. If it installs a new kernel it will want a reboot — use the shutdown steps in §08, and remember everything comes back on its own afterwards.
There is no schedule to keep. Patch the operating system when you think of it, update a service when you want something it fixed or when a security note says to, and do one thing at a time — so that if something breaks, you know what did it.
| What | Address | Works from |
|---|---|---|
| Watch media | http://192.168.1.10:8096 | House WiFi |
| Watch media (secure) | https://homie.tailnet-name.ts.net | Tailnet |
| Syncthing admin | https://homie.tailnet-name.ts.net:8443 | Tailnet |
| Downloads | https://homie.tailnet-name.ts.net:8444 | Tailnet |
| n8n editor | https://homie.tailnet-name.ts.net:8445 | Tailnet |
| These docs | http://localhost:8446 | An SSH tunnel only |
| Terminal | ssh admin@192.168.1.10 | House WiFi |
| File transfer | sftp://admin@192.168.1.10 | House WiFi |
The full reachability table, including what the public internet can reach (nothing).
| Contents | Path on the server |
|---|---|
| Films | /mnt/t7/Movies |
| TV | /mnt/t7/TV |
| Downloads in progress | /mnt/t7/downloads |
| Backups | /mnt/t7/backups |
| Which show goes in which folder | ~/homeserver/hooks/series-map.conf |
| Filing log — read when a download does not appear | ~/homeserver/config/qbittorrent/qbt-file.log |
| Synced school & work files | ~/data/school-work |
| Recovered old file versions | ~/data/school-work/.stversions |
| Service configuration | ~/homeserver |
Run these from ~/homeserver. Anything taking arguments shows them; the rest take none.
| Command | Does |
|---|---|
bin/homie-check | Full health check — read-only, safe any time |
bin/homie-automation-check | Just the automation stack and workflow freshness |
bin/homie-docs | Read any of these pages as text. --search TERM greps them all |
bin/homie-backup | Back up synced files and server settings to the T7. Also runs nightly |
bin/canvas-preview | Show what a Canvas feed would sync. Changes nothing |
bin/addmedia movie FILE "Title" YEAR |
File one film by handbin/addmedia movie ~/inbox/f.mkv "Some Film" 1954 |
bin/addmedia show FILE "Title" YEAR S E "Ep" |
File one episodebin/addmedia show ~/inbox/e.mkv "Some Show" 1961 2 1 "Ep Name" |
docker ps | Which services are running |
docker compose down | Stop everything. Data is untouched |
docker compose up -d | Start everything back up |
docker compose restart NAME | Restart one — but see §08 for the VPN exception |
docker logs NAME --tail 50 | Recent output from one service |
systemctl list-timers 'homie-*' | When the scheduled jobs last ran and next will |
sudo shutdown -h now | Power the machine off properly |
There is no password manager here — there is a placement rule. Anything secret lives in a file git is told to ignore, and the repo carries a template naming what is needed without containing it.
| Path | Holds | In git? |
|---|---|---|
.env |
The real values: user IDs, timezone, the tailnet address, the qBittorrent login, the NordVPN key, n8n’s encryption key, and the automation credentials | Ignored |
.env.example |
The same variable names with empty values, so the repo says what is needed without leaking it | Committed |
config/ |
Live state: Jellyfin’s user database, Syncthing’s private key, Caddy’s certificates, qBittorrent’s password hash, n8n’s encrypted credentials | Ignored |
data/, backups/ |
Your files and the backup archives | Ignored |
Passwords are stored by each service rather than by us: Jellyfin keeps its own in its
database, qBittorrent keeps a hash, Syncthing hashes its web login. Nothing here
stores a plaintext password except .env, which holds the qBittorrent
login because the filing script has to log in as you.