Jouskaio.me

The value of an idea lies in the using of it

Building a Self-Hosted Home Media Server: A Complete Guide

From NAS Storage to a Netflix-Like Experience Using Open-Source Tools

Introduction

Streaming services have become the norm for consuming movies and TV shows. Netflix, Disney+, Amazon Prime — they all offer convenience, but at a cost: monthly subscriptions that add up, content that disappears overnight, geo-restrictions, and zero control over what you watch and how you watch it. What if you could build your own streaming platform, hosted at home, that looks and feels like Netflix, automatically downloads content on demand, manages subtitles, and streams to any device including your TV — all for free ?

This is exactly what a self-hosted home media server achieves. It is a personal, private streaming ecosystem that you fully own and control. This article walks you through the full architecture, the technologies involved, the step-by-step build process, and — based on real-world operation — how to keep it stable and troubleshoot it when things go wrong.

The Goal: What Are We Building ?

The end result is a system that allows you to:

  • Browse and stream your movie and TV show library from any device (TV, phone, tablet, browser)
  • Request new content by simply searching for a title (the system handles the download automatically)
  • Access it from anywhere via a secure public domain name (e.g. ⁠jellyfin.yourdomain.com)
  • Manage subtitles automatically in multiple languages
  • Monitor server health, download activity, and library stats from a single dashboard
  • Use a mobile app (Moonfin on iOS) that looks and feels like a premium streaming app
  • Store everything on a NAS (Network Attached Storage) like a Synology
  • Run everything inside an isolated, easy-to-back-up virtual environment (Proxmox LXC container)

The philosophy behind this project is called the arr stack — a collection of open-source tools that work together seamlessly to create a fully automated media pipeline.

The Technologies Involved

Jellyfin — The Media Server

Jellyfin is the heart of the system. It is a free and open-source media server that organizes your movies, TV shows, and music into a beautiful library with posters, descriptions, ratings, and trailers — all pulled automatically from online databases like TMDb.

Jellyfin streams your media to any client: a web browser, a smart TV app, a mobile app, or a dedicated player. It handles transcoding on-the-fly, meaning it can convert video formats in real-time so any device can play any file regardless of its original format.

Key features:

  • Beautiful Netflix-like UI (especially with the JellyFlix CSS theme)
  • Multi-user support with individual watch history and parental controls
  • Hardware-accelerated transcoding (Intel QuickSync, NVENC)
  • Plugin system (Open Subtitles, TMDb, Trakt, etc.)
  • Fully open-source, no telemetry, no subscription

Jellyseerr — The Request Manager

Jellyseerr is the user-facing request portal. When you want to watch a movie that is not in your library yet, you go to Jellyseerr, search for it, and click “Request”. Jellyseerr then automatically notifies the download tools to fetch it.

It provides:

  • A polished Netflix-style discovery interface
  • Integration with TMDb for rich metadata
  • Request tracking (Pending / Downloading / Available)
  • User management — friends and family can request content
  • Notifications (email, Discord, Pushover, etc.)

Radarr — The Movie Manager

Radarr is an automated movie collection manager. Once Jellyseerr sends it a request, Radarr:

  1. Searches configured torrent indexers for the best quality match
  2. Sends the torrent to qBittorrent for download
  3. Monitors the download and renames/moves the file to the correct folder
  4. Upgrades quality automatically if a better version is released (e.g. CAM → BluRay)

Sonarr — The Series Manager

Sonarr does the same as Radarr but for TV series. It monitors entire shows, automatically downloads new episodes when they air, and keeps your library up to date season by season.

Prowlarr — The Indexer Manager

Prowlarr is the centralized indexer manager. Instead of configuring torrent sources separately in Radarr and Sonarr, you configure them once in Prowlarr and it syncs to all *arr apps automatically. It supports hundreds of public and private torrent indexers.

qBittorrent — The Download Client

qBittorrent is the actual torrent download engine. It receives torrent jobs from Radarr/Sonarr via its web API, downloads the files, and places them in the correct directory where Jellyfin can find them. It runs headlessly in Docker with a web UI for management.

Bazarr — The Subtitle Manager

Bazarr works hand-in-hand with Radarr and Sonarr. It scans your entire library, detects missing subtitles in the languages you configure, and automatically downloads them from providers like OpenSubtitles. It also connects to Sonarr and Radarr via SignalR for real-time updates whenever new content is added.

Homepage — The Dashboard

