I Built My Own Netflix in an Evening (and Fought My ISP the Whole Way)

Setting up a self-hosted media stack with Docker: Radarr, Sonarr, Jellyfin and friends. The parts nobody warns you about, mostly SELinux and Jio.

July 13, 2026·11 min read··view raw

I wanted to watch a movie. That is the entire origin story.

Not "I wanted to learn Docker networking" or "I wanted to build a homelab." I just wanted to type a movie name somewhere and have it show up on a nice interface with a poster and subtitles, like a streaming service, except one I own and one that has everything.

The self-hosted crowd calls this the "arr stack." Radarr for movies, Sonarr for TV, Prowlarr to find things, qBittorrent to download, Bazarr for subtitles, Jellyfin to actually watch. Seven or eight containers that talk to each other and, when wired up right, turn "add a movie" into "it appears in your library, renamed, with English subtitles, no further clicks."

That last part is the dream. Getting there took an evening, and most of that evening was not spent on the media stack at all. It was spent fighting three things nobody puts in the quickstart: SELinux, an NTFS drive, and Reliance Jio actively blocking half the internet I needed.

This is the whole thing, including the parts that broke.


What the stack actually isCopied!

Before the pain, the shape of it.

ServiceJobPort
JellyfinThe thing you watch. Your Netflix.8096
RadarrMovies. Finds, downloads, renames, files them.7878
SonarrSame, for TV. Grabs new episodes as they air.8989
LidarrSame, for music.8686
ProwlarrManages indexers and feeds them to the others.9696
qBittorrentThe actual download client.8080
BazarrDownloads subtitles automatically.6767

The flow, once it all works, reads like this. You add a movie in Radarr. Radarr asks Prowlarr to search your indexers. You pick a release. qBittorrent downloads it. Radarr moves and renames it into your library. Bazarr fetches subtitles. Jellyfin shows it with a poster. You press play.

Everything runs in Docker Compose. Config on the fast disk, media on a big disk. Nothing starts on boot, because I did not want eight containers eating RAM when I am not watching anything. I start it with a small script when I want it and stop it when I am done.


Decision one: where does the media liveCopied!

First real choice. df -h and pick a home for the library.

My root partition had 17GB free. My home partition had 20GB. Neither is enough for a real movie collection. I have a 217GB NTFS data drive with 63GB free, and a Windows partition with 99GB free.

I went with home first, for one reason: hardlinks. On a normal Linux filesystem, when Radarr imports a download into your library, it can hardlink instead of copy. The file moves instantly and uses zero extra space. On NTFS, hardlinks do not work, so every import is a full copy. You briefly use double the space and it is slower.

Then I actually looked at my home partition. 20GB free, and no easy way to free more without deleting things I use. So I moved the library to the NTFS data drive, accepted the copy penalty, and told Radarr and Sonarr to stop trying to hardlink. If your media lives on ext4 or btrfs, keep hardlinks on and skip this whole paragraph.

There is a catch with NTFS on Linux that cost me a few minutes. The drive was auto-mounted by the desktop as owned by root, so the containers (running as my user, UID 1000) could not write to it. The fix was in /etc/fstab, which already had the right entry with uid=1000,gid=1000, it just was not the mount in effect. A quick unmount and remount picked up the fstab options and suddenly a container running as UID 1000 could write. I verified with a throwaway container before trusting it:

docker run --rm -u 1000:1000 -v /media/DDrive/media/downloads:/test alpine \
  sh -c 'touch /test/.write_test && echo ok && rm /test/.write_test'

If that prints ok, your containers can write. Do this before building the whole stack on top of a drive that turns out to be read-only.


Decision two: the VPN that wasn'tCopied!

The plan was to route torrent traffic through a VPN, with qBittorrent living inside a container called gluetun that holds the VPN tunnel. If the VPN drops, qBittorrent loses all network access. That is the kill switch, and it is the correct way to do this.

I picked Windscribe's free tier. Went to generate the OpenVPN config, and got a wall: "This is a Pro feature. Upgrade to Windscribe Pro."

They had paywalled config generation. Fine. Switched to ProtonVPN's free tier, which supports WireGuard through gluetun natively. You generate one WireGuard private key in their dashboard, paste it into a .env file, done. I picked a Singapore server because it is the closest free ProtonVPN region to India.

The tunnel came up. I checked it properly, because a container being "up" tells you nothing about whether the VPN actually works. WireGuard is a silent protocol. It will happily sit there passing no traffic and report no error. The only real test is to ask, from inside the container, what your public IP is:

docker exec qbittorrent sh -c 'curl -s https://ipinfo.io/json'

My real IP is a Jio address in Surat. The container reported an IP in Singapore. That is the proof. Not the logs, not the health check, the actual exit IP being somewhere I am not.

So the VPN worked. And then I tried to download something and it crawled. 14 KB/s. On a free VPN tier with no port forwarding, most trackers would not connect and the ones that did were painful.

I turned the VPN off, pulled qBittorrent out of gluetun, gave it a direct connection, and the same download went from 14 KB/s to 3.3 MB/s. About two hundred times faster.

