Bassam Ismail
Personal

Building a self-hosted media stack (and what I deleted)

12 min read

The media stack is the reason most people build a home server, and it is the part I have the least to say about, because the individual pieces are well-trodden. Jellyfin plays the library. A suite of apps named like a font foundry (Radarr, Sonarr, Prowlarr, Bazarr) handle finding, fetching, and subtitling. A request app lets the household ask for a film without touching any of it. The interesting work was never installing them. It was the glue between them, and the discipline of taking pieces back out.

This is the fourth part of a series on running a home server on a spare MacBook. This part is the media stack: how the pieces connect, the three integration problems that ate an evening each, and the things I added and then deliberately deleted.

TL;DR

Building a self-hosted media stack with Jellyfin, Sonarr, Radarr, Prowlarr, Bazarr, and qBittorrent on a home server involves more integration work than installation. The three hardest problems are qBittorrent rotating its web UI password on every restart (fixed by subnet-whitelisting the Docker network), ISP-blocked IPv4 paths to video servers (fixed by enabling IPv6 on the compose network), and bot-protected indexers (fixed by wiring FlareSolverr into Prowlarr). An automated cleanup job that trusted an empty API response deleted a dozen films, which is why destructive automations need input sanity checks, deletion size caps, and a recycle bin. The real discipline in running a media stack is not assembling it but knowing what to delete.

The pipeline, in one breath

The stack is a small assembly line. A request comes in through the request app. An indexer manager fans the search out across sources. A download client fetches the file. The library managers rename and file it and pull subtitles. The media server presents it. Each app only talks to its neighbors, which is what makes the whole thing comprehensible.

THE MEDIA ASSEMBLY LINErequesthousehold asksfindindexer managerdownloadtorrent clientfile + subslibrary managerswatchmedia server[ each app talks only to its neighbors ]

Everything runs as containers on one shared Docker network, so the apps reach each other by name, and the Caddy reverse proxy from part two gives each a clean hostname. That part is genuinely turnkey. The trouble was never the boxes. It was the wires between them.

The compose file, more or less

Most of the stack comes from linuxserver.io images, which all take the same three knobs: a PUID/PGID pair so files land owned by my user instead of root, a TZ so scheduled tasks fire at sane local times, and a media bind mount so every app sees the same library at the same path. On this box that is UID 501, GID 20 (the macOS staff group), and ${HOME}/Media mapped to /media everywhere. Sharing one path is what lets the download client, the library managers, and the media server all refer to the same file without copying it around.

Trimmed to the essentials, the services look like this:

services:
  jellyfin:
    image: lscr.io/linuxserver/jellyfin:latest
    environment: [PUID=501, PGID=20, TZ=Etc/UTC]
    volumes: ["${HOME}/Media:/media"]
    ports: ["8096:8096"]
 
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    environment: [PUID=501, PGID=20, TZ=Etc/UTC]
    volumes: ["${HOME}/Media:/media", "./sonarr/config:/config"]
    ports: ["8989:8989"]
 
  radarr:
    image: lscr.io/linuxserver/radarr:latest
    environment: [PUID=501, PGID=20, TZ=Etc/UTC]
    volumes: ["${HOME}/Media:/media", "./radarr/config:/config"]
    ports: ["7878:7878"]
 
  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    environment: [PUID=501, PGID=20, TZ=Etc/UTC]
    volumes: ["./prowlarr/config:/config"]
    ports: ["9696:9696"]
 
  bazarr:
    image: lscr.io/linuxserver/bazarr:latest
    environment: [PUID=501, PGID=20, TZ=Etc/UTC]
    volumes: ["${HOME}/Media:/media", "./bazarr/config:/config"]
    ports: ["6767:6767"]
 
  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    environment: [PUID=501, PGID=20, TZ=Etc/UTC, WEBUI_PORT=8080]
    volumes: ["${HOME}/Media:/media", "./qbittorrent/config:/config"]
    ports: ["8080:8080", "6881:6881", "6881:6881/udp"]
 
  seerr:
    image: fallenbagel/jellyseerr:latest
    init: true
    environment: [TZ=Etc/UTC]
    volumes: ["./seerr/config:/app/config"]
    ports: ["5055:5055"]
 
  flaresolverr:
    image: ghcr.io/flaresolverr/flaresolverr:latest
    environment: [TZ=Etc/UTC]
    ports: ["8191:8191"]
 
  pinchflat:
    image: ghcr.io/kieraneglin/pinchflat:latest
    platform: linux/amd64
    environment: [TZ=Etc/UTC]
    volumes: ["${HOME}/Media/YouTube:/downloads"]
    ports: ["8945:8945"]

