danspicytaco

Media server

Self-hosted Ansible

A self-hosted Jellyfin media server deployed with Ansible, Docker Compose, and Traefik.

I built this because I wanted a cheap, self-hosted way to watch movies and TV shows, and because I wanted more practice with Ansible and Docker on a real server rather than a toy example.

The interesting part was getting the pieces around Jellyfin to behave like one system: bootstrapping the VPS, keeping service config repeatable, wiring the apps together, and making updates deliberate instead of hopeful.

I did not build this to download illegal or copyrighted content. I built it as a self-hosting and infrastructure project.

What it ran

The public surface was intentionally small: Traefik handled HTTPS and routed traffic to Seerr for requests and Jellyfin for playback, while everything else stayed on the internal Docker network.

Behind that, the usual media-management stack did the plumbing. Radarr handled movies, Sonarr handled TV, Prowlarr managed indexers, Bazarr fetched subtitles, and qBittorrent handled downloads.

Jellyfin movies library showing a single test movie

The shape looked like this:

Users -> Traefik (HTTPS)
          ├── Seerr
          └── Jellyfin

Internal Docker network:
  Seerr -> Jellyfin, Radarr, Sonarr
  Prowlarr -> Radarr, Sonarr
  Radarr/Sonarr -> qBittorrent -> /content/torrents
  Radarr/Sonarr/Jellyfin -> /content/media
  Bazarr -> Radarr, Sonarr -> subtitles in /content/media

Hand-drawn architecture diagram for the media server stack

Only Seerr and Jellyfin sat behind public HTTPS routes. The admin UIs for qBittorrent, Radarr, Sonarr, Prowlarr, and Bazarr bound to localhost on the VPS, so I reached them through SSH port forwarding when I needed them.

Seerr manage-movie panel showing a completed request and Radarr controls

Making setup repeatable

The first version would have been easy to click together in a few web UIs, but that would only have captured the state of the server at one moment. It would not have helped when I rebuilt the VPS, a container changed its config format, or I forgot which checkbox I ticked six months earlier.

So I treated the server as something I should be able to recreate. Ansible installed Docker and the Compose plugin, configured UFW, created the media and app-data directories, templated the Compose file and service configs, started the stack, waited for health checks, and ran a one-shot init script against the app APIs.

Redacted Ansible verify output showing all media service health checks passing

The init step held most of the fiddly work. It created the Jellyfin admin user and libraries, generated the Jellyfin API key Seerr needed, connected Radarr and Sonarr to qBittorrent, connected Prowlarr back into the *arr apps, read Bazarr’s generated API key, and pushed subtitle provider and language settings.

I made that init script idempotent enough to re-run after config changes. It kept a small state file, skipped completed work, and avoided the more annoying failure mode where a redeploy rebuilt half the stack but left the apps half-configured.

Constraints that shaped it

I wanted this to run on cheap infrastructure, so I assumed the VPS would not have a GPU and would not be good at transcoding. That pushed the project toward direct play rather than raw quality.

The stack had a custom quality profile called Chromecast 2018. It blocked formats my old Chromecast was likely to choke on, including HEVC, DTS, TrueHD, 4K, interlaced 1080i, and raw HD captures. If I knew I would cast something, I picked that profile in Seerr’s advanced request flow; normal requests stayed unrestricted for clients that could handle better formats.

That trade-off meant one Radarr or Sonarr instance kept one file per title, so I did not get both a full-quality copy and a casting-safe copy. For this project, fewer moving parts mattered more than perfect per-device libraries.

Prowlarr had the same shape of compromise: the playbook connected it to Radarr and Sonarr, but I still added indexers by hand through an SSH tunnel so account details and provider choices stayed out of the repo.

How updates worked

The Docker image versions were pinned in vars.yml because I did not want a redeploy to silently pull a new major version of something stateful.

A small Python script, scripts/check-image-updates.py, read the Compose template and vars.yml, checked Docker Hub or GHCR for semver tags, and reported which services had updates available. The update path was deliberately boring: check available versions, bump pins, run the Ansible syntax check, inspect a check-mode config diff, then redeploy with the config, compose, and verify tags.

Rollback followed the same bias toward boring operations: if an image bump broke something, I could revert the version change and redeploy; if app data had migrated badly, I needed to stop there and restore that app’s config directory from backup rather than keep poking at a live service.

What I learned

Compose was the easy bit; the harder part was everything around first boot.

A lot of self-hosted apps assumed a human would open the web UI and finish setup. That was fine for a homelab, but it was annoying when the goal was a repeatable server. The project became a small catalogue of app-specific init quirks: which APIs worked before login, where generated API keys landed, what needed a restart, and which config files the app rewrote after boot.

The useful lesson was to automate the boring edges without fighting the apps. Template what stayed stable, use APIs for state the app owned, read generated keys back when the app insisted on creating them, and keep secrets outside Git while still persisting generated credentials locally so a re-run did not lock me out.

What I left for later

The repo reached a good enough state for my own use, but I left a few obvious next steps: document backup and restore for app config and media metadata, add lightweight health and disk-space alerts, test the full playbook against a disposable VPS before larger changes, and make update checks easier to run as regular maintenance.

I did not want this to turn into a platform. The point was a small, understandable stack that I could rebuild without remembering a weekend’s worth of clicking.