Homepage is a highly customizable startpage/dashboard that aggregates live statistics from every service in the stack into a single glanceable view: Jellyfin library counts, Radarr/Sonarr wanted/missing counts, qBittorrent transfer speeds, Prowlarr indexer stats, and more — all in one place, accessible from any browser on your network.

Jellystat — The Analytics Engine

Jellystat connects directly to Jellyfin’s API and a dedicated PostgreSQL database to provide rich, historical playback statistics: most-watched movies and series, active sessions, per-user viewing habits, and library growth over time.

Maintainerr — The Automatic Cleanup Tool

Maintainerr monitors your library for content that hasn’t been watched in a configurable period and automatically removes it (or flags it for review) to reclaim storage space, based on rules you define — extremely useful to prevent your NAS from silently filling up.

Fetcharr — The Metadata/Availability Sync Tool

Fetcharr keeps metadata and request statuses synchronized across the stack, reducing manual bookkeeping between Jellyseerr, Radarr, and Sonarr.

Synology NAS — The Storage Layer

A Synology NAS provides reliable, always-on storage with RAID protection for your media files. In this architecture, the NAS is mounted as a network share (SMB/NFS) directly to the container running Docker, meaning all applications read and write files directly to the NAS without the NAS needing to run any media software itself.

Proxmox + LXC — The Virtualization Layer

Rather than running Docker directly on bare metal or inside a full VM, this setup runs the entire Docker stack inside a single unprivileged LXC container managed by Proxmox VE. This gives you:

  • Near-native performance (LXC has virtually no virtualization overhead compared to a full VM)

Easy snapshots and backups at the container level, independent of Docker Simple resource control (CPU, RAM limits) directly from the Proxmox web UI The ability to run other unrelated services (other CTs/VMs) on the same physical host

⚠️ Important lesson learned: LXC containers can develop a stale/frozen network namespace state after repeated ⁠docker compose down/up cycles or Docker daemon restarts inside the container. If containers can ping each other by IP but not by hostname, or if the whole Docker network seems “stuck”, a full ⁠pct stop && pct start of the LXC container (from the Proxmox host, not from inside the container) resolves it by giving the container a completely fresh network stack.

Docker & Docker Compose — The Runtime

All services run as Docker containers orchestrated by Docker Compose. This provides:

  • Isolation between services
  • Easy updates (⁠docker compose pull && docker compose up -d) Reproducible deployments (entire stack defined in one ⁠docker-compose.yml file)
  • Shared networking between containers (they talk to each other by service name)

Traefik — The Reverse Proxy

Traefik sits in front of all services and routes incoming HTTPS traffic to the correct container based on the subdomain. It automatically handles SSL certificates via Let’s Encrypt, so all your services are accessible securely over HTTPS from the internet.

For example:

  • jellyfin.yourdomain.com → Jellyfin container
  • jellyseerr.yourdomain.com → Jellyseerr container ⁠
  • radarr.yourdomain.com → Radarr container

Moonfin — The Mobile Client

Moonfin is a premium iOS app that connects to your Jellyfin server and Jellyseerr simultaneously, providing a unified Netflix-like mobile experience. You can browse your library, stream directly, and request new content — all from one app.

Architecture & Structure

High-Level Overview

┌─────────────────────────────────────────────────────────────┐
│                          INTERNET                           │
└────────────────────────┬────────────────────────────────────┘
                          │ HTTPS (443)
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                    YOUR DOMAIN DNS                          │
│   jellyfin.you.me / jellyseerr.you.me / radarr.you.me       │
└────────────────────────┬────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                  HOME ROUTER / BOX                          │
│         Port forwarding 80 + 443 → LXC IP                   │
└────────────────────────┬────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                  PROXMOX HOST                               │
│  ┌──────────────────────────────────────────────────────┐   │
│  │        LXC Container "media-server" (Docker host)    │   │
│  │  ┌──────────────────────────────────────────────────┐│   │
│  │  │  Docker Network (bridge, static IPs)             ││   │
│  │  │                                                  ││   │
│  │  │  ┌──────────┐  routes to  ┌────────────────┐     ││   │
│  │  │  │ Traefik  │ ───────────►│ Jellyfin       │     ││   │
│  │  │  │ :80/443  │             │ Jellyseerr     │     ││   │
│  │  │  │ (proxy)  │ ───────────►│ Radarr/Sonarr  │     ││   │
│  │  │  │          │             │ Prowlarr       │     ││   │
│  │  │  │          │ ───────────►│ qBittorrent    │     ││   │
│  │  │  │          │             │ Bazarr         │     ││   │
│  │  │  │          │ ───────────►│ Homepage       │     ││   │
│  │  │  │          │             │ Jellystat + DB │     ││   │
│  │  │  │          │ ───────────►│ Maintainerr    │     ││   │
│  │  │  └──────────┘             └────────────────┘     ││   │
│  │  └──────────────────────────────────────────────────┘│   │
│  │                                                      │   │
│  │        NFS/SMB mount from Synology NAS               │   │
│  │        /mnt/media/movies  /mnt/media/tv              │   │
│  └────────────────────────┬─────────────────────────────┘   │
└───────────────────────────┼─────────────────────────────────┘
                            │ NFS/SMB
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                      SYNOLOGY NAS                           │
│  /volume1/media/movies    ← Radarr writes here              │
│  /volume1/media/tv        ← Sonarr writes here              │
│  /volume1/media/downloads ← qBittorrent downloads here      │
└─────────────────────────────────────────────────────────────┘