Two of those entries carry a story before they ever run. seerr (the request app the household actually sees) gets init: true so a real init process reaps the headless browser children it spawns instead of leaving zombies. pinchflat (a YouTube archiver) gets platform: linux/amd64 pinned, because not everything in this world ships a clean arm64 image and forcing emulation was less painful than not running it at all. The port column is dull on purpose. The download client publishes its BitTorrent port on both TCP and UDP (6881) and serves its web UI on 8080; everything else exposes one HTTP port and is otherwise reached over the internal network by name.

Three integration problems, each a different kind of annoying

The download client kept locking out the apps. qBittorrent's web UI generates a temporary admin password on startup and rotates it on every restart, which is sensible for a thing with a public face and infuriating for a thing being driven by four other containers on a private network. The arr apps would authenticate once, then start collecting 401s the next time the container bounced. You can fish the current password out of the logs:

docker logs qbittorrent | grep "temporary password"

That works for a human logging in once. It does not work for automation that expects a stable credential. The real fix is to tell qBittorrent to trust the private Docker subnet and skip auth for callers on it, set in qbittorrent/config/qBittorrent/qBittorrent.conf under [Preferences]:

[Preferences]
WebUI\AuthSubnetWhitelistEnabled=true
WebUI\AuthSubnetWhitelist=192.168.97.0/24,fd00:dead:beef::/64,127.0.0.1/32,::1/128

Now the library managers on that subnet talk to it directly and a human visiting from anywhere else still gets a login prompt. The lesson generalizes: on a private network, service-to-service auth is sometimes friction with no security benefit, and the right move is to scope the bypass to the subnet rather than weaken auth globally. The whitelist is an allowlist of trusted callers, not the open door it looks like at first glance.

My ISP quietly blocked part of the internet. The YouTube archiver kept failing to download anything, with ECONNREFUSED on connections that should have just worked. The cause was not the tool. My connection sits behind carrier-grade NAT, and the ISP's IPv4 path to some of Google's video servers was being dropped on the floor. The fix was to give the containers a route that did not depend on that broken v4 path, which meant enabling IPv6 on the Docker network so the affected traffic could route over v6 instead.

Normally you would flip a daemon flag for this, but the runtime here is OrbStack, and there is no daemon.json to edit. The equivalent lives at the compose-network level, where I also pin the v4 subnet so the whitelist above stays meaningful:

networks:
  default:
    enable_ipv6: true
    driver: bridge
    ipam:
      config:
        - subnet: 192.168.97.0/24
        - subnet: fd00:dead:beef::/64

The subnets here are the same ones the qBittorrent whitelist trusts, which is not a coincidence: pinning them is what lets a static allowlist survive container restarts. I would not have guessed that "give the containers IPv6 addresses" was the fix for "YouTube downloads fail," which is the recurring texture of homelab debugging: the error is in one layer and the cause is two layers down.

Some indexers sit behind bot protection. Several search sources gate their pages behind the same kind of browser challenge a normal site uses against scrapers. Prowlarr cannot answer those on its own. FlareSolverr exists for exactly this: it runs a headless browser, solves the challenge, and hands back a usable session. It is already in the compose file at flaresolverr:8191; the wiring is a single entry in Prowlarr under Settings, Indexers, Add Proxy, of type FlareSolverr, pointed at:

http://flaresolverr:8191

Tag the protected indexers with that proxy and they start resolving. It is a slightly absurd amount of machinery (a whole headless browser) to look up where a file lives, and it is also the difference between the stack working and not. Note that it reaches FlareSolverr by container name over the internal network, which is the same pattern every app uses to find every other app here. No host IPs, no ports memorized, just names on a shared network.

The parts I deleted, and why that is the real skill

I added more than I kept, and the deletions taught me more than the installs.

Readarr, a book manager in the same arr family, had a broken arm64 build across every tag I tried, so it never ran. Unlike pinchflat, it was not worth forcing through amd64 emulation for something I would barely touch, so it came out. An audiobook server worked fine and I removed it anyway, because I was not using it and an idle service is still a thing to update, monitor, and back up. A couple of other experiments went in and came straight back out once it was clear they were curiosity, not use. None of that is failure. A home server's natural tendency is to accrete, and the only force pushing the other way is you, periodically asking each service to justify its place.

