j5@ganymede ~ ❯ man homelab
ganymede — operator's manual
// everything on this box, why it is the way it is, and what to do when it isn't
Read this offline
This page is a single self-contained file — no external CSS, fonts or scripts. Save it anywhere and it still works with the network down, which is exactly when you'll want it.
Run this on your own machine now, while everything works. It makes
~/homelab-offline/ and pulls down this manual plus the credentials, so a dead network
leaves you with both the instructions and the logins.
mkdir -p ~/homelab-offline && chmod 700 ~/homelab-offline && \
scp jcasey@192.168.1.10:/opt/homelab/compose/dashboard/html/manual.html ~/homelab-offline/ && \
scp -r jcasey@192.168.1.10:/opt/homelab/secrets/plain ~/homelab-offline/secrets && \
chmod -R go-rwx ~/homelab-offline/secrets && ls -R ~/homelab-offline
The IP is used rather than ganymede on purpose — if DNS is the thing that broke, the
hostname won't resolve. Then open ~/homelab-offline/manual.html in any browser. It
prints cleanly too, for the truly bad days.
chmod 700 keeps it to your user, but it is still readable by anyone with your login and
by anything you back it up to. Don't sync it to cloud storage, and re-pull it rather than keeping a
stale copy around indefinitely./opt/homelab, so git log manual.html
shows what changed and when. Re-copy it after any significant change.What is where
One Proxmox host, two containers, everything else in Docker inside them.
| Thing | Address | Notes |
|---|---|---|
| ganymede (Proxmox host) | 192.168.1.10 | ZFS pools, hypervisor. Web UI on :8006 |
| CT 101 "apps" | 192.168.1.11 | Everything below except Minecraft |
| CT 102 "games" | 192.168.1.12 | Minecraft (Enigmatica 6) |
| AT&T gateway | 192.168.1.254 | Router only — its DHCP is deliberately OFF |
Services
| Service | Local | Public | Does what |
|---|---|---|---|
| Dashboard | :8085 | j5casey.win | Links to everything |
| Jellyfin | :8096 | jellyfin.j5casey.win | Watch TV and films |
| Navidrome | :4533 | music.j5casey.win | Music, two libraries |
| Jellyseerr | :5055 | requests.j5casey.win | Request new media |
| Sonarr | :8989 | lan | TV manager |
| Radarr | :7878 | lan | Film manager |
| Lidarr | :8686 | lan | Music manager |
| Bazarr | :6767 | lan | Subtitles |
| Prowlarr | :9696 | lan | Indexer proxy (NZBgeek, IPTorrents) |
| SABnzbd | :8080 | lan | Usenet downloader |
| qBittorrent | :8081 | lan | Torrents |
| AdGuard Home | :8083 | lan | DNS + DHCP for the whole house |
| Uptime Kuma | :3001 | lan | Service monitoring |
| Beszel | :8090 | lan | CPU / RAM / disk graphs |
| Grocy | :9283 | lan | Pantry and recipes |
| Minecraft | 192.168.1.12:25565 | port forwarded | RCON 25575 must NEVER be forwarded |
Everything marked lan is reachable on your LAN or over Tailscale only. The dashboard's links detect how you reached it and switch between local addresses and public hostnames automatically.
Credentials
They live under /opt/homelab/secrets/, version-controlled and
sops-encrypted. Per-app API keys stay inside each app's own config directory under
/appdata/.
Network & DNS — the part that bites
This is the most surprising piece of the whole setup, so it's worth understanding before you change anything.
Why AdGuard runs DHCP
j5casey.win resolves publicly to your WAN address. The AT&T gateway
cannot NAT-hairpin — a device inside the house that tries to reach your own public
IP hands the packet to the gateway, and the gateway refuses to turn it around. Verified: TCP to the
WAN IP times out from every LAN host while working fine from the internet.
The fix is split-horizon DNS. On the LAN, j5casey.win resolves to
192.168.1.11 and Caddy serves it directly with a valid certificate. For that, every
device has to use AdGuard as its resolver — and the gateway has no DNS override field anywhere
in its UI. Replacing its DHCP server was the only remaining way to hand devices a resolver.
The rules
Check who is serving DHCP:
nmap --script broadcast-dhcp-discover -e vmbr0
# every "Server Identifier" must be 192.168.1.11
Upstream resolver
AdGuard uses Quad9 over DNS-over-HTTPS, with plain 9.9.9.9 and 1.1.1.1
as fallback. The fallback exists because Quad9's DoH endpoint collapsed once and
took the whole house's DNS with it — with no fallback configured, every lookup failed while
j5casey.win kept resolving (rewrites are answered locally, no upstream needed).
Browsers that bypass AdGuard
Firefox enables DNS-over-HTTPS by default in the US, sending lookups straight to Cloudflare and
skipping AdGuard entirely — so jellyfin.j5casey.win resolves to the public IP and fails
on the LAN. The tell is that the failing name never appears in AdGuard's query log while
other names from the same device do.
AdGuard answers Firefox's canary domain with NXDOMAIN, which switches DoH off network-wide:
dig use-application-dns.net @192.168.1.11 # must return NXDOMAIN
A browser restart is required — the canary is only checked at startup. If someone has set DoH to "Max Protection" it ignores the canary and must be turned off manually.
How a download actually happens
- You (or Jellyseerr) add a title to Sonarr or Radarr
- It asks Prowlarr, which queries NZBgeek (usenet) and IPTorrents (torrents)
- Usenet gets first refusal. Torrents are considered 15 minutes later
- Usenet grabs go to SABnzbd, torrents to qBittorrent
- On completion the *arr imports it into
/data/media/...as a hardlink — the file exists in both places but occupies disk once - Jellyfin picks it up; Bazarr hunts subtitles
Aborted, cannot be completed, which means missing articles that par2
couldn't repair. Torrents carried that load instead. For new releases usenet is still the
better path.completed / importBlocked with reason
Sample and the library shows nothing, while the torrent seeds happily. Sonarr now scores
those releases down so P2P copies win, but when one slips through see
Downloads stuck.Quality profiles, in plain terms
Every "quality" is two things bolted together: a source and a
resolution. WEBDL-1080p means "pulled from a streaming service, 1080p".
The source is what actually decides how good it looks:
| Source | What it is | Quality |
|---|---|---|
| Remux | Untouched Blu-ray stream, repackaged | Perfect, enormous (30–80 GB/ep) |
| BluRay | Re-encoded from the disc | Near-perfect |
| WEB-DL | Pulled from Netflix/Max/Amazon, never re-encoded | Very good |
| WEBRip | Captured from a stream and re-encoded | Slightly worse |
| HDTV | Recorded off broadcast | Logos, lower bitrate |
| DVD / SDTV | 480p | What it is |
Which profile a show gets depends on what can exist, not preference:
- WEB-1080p — current streaming shows (House of the Dragon, Strange New Worlds). There is no Blu-ray yet; WEB-DL is the best available
- HD 720p/1080p — finished shows with real Blu-ray remasters (TNG, Game of Thrones, Avatar TLA)
- Classic TV (any quality) — never remastered, exists only as 480p DVD (DS9, Voyager, MacGyver, Grandmother's shows). An HD-only profile would search forever and find nothing
Grandmother's library
She gets a curated subset, not the whole library — a separate Jellyfin library
pointed at /data/grandmother/, built from a manifest.
/opt/homelab/grandmother-library.txt # one path per line, relative to /data/media/
The tree is rebuilt as hardlinks, so her entire library costs about 2 MB of actual disk. Adding a show to her view duplicates nothing.
grandmother-list # what she can see
grandmother-add "andy griffith" # fuzzy match; lists candidates if ambiguous
grandmother-remove "the crown"
grandmother-sync # rebuild the hardlink tree (runs nightly and on add)
grandmother-add only links things already in /data/media. Add it via
Sonarr/Radarr and wait for the download before adding it to her library.Music — two separate libraries
Navidrome runs two libraries because the collections barely overlap and merging them would bury a 200-artist collection under a 600-artist one:
| Library | Path | Lidarr root |
|---|---|---|
| Jack's Music | /data/media/music | same |
| Karington's Music | /data/media/music-karington | same |
Both accounts can see both libraries and switch between them. Favourites, play counts and playlists are per-account automatically. Navidrome exposes each library to phone clients as a Subsonic "music folder", so Symfonium can browse them separately too.
"Harry Styles - As It Was (Official Video)" by "FosterThePeople". That
breaks playlist matching and makes the library ugly. Fix with:
retag-youtube.py --root /tank/data/media/music-karington/YouTube \
--uncovered /opt/homelab/spotify-uncovered-karington.json
On one library this took playlist matching from 47% to 96%.Subtitles
Bazarr handles these, with English set as the default profile for every series and film — anything added from now on gets subtitles hunted automatically.
Many files already carry subtitles inside them (House of the Dragon has 36 tracks). Those need nothing; just pick them in Jellyfin's player. Bazarr only fetches where none exist.
Add a show or film
Easiest path is Jellyseerr (requests.j5casey.win) — it hands off to
Sonarr/Radarr automatically. Otherwise add it directly in Sonarr or Radarr and pick the profile using
the table above.
For a large backfill, search season by season rather than episode by episode — one query per season instead of 26, which keeps you well clear of indexer rate limits.
tv-sonarr
or radarr category — the import and rename still happen normally.Add something to Grandmother's library
grandmother-add "love worth finding"
If the title isn't in TVDB or TMDB at all — as with the Adrian Rogers sermons — Jellyfin has
nothing to match against and shows it bare. Local metadata fixes that: put poster.jpg,
fanart.jpg and a tvshow.nfo in the series folder, with
<lockdata>true</lockdata> so a later refresh doesn't wipe it.
Push something to the front of the queue
SABnzbd supports per-item priority. Setting an item to Force jumps it ahead of everything, including things queued later.
# find the items, then set priority 2 (Force) on each nzo_id
curl "http://192.168.1.11:8080/api?mode=queue&output=json&apikey=$KEY"
curl "http://192.168.1.11:8080/api?mode=queue&name=priority&value=$NZO&value2=2&apikey=$KEY"
This only reorders the queue — nothing is cancelled or re-searched.
queued, something long is
ahead of it and waiting is the only option. Don't fire big searches immediately before an import you
care about.Reclaim disk
Most torrents cost nothing to keep seeding — Sonarr hardlinks them into the library, so the seed and the library file are the same bytes. Deleting them frees zero and only costs ratio.
The exception is RAR'd scene releases: the library copy had to be extracted, so it's genuinely separate data. Those are the only reclaimable ones, and a weekly job handles them:
retire-seeded-rars.py --dry-run # what's eligible and why
retire-seeded-rars.py --retire # do it (cron runs this Mondays)
It deletes a torrent only when it is RAR-backed, has seeded at least 30 days, and Sonarr confirms the season is complete in the library.
Nobody has internet
Symptom: devices connect but nothing loads, or "sign in required"
- Is DNS answering at all?
dig @192.168.1.11 j5casey.win # should return 192.168.1.11 dig @192.168.1.11 example.com # should return a public IP - If the first works and the second doesn't, the upstream is broken, not AdGuard.
It should fall back to plain DNS automatically; check
docker logs adguardfor repeated upstream errors. - If neither works, is AdGuard even running?
pct exec 101 -- docker ps | grep adguard pct exec 101 -- ss -tulnp | grep -E ':(53|67) ' - Two DHCP servers?
nmap --script broadcast-dhcp-discover -e vmbr0
connectivitycheck.gstatic.com; if the answer isn't Google's, they assume a login portal.
The AT&T gateway hijacks those domains and answers with itself — and if AdGuard resolved through
the gateway even briefly, it caches the poisoned answer and serves it house-wide.
dig @192.168.1.11 connectivitycheck.gstatic.com # must NOT be 192.168.1.254
curl -u USER:PASS -X POST http://192.168.1.11:8083/control/cache_clear
Devices with their own cached copy need a reboot or a wifi forget-and-rejoin. TVs hold this far
longer than phones.One device can't reach j5casey.win
Symptom: the rest of the house is fine, but one machine can't load the internal hostnames
Almost always a stale DHCP lease. The device is still holding an address handed out by the AT&T gateway from before AdGuard took over DHCP, so it has never been told about the internal resolver. It browses the internet perfectly well, which is what makes this confusing — only the split-horizon names fail.
- Confirm from ganymede. A device that isn't using AdGuard leaves no trace in either place:
No lease and zero queries together is the signature.# is there a lease for its MAC? curl -s -u USER:PASS http://192.168.1.11:8083/control/dhcp/status | python3 -m json.tool # has it ever asked AdGuard anything? curl -s -u USER:PASS "http://192.168.1.11:8083/control/querylog?limit=500" | grep -c '192.168.1.NNN' - On the device itself, read what it actually believes:
cat /etc/resolv.conf nmcli dev show | grep -E 'GENERAL.CONNECTION|IP4.ADDRESS|IP4.DNS'nameserver 192.168.1.254orsearch attlocal.netmeans an AT&T lease. AdGuard hands out neither, so either string is proof on its own. - Force a clean lease. Deleting the cached one is the part that matters:
Use the connection name fromsudo sh -c 'rm -f /var/lib/NetworkManager/*wlan0*.lease' sudo nmcli con down CONN && sudo nmcli con up CONNnmcli con show --active, and the right interface (wlan0for wifi,eth0/enp*for wired). - Verify —
cat /etc/resolv.confshould now say192.168.1.11, anddig j5casey.win +shortshould return the LAN address rather than the public one.
/var/lib/NetworkManager/. On reconnect it
re-requests the address it last held (INIT-REBOOT) instead of broadcasting a fresh discovery,
so it climbs straight back onto the old address from the old server. Forgetting and rejoining the
network changes nothing while that file survives — and neither does a reboot. Delete the lease or
nothing moves.sudo rm /var/lib/NetworkManager/*.lease silently does nothing. The shell
expands * before sudo runs — as you, not root — and that directory is mode 0700,
so the pattern matches nothing at all. zsh aborts with "no matches found"; bash quietly passes the
literal unexpanded pattern to rm. Either way the files are still there and the next step appears to
fail for no reason. Put the glob inside the privileged shell instead:
sudo sh -c 'rm -f …'. Worth remembering generally — sudo find will happily
list files in a directory your own shell can't glob, which makes the two look inconsistent.attlocal.net out of nothing. If the AT&T search
domain comes back after the cache is cleared, the gateway is still serving DHCP — fix
it there, not on the device. Other suspects, in the order they're worth checking:
nmcli con show CONN | grep ipv4.method returning manual (a pinned address, no
DHCP involved); an immutable resolver, where lsattr /etc/resolv.conf shows i
and sudo chattr -i releases it; a stray dhcpcd fighting NetworkManager over
the same interface, common on Arch; or Tailscale MagicDNS, where --accept-dns rewrites
resolv.conf to 100.100.100.100 and overrides the LAN entirely.Something won't play in Jellyfin
Symptom: "Unable to find a valid media source to play"
Almost always codecs. YouTube-sourced files increasingly arrive as AV1 + Opus, which most clients can't decode. Check:
pct exec 101 -- docker exec jellyfin /usr/lib/jellyfin-ffmpeg/ffprobe -v error \
-show_entries stream=codec_type,codec_name -of default=noprint_wrappers=1 "/data/path/to.mkv"
If it says av1 / opus, re-pull it preferring H.264 and AAC. Anything
destined for Grandmother especially should be H.264 — it direct-plays everywhere and never troubles
the iGPU.
Symptom: playback fails instantly with a bare "fatal error"
Hardware transcoding can't open the GPU. The give-away is ffmpeg exiting within milliseconds:
pct exec 101 -- docker exec jellyfin sh -c 'ls -t /config/log/*.log | head -1 | xargs grep -i "Device creation failed"'
Exit code 234 is -22 (EINVAL) from VA-API. Confirm the Jellyfin
process has the render group — not just the container:
pct exec 101 -- docker exec jellyfin sh -c 'grep Groups /proc/$(pgrep -f bin/jellyfin | head -1)/status'
# must include the gid that owns /dev/dri/renderD128
Symptom: wrong artwork or synopsis
Jellyfin matched the wrong title — common where a remake shares the name (Avatar: The Last Airbender 2005 vs 2024). Put the year in the folder name, then re-identify the series in Jellyfin.
Episodes look missing but probably aren't
Symptom: a completed show shows e.g. 174/176
Those two are almost always specials — Season 0 retrospectives, syndication re-cuts, behind-the-scenes clips. TVDB counts them; Sonarr shows them in the total but leaves them unmonitored so they're never chased. I Love Lucy has 651 of them against 193 real episodes.
To see real gaps, count only season > 0 and monitored+aired episodes. Genuinely missing episodes are usually usenet retention on old shows — re-search and let torrents take them.
Downloads finish but never appear
Symptom: torrent at 100% and seeding, library empty
Scene RAR release. Sonarr will say completed / importBlocked, reason
Sample:
extract-torrent-rars.py --scan # what needs unpacking
extract-torrent-rars.py --extract-all # unpack, leaving the seed untouched
Then import the extracted tree. The torrent's own files are never touched, so seeding continues.
sudo, the files land owned by root and Sonarr (uid
1000) cannot move them. It fails with UnauthorizedAccessException per
file while still reporting the command as completed — "Manually imported 4 files". The
failures are Warn-level in the log only. The script chowns as it goes now, but if you unpack by hand:
chown -R 1000:1000 /tank/data/torrents/extractedSymptom: downloads complete but sit in /data/usenet/complete forever
Import never ran. DownloadedEpisodesScan does not recurse into release
folders that contain a nested folder of the same name, so the obvious fix silently does nothing:
import-orphans.py --dry-run # what's importable
import-orphans.py --import
This once recovered 309 episodes / 462 GB that were already on disk.
Symptom: everything says "downloadClientUnavailable"
The *arr apps can't authenticate to qBittorrent. Because they all reach it through the same Docker bridge gateway, one stale password locks out everything, including your browser. Test the download clients in each app; qBittorrent's ban threshold is raised so this shouldn't recur.
Symptom: nothing grabs after an outage
Prowlarr benches indexers for hours after failures, and Sonarr, Radarr and Lidarr each keep their own separate failure state. Clearing Prowlarr alone is not enough — test the indexers in all four.
After a power cut
Both containers have onboot: 1 and come up automatically — the whole stack was live
28 seconds after boot last time. What takes minutes is the host itself: POST, ZFS
import, Proxmox init. If you check from a phone during that window it looks dead when it isn't.
- Check from a machine that's already online:
dig @192.168.1.11 j5casey.win - Devices with an existing lease keep working — leases are 7 days and survive reboots. Only devices asking for a fresh lease are affected
- If you fell back to AT&T DHCP in the meantime: turn it off again, then flush AdGuard's cache (see above)
- Long-running jobs (yt-dlp fetches, imports) do not survive — restart them. Most are resumable and pick up where they stopped
Scripts
All in /opt/homelab/scripts/. Every one takes --dry-run or
--help where it could do damage.
| Script | What it does |
|---|---|
grandmother-add / -remove / -list / -sync | Manage her curated library |
import-orphans.py | Import completed downloads Sonarr never picked up |
extract-torrent-rars.py | Unpack scene RAR torrents so they can import |
retire-seeded-rars.py | Delete RAR seeds past 30 days once replaced |
staged-library-fill.py | Release shows for download one at a time |
lidarr-fix-monitoring.py | Undo mass album monitoring under a root folder |
import-spotify.py | Import a Spotify CSV export into Lidarr |
yt-music-fetch.py | Fetch tracks usenet couldn't find, from YouTube |
retag-youtube.py | Fix tags on YouTube rips from the Spotify metadata |
build-playlists.py | Write .m3u playlists into a music tree |
build-playlists-navidrome.py | Build playlists via API so they span both libraries |
karington-missing-report.py | What couldn't be obtained, grouped by cause |
fetch-lwf.sh | Fetch Adrian Rogers sermons; resumable |
adguard-watchdog.sh | Restart AdGuard if DNS stops answering |
adguard-rollback.sh | Hand DHCP back to the AT&T gateway |
cloudflare-ddns.sh | Keep the j5casey.win A records on your WAN IP |
check-port-forwards.sh | Verify which ports are open from outside |
Scheduled jobs
| When | What |
|---|---|
| every 2 min | AdGuard DNS watchdog — restarts it after two failed probes |
| Mondays 04:17 | Retire RAR seeds past 30 days that have a confirmed replacement |
| every 15 min | sanoid ZFS snapshots |
| Sundays | ZFS scrub / trim |
| nightly | grandmother-sync rebuilds her hardlink tree |
Logs: /var/log/adguard-watchdog.log,
/var/log/retire-seeded-rars.log, /var/log/fetch-lwf.log,
/var/log/cloudflare-ddns.log.
/etc/cron.d do not inherit the PATH from
/etc/crontab — they run with a bare /usr/bin:/bin. pct lives in
/usr/sbin, so any script calling it from cron must use the full path. The AdGuard
watchdog was silently broken for two days because of exactly this.Disk layout
/tank/data/ -> /data inside CT 101
media/tv/ TV, one folder per show
media/movies/ films
media/music/ Jack's music
media/music-karington/ Karington's music
grandmother/tv|movies/ hardlinks to her curated subset
usenet/complete/ SABnzbd finished downloads
torrents/complete/ qBittorrent data (keep — this is what seeds)
torrents/extracted/ unpacked scene RARs, staging only
imports/ Spotify exports and other one-off inputs
/tank/appdata/ -> /appdata (every app's config)
/rpool/scratch/ -> /scratch (SAB unpack scratch, on NVMe)
/opt/homelab/ git repo: compose files, scripts, docs, secrets
Gotchas worth remembering
- Hardlinks mean deleting a torrent usually frees nothing. Check the link count before assuming you'll reclaim space.
- Specials inflate every episode count. A show at 174/176 is almost certainly complete.
- Sonarr reports success it didn't have. A ManualImport can say "completed — imported 4 files" while every file failed. Check the log, not the command result.
- qBittorrent 5.x names its cookie
QBT_SID_<port>, notSID. Scripts matchingSIDexactly read a successful login as a failure. - YouTube 403s are usually throttling, not blocking. Wider pacing and retries fix
them. A genuine block shows as "This video is not available" on every client except
tv, which admits "DRM protected". - Never forward RCON (25575). Minecraft's game port is fine; RCON is remote command execution.
- IPTorrents only. Public trackers (The Pirate Bay, YTS) are disabled in Prowlarr on purpose, and DHT/PeX/LSD are off in qBittorrent because private trackers require it.
built on ganymede · /opt/homelab/compose/dashboard/html/manual.html · version-controlled in /opt/homelab