That is the honest tradeoff nobody tells you. A free VPN tier makes torrenting slow enough to be useless. You either pay for a good VPN, or you torrent on your real IP and accept that your ISP can see it. I left the whole gluetun config in the compose file, commented out, with notes on how to turn it back on. Choose your own risk tolerance here. I am not your lawyer.


The one that actually ate the evening: SELinuxCopied!

I brought the stack up. All seven containers said "up." Then I went looking for the API keys each service writes to its config file on first boot, and the config directories were empty.

Empty. The containers were running but writing nothing.

The logs told the story:

find: '/config': Permission denied
**** Permissions could not be set. ****
System.UnauthorizedAccessException: Access to the path '/config/Sentry' is denied.

Permission denied, even though the config directories on the host were owned by my user, and the containers run as my user. Ownership was correct. Something else was blocking the write.

That something is SELinux. I am on Fedora, which ships SELinux in enforcing mode. SELinux does not care about Unix ownership. It labels files, and a container running in the container_t domain is simply not allowed to touch files labeled user_home_t, which is what everything in your home directory is labeled. Correct ownership, blocked anyway.

getenforce          # Enforcing
ls -Z ~/arr-stack/prowlarr    # ...:user_home_t:...  the problem

The fix is a two-character change per volume in the compose file. Add :Z to a bind mount and Docker relabels that directory to a private label the container is allowed to use. Use lowercase :z for directories shared between multiple containers, like the media folders that Radarr and Jellyfin both mount.

volumes:
  - ./prowlarr:/config:Z
  - ${MEDIA_PATH}/movies:/movies:z

Recreate the containers, and suddenly every service writes its config, generates its API key, and comes alive. This is the single thing most likely to trip you up on Fedora, RHEL, or any SELinux distro, and most guides written on Ubuntu never mention it because Ubuntu does not enforce SELinux. If your containers are mysteriously read-only, this is why.


Wiring it togetherCopied!

With the containers actually able to write, the rest is connecting services to each other. Each arr app generates an API key in its config.xml. You hand those keys around so the services can talk.

The pattern that matters: services reach each other by container name on the Docker network, not by localhost. Prowlarr talks to Radarr at http://radarr:7878, not http://localhost:7879. Your browser uses localhost and the published port. The containers use the container name and the internal port. Mixing these up is a classic first-timer mistake.

One local quirk: port 7878, Radarr's default, was already taken on my machine by another app. So I published Radarr on 7879 on the host, while it still listens on 7878 inside the container. Browser hits 7879, Prowlarr hits 7878. Both correct, because they are talking to it through different doors.

I connected qBittorrent as the download client in Radarr and Sonarr, registered Radarr and Sonarr as apps in Prowlarr so indexers sync automatically, set root folders, and pointed Bazarr at both. Every one of these has a Test button. Use it. A green test is the difference between "configured" and "actually works," and they are not the same thing.

For indexers I added The Pirate Bay and YTS. I tried to add 1337x and a few others and they timed out. Hold that thought.


Jio, part one: no postersCopied!

First movie downloaded, imported, showed up in Jellyfin. As a blank grey tile. No poster, no description, nothing.

Jellyfin fetches metadata from TheMovieDB. So I checked whether the container could reach it:

docker exec jellyfin sh -c 'curl -s -o /dev/null -w "%{http_code}\n" https://api.themoviedb.org/3/'
# 000

000 means it could not connect at all. But general internet worked, Google returned 200. Only TheMovieDB was dead. So I looked at what the container resolved the domain to:

docker exec jellyfin sh -c 'getent hosts api.themoviedb.org'
# 2405:200:... which is a Jio address

There it is. Jio was poisoning DNS. When the container asked for TheMovieDB's address, Jio handed back one of its own dead IPs instead of the real one. Classic ISP-level domain blocking.

DNS poisoning has a clean fix: stop using the ISP's resolver. I added public DNS to the containers in the compose file, as a small reusable anchor applied to everything that talks to the outside:

x-dns: &dns
  dns:
    - 1.1.1.1
    - 8.8.8.8

Recreated the containers. Now the DNS resolved to the real TheMovieDB address. And it still did not connect.


Jio, part two: the deeper blockCopied!

Correct DNS, still 000. That rules out DNS poisoning and points at something worse.

docker exec jellyfin sh -c 'curl -4 -s -o /dev/null -w "%{http_code}\n" https://api.themoviedb.org/3/'
# still 000, even forcing IPv4 to a correct address

This is SNI-based blocking. When your browser or a container opens an HTTPS connection, the very first packet includes the hostname it wants, in plaintext, in the TLS handshake. Jio reads that hostname and, if it is on their list, kills the connection. DNS cannot save you here, because the address is already correct. The block happens at the moment the hostname is revealed.

You cannot out-config this. You have to tunnel around it. I turned on the 1.1.1.1 WARP client on my laptop, which routes traffic through Cloudflare and hides the hostname from Jio. Tested again:

docker exec jellyfin sh -c 'curl -s -o /dev/null -w "%{http_code}\n" https://api.themoviedb.org/3/'
# 401