Data Flow: Requesting a New Movie

User (Moonfin / Jellyseerr web)
   │  1. Search + Request "The Crow"
   ▼
Jellyseerr
   │  2. Sends request to Radarr API
   ▼
Radarr
   │  3. Queries Prowlarr for torrent sources
   ▼
Prowlarr
   │  4. Returns torrent results from indexers
   ▼
Radarr
   │  5. Picks best quality match, sends to qBittorrent
   ▼
qBittorrent
   │  6. Downloads torrent to /downloads
   │  7. Radarr detects completion, moves file to /movies/...
   ▼
Jellyfin
   │  8. Library scan detects new file, fetches metadata
   │     Bazarr grabs subtitles automatically
   ▼
User's TV / Phone — Film appears in library, ready to watch

Network Communication Map

jellyseerr    ──────────────────────► radarr:7878
jellyseerr    ──────────────────────► sonarr:8989
jellyseerr    ──────────────────────► jellyfin:8096

radarr        ──────────────────────► prowlarr:9696
sonarr        ──────────────────────► prowlarr:9696
radarr        ──────────────────────► qbittorrent:8080
sonarr        ──────────────────────► qbittorrent:8080

bazarr        ──────────────────────► sonarr:8989 (SignalR)
bazarr        ──────────────────────► radarr:7878 (SignalR)

homepage      ──────────────────────► ALL services (read-only stats)
jellystat     ──────────────────────► jellyfin:8096
jellystat     ──────────────────────► jellystat-db:5432

jellyfin plugin (Moonfin) ──────────► jellyseerr:5055

All services  ──────────────────────► /mnt/nas (shared volume)

Prerequisites


Before starting, you will need :

RequirementDetails
Proxmox VE hostOr a Linux VM/bare-metal server, 4GB+ RAM
LXC containerUbuntu 22.04 template, unprivileged, 6GB+ RAM recommended
Docker + Docker Compose v2.xInstalled inside the LXC
A Synology NAS (optional)Or any external storage / local disk
A domain namePurchased from OVH, Namecheap, Cloudflare, etc.
A public IPFrom your ISP (static preferred) or use DDNS
Port forwardingOpen 80 and 443 on your router to the LXC

Step-by-Step Setup

Step 1 — Create the LXC Container on Proxmox

From the Proxmox web UI, create a new unprivileged LXC container:

  • Template: Ubuntu 22.04
  • RAM: 6 GB minimum (Jellyfin transcoding + all *arr apps + Docker overhead adds up fast — 4GB is too tight and can cause OOM kills)
  • CPU cores: 4+
  • Enable nesting=1 in the container’s options (required for Docker to run inside LXC)
    pct set <CTID> -features nesting=1

Step 2 – Prepare the Container

# Update the system
sudo apt update && sudo apt upgrade -y

# Install Docker
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker

# Install Docker Compose plugin
sudo apt install docker-compose-plugin -y

Step 3 — Mount the Synology NAS


If you use a Synology NAS as storage, mount it via NFS:

# Install NFS client
sudo apt install nfs-common -y

# Create mount points
sudo mkdir -p /mnt/nas/movies
sudo mkdir -p /mnt/nas/tv
sudo mkdir -p /mnt/nas/downloads