ServiceRoleVerdict
Jellyfinmedia serverkept
Sonarr / RadarrTV / film managerskept
Prowlarrindexer managerkept
Bazarrsubtitleskept
qBittorrentdownload clientkept
Seerrrequest appkept
FlareSolverrchallenge solverkept
PinchflatYouTube archiverkept (amd64 pinned)
Readarrbook managercut (broken arm64 build)
Audiobook serveraudiobookscut (unused, still overhead)

The automation that deleted my films

The sharpest lesson came from an automation that was too eager. I had wired the request app's cleanup to cascade: remove a request, remove the media behind it via Sonarr and Radarr. The mechanism was a five-minute job that diffed the current requests against a state file at ~/selfhost/logs/jellyseerr-request-state.tsv and acted on the difference.

The bug is visible the moment you say it out loud. One day the API returned an empty response. The diff read "everything that was here is now gone," interpreted that as "remove everything," and deleted about a dozen films before I noticed. Nothing was unrecoverable and nothing was catastrophic, but it was a clean demonstration of a rule I should have followed from the start: an automated deletion needs guardrails proportional to its blast radius. A job that can delete the whole library should be the most paranoid code you own, not the least.

I added the guardrails after the fact, and they are deliberately cowardly:

  • Sanity-check the input before trusting it. An empty or unreadable API response is treated as "I don't know," not "nothing exists."
  • Refuse to act on a suspiciously large or empty set. If a single run wants to remove more than a small handful of items, it stops and logs instead of proceeding.
  • Send deletions to a recycle bin rather than straight to oblivion. The arr apps and qBittorrent point their removals at /media/.Recycle Bin with 30-day retention, so a mistake is a thing you undo, not a thing you mourn.

The third one is the cheapest and the one I should have had on day one. A recycle bin turns the entire category of "the automation deleted the wrong thing" from an incident into an annoyance.

The hard part of a media stack is not assembling it. It is resisting the urge to keep adding to it, and making the destructive automations cowardly on purpose.

What it actually feels like to run

Day to day, the media stack is the quietest part of the server, which is the highest compliment a home service can earn. Someone requests a film, it appears, the subtitles are right, and nobody thinks about indexers or subnets or IPv6. The work was front-loaded into the wiring and the pruning, and the steady-state cost is mostly just keeping a dozen images current.

That steady state has a real price worth naming: a stack this size is a standing update surface. Every one of those images is a thing that ships a new tag, occasionally breaks a config across a major version, and has to be pulled and restarted. That is exactly why an unused audiobook server was worth deleting, and why pinning pinchflat to amd64 emulation is a tradeoff I have to remember exists. Quiet is not free; it is bought with pruning.

If there is a transferable idea here, it is that a "stack" is a verb. The value is not in the dozen apps; it is in the handful of connections you got right and the handful of services you were willing to delete. The next part is about the surface that finally made all of this usable by people other than me: one dashboard that pulls the media stack, the home automation, and a home-grown money pipeline into a single pane of glass.

FAQ

Why does qBittorrent keep logging out Sonarr and Radarr after a restart?

qBittorrent generates a new temporary admin password on every container restart, so any saved credentials the arr apps hold become invalid. The fix is to enable the subnet whitelist in qBittorrent.conf so containers on the same private Docker network skip authentication entirely, while external access still requires a login.

How do I enable IPv6 on a Docker Compose network in OrbStack?

OrbStack has no daemon.json, so you set enable_ipv6: true directly in the networks block of your compose file, alongside a pinned ipam subnet. This is also what makes a static subnet whitelist in qBittorrent survive container restarts.

How do I connect FlareSolverr to Prowlarr?

Add FlareSolverr to your compose file on the same Docker network, then go to Prowlarr Settings, Indexers, Add Proxy, choose type FlareSolverr, and point it at http://flaresolverr:8191. Tag whichever indexers sit behind bot protection with that proxy.

How do I stop an automated script from deleting my entire media library?

Treat an empty or unreadable API response as unknown rather than as an empty state, refuse to act if a single run wants to remove more than a small number of items, and configure Sonarr, Radarr, and qBittorrent to send removals to a recycle bin path with a retention period instead of deleting immediately.

What PUID and PGID should I use for linuxserver.io containers on macOS?

On macOS, use PUID=501 for your user and PGID=20 for the built-in staff group. This ensures files written by the containers are owned by your normal macOS user rather than root.