401 is a win. 401 means the connection went through and TheMovieDB is just asking for an API key, which is exactly what should happen. The block was gone. I triggered a metadata refresh and the movie filled in with a poster, a description, the works.

One subtlety I hit later: WARP on the host does not automatically cover Docker containers. The containers can bypass the host tunnel and go straight out. So the WARP trick fixed metadata that the host fetches, but container searches to SNI-blocked indexers, the 1337x that timed out earlier, still failed. The real fix for those is routing that specific container through a VPN, the same gluetun trick, but pointed only at Prowlarr so searches tunnel out while downloads stay direct and fast. I noted it and moved on, because the stuff I actually wanted to watch was reachable.


Subtitles, and the throttle that lied to meCopied!

Bazarr handles subtitles. Point it at your arr apps, give it a language profile, enable a provider, and it downloads subtitles for everything automatically.

Creating the English language profile turned out to not be exposed in Bazarr's public API, so I wrote it straight into its SQLite database and restarted. Then enabled OpenSubtitles as the provider, which needs a free account.

I added my credentials and it still failed with "Username and password must be specified." I double-checked the config. Credentials were there, correct. It kept failing.

The trap: when OpenSubtitles first failed, before I had added credentials, Bazarr threw the provider into a twelve-hour throttle. So even after I fixed the credentials, it refused to retry, and the error in the log was the old one, timestamped from before the fix. I was staring at a stale error and assuming my new config was wrong.

Resetting the provider throttle made it retry immediately, it authenticated, and subtitles started downloading. Lesson: when a provider looks broken after you fixed its config, check whether it is throttled from earlier failures before you touch the config again.

There was a fun coda. The first subtitle downloaded but appeared to run ahead of the audio. I ran ffsubsync against the movie's audio to detect the offset, and it reported an offset of zero. The subtitle was already correct. The film was a musical, and the "early" subtitles were song lyrics, which are timed to lead the vocals on purpose, like karaoke. Not a bug. Just me not recognising that lyric subtitles are supposed to do that.


The lesson that matters most: seedersCopied!

Here is the single most useful thing I learned, and it has nothing to do with any of the config above.

I tried to download an older, less popular film. Every release I found showed a decent number of seeders in the indexer, but every one of them sat at zero percent and never moved. The seeder counts the indexer reported were stale. The actual swarms were dead.

Then I added a hugely popular film. It had 590 seeders and finished in eight minutes.

Same stack. Same settings. Same everything. The only variable was whether real people were actually sharing the file right now.

So the habit that makes this whole thing work: do not trust the automatic grab. Use Interactive Search, which shows you the real list of releases with live seeder counts, and pick one with a lot of seeders. A release with zero seeders will never download no matter how good its quality label looks. This is the number one reason a download "does not work," and it is almost never the setup. It is a dead file.

The corollary, which I ran into with Hindi films specifically: popular and recent content is well-seeded and downloads instantly, while older or niche content, including a lot of Bollywood, is barely seeded on public trackers, and the sites that do have it are the ones Jio blocks hardest. That is a coverage problem, not a stack problem, and the honest answer is a VPN for searches or a private tracker. No amount of config makes a dead swarm alive.


What it feels like nowCopied!

Once everything is wired, the actual daily use is almost boring, which is the goal.

Start the stack. Turn on WARP. Open Radarr, add a movie, run Interactive Search, pick the release with the most seeders. Walk away. A few minutes later it is downloaded, renamed, sitting in the library with a poster and English subtitles, playable in Jellyfin in the browser or on my phone or on the TV.

I added Lidarr for music with a lossless-only profile, so it holds out for FLAC instead of settling for an MP3. I put a Homepage dashboard in front of everything so I have one page with live download status and disk usage. Sonarr quietly grabs new episodes of shows I am following without me asking.

The setup was an evening. The friction was almost entirely environmental: SELinux because I am on Fedora, the copy penalty because my drive is NTFS, and a running battle with an ISP that poisons DNS and resets connections based on hostname. If you are on Ubuntu with an ext4 drive and a normal ISP, most of this post does not apply to you and you will be done in twenty minutes. If you are on Fedora in India, now you know exactly which walls you are about to hit and how to get through each one.

Was it worth an evening to avoid paying for a streaming subscription? Not really, if I am honest about the math. But I did not do it for the math. I did it because "I wanted to watch a movie" turned into "I want to understand exactly how this works," and now I do, and the movie played.


The actual filesCopied!

This post is the story. If you want the thing itself, I put it all in a repo:

github.com/xevrion/arr-stack

It has the full docker-compose.yml, a .env.example to copy and fill in, the little start-stop script, the Homepage dashboard config, and four docs: installation step by step, daily usage, the architecture reasoning behind each odd choice, and a troubleshooting page with every problem in this post and its fix. No secrets in it, the .gitignore keeps your keys and API tokens out.

Clone it, copy .env.example to .env, set your paths, and start from the installation guide. If you are on Fedora with an NTFS drive behind an Indian ISP, you already know which walls you are about to hit, because they are all in there.

Comments