# Mount (replace NAS_IP and /volume1/media with your paths)
sudo mount -t nfs NAS_IP:/volume1/media/movies /mnt/nas/movies
sudo mount -t nfs NAS_IP:/volume1/media/tv /mnt/nas/tv
sudo mount -t nfs NAS_IP:/volume1/downloads /mnt/nas/downloads

To make mounts persistent across reboots, add to ⁠/etc/fstab:

NAS_IP:/volume1/media/movies   /mnt/nas/movies   nfs   defaults,_netdev   0   0
NAS_IP:/volume1/media/tv       /mnt/nas/tv       nfs   defaults,_netdev   0   0
NAS_IP:/volume1/downloads      /mnt/nas/downloads nfs  defaults,_netdev   0   0

Step 4 — DNS Configuration

In your domain registrar’s DNS panel, create the following A records pointing to your public IP:

SubdomainTypeValue
⁠jellyfinA⁠YOUR_PUBLIC_IP
⁠jellyseerrA⁠YOUR_PUBLIC_IP
⁠radarrA⁠YOUR_PUBLIC_IP
⁠sonarrA⁠YOUR_PUBLIC_IP
⁠prowlarrA⁠YOUR_PUBLIC_IP
⁠qbitA⁠YOUR_PUBLIC_IP
⁠traefikA⁠YOUR_PUBLIC_IP
homepageA⁠YOUR_PUBLIC_IP

Step 4 — Create the Docker Compose Stack (with static IPs)

Key improvement over earlier versions of this stack: assign a static IP to every container on the Docker bridge network. This prevents a subtle but very real class of bugs where a service (particularly dashboards like Homepage) caches a container’s resolved IP internally and keeps hammering a stale address after that container gets recreated with a new IP — resulting in confusing ⁠ETIMEDOUT / ⁠ECONNREFUSED errors that look like network failures but aren’t.

