I have a problem. Once I saw a proper 4K Blu-ray remux on a calibrated display — 60 Mbps, no compression artifacts, no banding in dark scenes — streaming services were ruined for me. Netflix pushes maybe 15 Mbps for their "4K." It's not even close.
So like any reasonable person, I went down the self-hosted media rabbit hole. Jellyfin, Sonarr, Radarr, the whole *arr ecosystem. The pipeline itself is well-documented. What isn't well-documented is how to run it without your setup being a ticking time bomb.
I looked at dozens of docker-compose files across GitHub, Reddit, YouTube tutorials. They all had the same problems, and nobody seemed to care.
The state of most *arr compose files is embarrassing
Here's what I kept finding:
Everything on one flat network. Your torrent client, your media server, your reverse proxy — all sitting on the same default Docker bridge. There's zero isolation. Your Jellyfin instance can talk directly to your torrent client and vice versa. Why?
The "kill switch" that isn't one. Every guide tells you to use a VPN sidecar with iptables rules as a kill switch. This is a firewall rule. Software. If it fails — and iptables rules can absolutely fail or get flushed — your real IP leaks and you don't even know it. People treat this like it's airtight. It's not.
Ports bound to 0.0.0.0. I lost count of how many compose files just blast every port open to the entire LAN with no TLS, no auth, nothing. Your Sonarr admin panel is accessible from any device on your network by default.
No health checks anywhere. The VPN container dies? qBittorrent just sits there with no connection, silently failing. Nobody gets notified. Nothing recovers. Your family opens Seerr, requests a movie, and it just... never arrives. Then you get the text: "the movie thing is broken again."
I've spent 13+ years building backend systems. This stuff made my eye twitch.
So I built it properly
The full stack is called uncompressed. It's 10 containers across 2 Compose stacks, driven by a single .env file. Here's what's actually different.
Three networks, not one
traefik_proxy— HTTPS ingress onlyarr_internal— markedinternal: true, meaning it has literally no route to the internet. Services talk to each other here and nowhere elsevpn_network— tunneled outbound traffic only
This isn't complicated. It's just what you'd do if you actually thought about it for five minutes. But almost nobody does.
VPN namespace isolation (the big one)
This is the thing I care about most. qBittorrent doesn't "route through" Gluetun. It runs inside Gluetun's network namespace via network_mode: service:gluetun. The qBittorrent container literally has no network interface of its own. It borrows Gluetun's.
On top of that, a custom init script forces BIND_TO_INTERFACE: tun0. So even within the namespace, it's bound to the tunnel interface specifically.
What this means in practice: if the ProtonVPN WireGuard tunnel drops, there is no network path. Not a blocked path. No path. The difference between a firewall rule and a missing network interface is the difference between a locked door and no door existing.
Zero ports on the internet
Traefik binds exclusively to the Tailscale IP — ${TAILSCALE_IP}:443. If you're not on my Tailscale mesh, you can't even see that these services exist. HTTPS certs get provisioned automatically via Cloudflare DNS challenges.
No port forwarding. No dynamic DNS. No "but I set up Authelia." Just: are you on my mesh? No? Then there's nothing here for you.
Making it survive without me
The real test for a home media server isn't whether it works when you set it up. It's whether it still works three weeks later when you haven't touched it and your family is using it daily.
Every container has an endpoint-specific health check running every 30-60 seconds. Not "is the process alive" — "does the API actually respond." An Autoheal container watches for failures and restarts anything that goes unhealthy.
The depends_on chain uses condition: service_healthy throughout. The download client won't start until the VPN tunnel is verified active. The media server won't start until the services it depends on are actually responding. No more cascade failures where one container dying takes everything else with it.
This is the kind of thing that sounds boring to build and is incredibly satisfying when it just works at 2 AM without waking you up.
Hardware transcoding
The goal is always direct play — push the full remux to the client untouched. But phones exist, tablets exist, and sometimes your in-laws are watching on a 2019 iPad over a mediocre WiFi connection.
Intel Quick Sync via /dev/dri handles this. I pass the hardware transcoder directly into the Jellyfin container. Capable clients like Infuse on Apple TV get the raw file. Everything else gets a hardware-transcoded stream with almost zero CPU overhead. Multiple simultaneous 4K transcodes, no sweat.
This does mean the stack is x86-only. No Raspberry Pi, no ARM. If you want to serve Blu-ray remuxes, you need real hardware. That's just the tradeoff.
The family member test
This was the actual design constraint, not an afterthought. My family doesn't know what Sonarr is. They don't care. They open Seerr, tap a movie, and it shows up on the TV. That's the entire interface.
Behind the scenes: Seerr tells Radarr, which queries Prowlarr for indexers, which sends the grab to qBittorrent inside the VPN namespace. File downloads, gets imported and hardlinked, Bazarr grabs subtitles, Jellyfin picks it up on the next library scan. The whole thing takes maybe 20 minutes depending on the file size and seeders.
If any step in that chain fails, health checks catch it and Autoheal recovers it. Nobody texts me. Nobody knows anything happened.
Things that bit me
ProtonVPN WireGuard key rotation. This one is annoying. ProtonVPN's WireGuard keys can expire or desync, and when they do, Gluetun's tunnel fails silently. The health checks catch it eventually, but there's no graceful way to auto-rotate keys without manual intervention. I haven't solved this cleanly yet. If you have, please open an issue.
Traefik's label system. Traefik is powerful, but configuring it entirely through Docker Compose labels gets unreadable fast. The Let's Encrypt DNS challenge setup alone is a wall of labels. I went through a lot of iterations to keep the compose file from turning into label soup. It's still not pretty, but it works.
Gluetun port forwarding. Getting qBittorrent reachable for incoming peer connections through Gluetun's port forwarding took more debugging than I'd like to admit. The port needs to be forwarded through the VPN provider, mapped in Gluetun, AND configured in qBittorrent. Miss any one of those three and you silently lose incoming connections, which tanks your seeding ratios.
Get it
The whole thing is MIT licensed and on GitHub.
→ github.com/Lackoftactics/uncompressed → uncompressed.media
If you find a bug or see something I could do better, open an issue. If you've solved the ProtonVPN key rotation problem, I'd genuinely love to hear about it.