x-logging: &logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  traefik:
    image: traefik:v3.0
    container_name: traefik
    restart: unless-stopped
    networks:
      medianet:
        ipv4_address: 172.18.0.2
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./config/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - ./config/traefik/acme.json:/acme.json
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik.rule=Host(traefik.yourdomain.com)"
      - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik.service=api@internal"

  jellyfin:
    image: lscr.io/linuxserver/jellyfin:latest
    container_name: jellyfin
    environment:
      - PUID=1024
      - PGID=100
      - TZ=Europe/Paris
    volumes:
      - ./config/jellyfin:/config
      - /mnt/nas/movies:/data/movies:ro
      - /mnt/nas/tv:/data/tv:ro
    networks:
      medianet:
        ipv4_address: 172.18.0.15
    restart: unless-stopped
    logging: *logging
    mem_limit: 2g
    memswap_limit: 2g
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.jellyfin.rule=Host(jellyfin.yourdomain.com)"
      - "traefik.http.routers.jellyfin.tls.certresolver=letsencrypt"
      - "traefik.http.services.jellyfin.loadbalancer.server.port=8096"

  jellyseerr:
    image: fallenbagel/jellyseerr:latest
    container_name: jellyseerr
    volumes:
      - ./config/jellyseerr:/app/config
    networks:
      medianet:
        ipv4_address: 172.18.0.16
    restart: unless-stopped
    logging: *logging
    mem_limit: 384m
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.jellyseerr.rule=Host(jellyseerr.yourdomain.com)"
      - "traefik.http.routers.jellyseerr.tls.certresolver=letsencrypt"
      - "traefik.http.services.jellyseerr.loadbalancer.server.port=5055"

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    environment:
      - PUID=1024
      - PGID=100
      - TZ=Europe/Paris
    volumes:
      - ./config/radarr:/config
      - /mnt/nas/movies:/movies
      - /mnt/nas/downloads:/downloads
    networks:
      medianet:
        ipv4_address: 172.18.0.11
    restart: unless-stopped
    logging: *logging
    mem_limit: 512m
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.radarr.rule=Host(radarr.yourdomain.com)"
      - "traefik.http.routers.radarr.tls.certresolver=letsencrypt"
      - "traefik.http.services.radarr.loadbalancer.server.port=7878"

  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      - PUID=1024
      - PGID=100
      - TZ=Europe/Paris
    volumes:
      - ./config/sonarr:/config
      - /mnt/nas/tv:/tv
      - /mnt/nas/downloads:/downloads
    networks:
      medianet:
        ipv4_address: 172.18.0.12
    restart: unless-stopped
    logging: *logging
    mem_limit: 512m
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.sonarr.rule=Host(sonarr.yourdomain.com)"
      - "traefik.http.routers.sonarr.tls.certresolver=letsencrypt"
      - "traefik.http.services.sonarr.loadbalancer.server.port=8989"

  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    environment:
      - PUID=1024
      - PGID=100
      - TZ=Europe/Paris
    volumes:
      - ./config/prowlarr:/config
    networks:
      medianet:
        ipv4_address: 172.18.0.10
    restart: unless-stopped
    logging: *logging
    mem_limit: 256m
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.prowlarr.rule=Host(prowlarr.yourdomain.com)"
      - "traefik.http.routers.prowlarr.tls.certresolver=letsencrypt"
      - "traefik.http.services.prowlarr.loadbalancer.server.port=9696"

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    environment:
      - PUID=1024
      - PGID=100
      - TZ=Europe/Paris
      - WEBUI_PORT=8080
    volumes:
      - ./config/qbittorrent:/config
      - /mnt/nas/downloads:/downloads
    networks:
      medianet:
        ipv4_address: 172.18.0.13
    restart: unless-stopped
    logging: *logging
    mem_limit: 1g
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.qbit.rule=Host(qbit.yourdomain.com)"
      - "traefik.http.routers.qbit.tls.certresolver=letsencrypt"
      - "traefik.http.services.qbit.loadbalancer.server.port=8080"

  bazarr:
    image: lscr.io/linuxserver/bazarr:latest
    container_name: bazarr
    environment:
      - PUID=1024
      - PGID=100
      - TZ=Europe/Paris
    volumes:
      - ./config/bazarr:/config
      - /mnt/nas/movies:/movies
      - /mnt/nas/tv:/tv
    networks:
      medianet:
        ipv4_address: 172.18.0.14
    restart: unless-stopped
    logging: *logging
    mem_limit: 512m

  jellystat-db:
    image: postgres:15.2
    container_name: jellystat-db
    environment:
      - POSTGRES_DB=jfstat
      - POSTGRES_USER=jellystat
      - POSTGRES_PASSWORD=changeme
    volumes:
      - ./config/jellystat/db:/var/lib/postgresql/data
    networks:
      medianet:
        ipv4_address: 172.18.0.17
    restart: unless-stopped
    logging: *logging
    mem_limit: 256m

  jellystat:
    image: cyfershepard/jellystat:latest
    container_name: jellystat
    environment:
      - POSTGRES_USER=jellystat
      - POSTGRES_PASSWORD=changeme
      - POSTGRES_IP=jellystat-db
      - POSTGRES_PORT=5432
      - JWT_SECRET=use_a_long_random_string_here
      - TZ=Europe/Paris
    depends_on:
      - jellystat-db
    networks:
      medianet:
        ipv4_address: 172.18.0.18
    restart: unless-stopped
    logging: *logging
    mem_limit: 256m

  maintainerr:
    image: ghcr.io/maintainerr/maintainerr:latest
    container_name: maintainerr
    user: "1024:100"
    environment:
      - TZ=Europe/Paris
    volumes:
      - ./config/maintainerr:/opt/data
      - /mnt/nas/movies:/movies
      - /mnt/nas/tv:/tv
    networks:
      medianet:
        ipv4_address: 172.18.0.21
    restart: unless-stopped
    logging: *logging
    mem_limit: 256m

  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    container_name: homepage
    environment:
      - TZ=Europe/Paris
      - PUID=1024
      - PGID=100
    volumes:
      - ./config/homepage:/app/config
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      medianet:
        ipv4_address: 172.18.0.20
    restart: unless-stopped
    logging: *logging
    mem_limit: 192m
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.homepage.rule=Host(homepage.yourdomain.com)"
      - "traefik.http.routers.homepage.tls.certresolver=letsencrypt"
      - "traefik.http.services.homepage.loadbalancer.server.port=3000"

networks:
  medianet:
    driver: bridge
    ipam:
      config:
        - subnet: 172.18.0.0/16
          gateway: 172.18.0.1

Step 6 — Traefik Configuration

Create ⁠config/traefik/traefik.yml:

api:
  dashboard: true

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      email: you@example.com
      storage: /acme.json
      httpChallenge:
        entryPoint: web

providers:
  docker:
    exposedByDefault: false
    network: medianet

Step 7 — Launch the Stack

cd ~/mediaserver
docker compose up -d
docker compose ps

Step 8 — Initial Configuration

Jellyfin (⁠jellyfin.yourdomain.com)

  1. Complete the setup wizard
  2. Add movie library → path ⁠/data/movies Add TV library → path ⁠/data/tv
  3. Install plugins: Open Subtitles, TMDb, TMDb Box Set

Prowlarr (⁠prowlarr.yourdomain.com)

  1. Add indexers (public/private trackers)
  2. Settings → Apps → Add Radarr (URL: ⁠http://radarr:7878, API key from Radarr)
  3. Settings → Apps → Add Sonarr (URL: ⁠http://sonarr:8989, API key from Sonarr)

Radarr / Sonarr

  1. Settings → Download Clients → Add qBittorrent (Host: ⁠qbittorrent, Port: ⁠8080)
  2. Settings → Root Folders → Add ⁠/movies (or ⁠/tv) Settings → Quality Profiles → configure your preferred quality

Jellyseerr (⁠jellyseerr.yourdomain.com)

  1. Connect to Jellyfin: URL ⁠http://jellyfin:8096
  2. Connect to Radarr and Sonarr with their API keys Configure default quality profiles

Bazarr

  1. Settings → Sonarr / Radarr → add connection with API keys
  2. Settings → Languages → choose your subtitle languages Settings → Providers → enable OpenSubtitles

Homepage

  1. Edit ⁠config/services.yaml to add each widget with its URL and API key
  2. Edit ⁠config/settings.yaml to set ⁠HOMEPAGE_ALLOWED_HOSTS to your access URL — otherwise Homepage will silently refuse connections from anything but localhost

Jellystat

  1. On first launch, create an admin account
  2. Settings → Add Jellyfin server URL + API key Wait for the initial sync to populate historical data

Step 9 — Apply the Netflix Theme to Jellyfin

In Jellyfin → Dashboard → General → Custom CSS Code:

@import url("https://cdn.jsdelivr.net/gh/prayag17/JellyFlix@latest/default.css");
@import url("https://cdn.jsdelivr.net/gh/prayag17/JellyFlix@latest/addons/Logo.css");

Step 10 — Configure Moonfin on iOS

  1. Download Moonfin from the App Store
  2. Add server: ⁠https://jellyfin.yourdomain.com
  3. Sign in with your Jellyfin credentials
  4. Settings → Seerr → Enable Seerr → Sign in with Jellyfin account

Security Considerations

This stack is exposed to the internet, so security matters:

  • Strong passwords for all services
  • Never expose qBittorrent, Radarr, Sonarr, or Prowlarr publicly — use Traefik middlewares with BasicAuth or IP whitelisting
  • Only Jellyfin and Jellyseerr should be publicly accessible
  • Fail2ban can be configured to block brute force attempts
  • Let’s Encrypt SSL is handled automatically by Traefik

To restrict access to the *arr tools and Homepage to your local network only, add this Traefik middleware:

# In your docker-compose labels for radarr, sonarr, prowlarr, qbittorrent:
- "traefik.http.routers.radarr.middlewares=local-only"
- "traefik.http.middlewares.local-only.ipwhitelist.sourcerange=192.168.1.0/24"

Maintaining the Stack

Update all containers

cd ~/mediaserver
docker compose pull
docker compose up -d

View logs

docker compose logs -f jellyfin
docker compose logs -f radarr

Backup

The only thing you need to back up are the ⁠config/ folders. Your media files live on the NAS and are safe. A simple cron job can archive configs :

# Daily backup of configs
0 3 * * * tar -czf /backup/mediaserver-config-$(date +%F).tar.gz ~/mediaserver/config/

⚠️ Lesson learned: if you also run a filesystem-level backup job (e.g. Proxmox’s built-in backup, or a snapshot-based backup) on the LXC container itself, schedule it outside peak usage hours and expect it to briefly freeze I/O on the container during the snapshot phase — this can cause transient database corruption on services with SQLite databases (Radarr, Sonarr, Prowlarr) if the container crashes or is forcibly stopped mid-backup. Always run ⁠fsck on the LXC’s underlying volume after any unclean shutdown.

Troubleshooting: Real Issues and Fixes

Running this stack for a while inevitably surfaces a handful of recurring issues. Here are the ones most worth knowing about, since none of them are obvious from the official documentation.

“Homepage shows API Error on some widgets, but the service works fine when I open it directly”

Symptom: ⁠docker compose logs homepage shows ⁠ETIMEDOUT or ⁠ECONNREFUSED errors pointing to an IP address that doesn’t match the container’s actual current IP.

Cause: Docker assigns dynamic IPs to containers by default. Node.js-based services like Homepage can internally cache resolved DNS lookups and keep using stale IPs after a container is recreated with a new one — especially after several rounds of ⁠docker compose down/up.

Fix: Assign static IPs to every service in your ⁠docker-compose.yml (as shown in the compose file above). Once an IP never changes, this class of bug disappears permanently — even if the DNS cache issue persists internally, it will always resolve to the correct, unchanging address.

“One or two services intermittently show 503 or ECONNREFUSED, but a manual curl/wget to them returns 200 OK instantly”

Symptom: Errors appear only occasionally in the logs; manual testing shows the service is healthy.

Cause: Usually a transient resource issue — the container briefly restarts (silent crash-restart) or is momentarily unavailable (e.g. Jellyfin mid-library-scan or transcoding under load) at the exact moment Homepage polls it.

Diagnosis:

docker inspect <container> --format='{{.State.OOMKilled}} - Restart Count: {{.RestartCount}}'

If ⁠OOMKilled: true or a high restart count shows up, your ⁠mem_limit is too tight for that service.

Fix: Increase the container’s memory limit, particularly for Jellyfin (transcoding is memory-hungry) and any container handling large libraries.

“The whole Docker network seems frozen — containers can’t reach each other, even by IP”

Symptom: All inter-container communication fails simultaneously, ⁠nf_conntrack counters look fine, ⁠iptables rules look normal, restarting Docker (⁠systemctl restart docker) inside the container doesn’t help.

Cause: The LXC container’s network namespace itself gets into a bad state — often after repeated Docker daemon or ⁠docker compose down/up cycles overnight.

Fix: Restart the entire LXC container from the Proxmox host (not from inside it):

pct stop <CTID>

pct start <CTID>

This gives the container a genuinely fresh network stack, which a simple Docker restart cannot achieve since the underlying namespace issue lives outside of Docker’s control.

“A container’s SQLite database is corrupted after an unclean shutdown”

Symptom: Radarr/Sonarr/Prowlarr fails to start, or the web UI throws generic 500 errors, right after a backup job or a forced restart.

Fix:

pct enter <CTID>

fsck -y /dev/mapper/<volume-of-affected-mountpoint>

If the corruption is inside the app’s own SQLite database rather than the filesystem, most *arr apps ship a repair/vacuum option accessible from ⁠System → Backup in their web UI, or you can restore from the last known-good daily config backup.

Going Further

Once your base stack is running, here are popular extensions:

ToolPurpose
BazarrAutomatic subtitle download for existing library
JellystatsBeautiful statistics dashboard for Jellyfin
HomepageUnified live dashboard for the entire stack
MaintainerrAutomatic cleanup of unwatched content to reclaim storage
NotifiarrCentralized notification hub for all *arr apps
RecyclarrSync quality profiles from the community TRaSH Guides
WatchtowerAutomatic Docker image updates
Cloudflare TunnelNo port forwarding needed, traffic through Cloudflare

Conclusion

What we have built is a fully self-hosted, automated, and private streaming platform. From a single ⁠docker compose up -d command, you get a Netflix-like media server that:

  • Organizes and serves your entire movie and TV collection
  • Automatically downloads new content on request in the best available quality
  • Handles subtitles without any manual intervention
  • Is accessible from anywhere in the world via a secure HTTPS domain
  • Gives you a real-time operational dashboard of the entire stack’s health
  • Looks stunning on any device thanks to the JellyFlix theme and Moonfin on iOS
  • Runs on a resilient, snapshot-friendly LXC container rather than a heavier full VM

The total cost? A Linux VM (can even run on a spare PC or a €5/month VPS for the apps), a NAS or external hard drive for storage, and a domain name (~€10/year). No monthly subscription. No content disappearing. No geo-restrictions. Your media, your rules.


Stack versions used in this guide: Jellyfin 10.11, Jellyseerr 2.x, Radarr 5.x, Sonarr 4.x, Prowlarr 1.x, Traefik 3.x, Docker Compose v2

Newsletter

Stay in the loop! ✨
 
Subscribe to my newsletter to get my latest updates, new articles, and thoughts on tech, projects, and what I’m building.

Leave a Reply

Your email address will not be published. Required fields are marked *

×