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

- Link: https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/
- Published: 2026-09-06T11:27:23+02:00
- Author: Manon Salsou

Table of contents

21mn read

 1.    [Introduction](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#introduction)
 2.    [The Goal: What Are We Building ?](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#the-goal-what-are-we-building)
 3.   a.    [Jellyfin — The Media Server](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#jellyfin-the-media-server)
      b.    [Jellyseerr — The Request Manager](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#jellyseerr-the-request-manager)
      c.    [Radarr — The Movie Manager](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#radarr-the-movie-manager)
      d.    [Sonarr — The Series Manager](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#sonarr-the-series-manager)
      e.    [Prowlarr — The Indexer Manager](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#prowlarr-the-indexer-manager)
      f.    [qBittorrent — The Download Client](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#qbittorrent-the-download-client)
      g.    [Bazarr — The Subtitle Manager](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#bazarr-the-subtitle-manager)
      h.    [Homepage — The Dashboard](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#homepage-the-dashboard)
      i.    [Jellystat — The Analytics Engine](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#jellystat-the-analytics-engine)
      j.    [Maintainerr — The Automatic Cleanup Tool](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#maintainerr-the-automatic-cleanup-tool)
      k.    [Fetcharr — The Metadata/Availability Sync Tool](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#fetcharr-the-metadata-availability-sync-tool)
      l.    [Proxmox + LXC — The Virtualization Layer](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#proxmox-lxc-the-virtualization-layer)
      m.    [Traefik — The Reverse Proxy](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#traefik-the-reverse-proxy)
      n.    [Moonfin — The Mobile Client](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#moonfin-the-mobile-client)
 4.    [Architecture & Structure](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#architecture-structure)
 5.   a.   [High-Level Overview](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#high-level-overview)
      b.   [Data Flow: Requesting a New Movie](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#data-flow-requesting-a-new-movie)
      c.   [Network Communication Map](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#network-communication-map)
 6.    [Prerequisites](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#prerequisites)
 7.    [Step-by-Step Setup](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-by-step-setup)
 8.   a.    [Step 1 — Create the LXC Container on Proxmox](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-1-create-the-lxc-container-on-proxmox)
      b.    [Step 2 - Prepare the Container](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-2-prepare-the-container)
      c.    [Step 3 — Mount the Synology NAS](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-3-mount-the-synology-nas)
      d.    [Step 4 — DNS Configuration](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-4-dns-configuration)
      e.    [Step 4 — Create the Docker Compose Stack (with static IPs)](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-4-create-the-docker-compose-stack-with-static-ips)
      f.    [Step 6 — Traefik Configuration](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-6-traefik-configuration)
      g.    [Step 7 — Launch the Stack](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-7-launch-the-stack)
      h.    [Step 8 — Initial Configuration](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-8-initial-configuration)
      i.    [Step 9 — Apply the Netflix Theme to Jellyfin](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-9-apply-the-netflix-theme-to-jellyfin)
      j.    [Step 10 — Configure Moonfin on iOS](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#step-10-configure-moonfin-on-ios)
 9.    [Security Considerations](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#security-considerations)
 10.   [Maintaining the Stack](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#maintaining-the-stack)
 11.  a.   [Update all containers](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#update-all-containers)
      b.   [View logs](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#view-logs)
      c.   [Backup](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#backup)
 12.   [Troubleshooting: Real Issues and Fixes](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#troubleshooting-real-issues-and-fixes)
 13.   [Going Further](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#going-further)
 14.   [Conclusion](https://blog.jouskaio.me/building-a-self-hosted-home-media-server-a-complete-guide/#conclusion)

# **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.
In its current form, the stack relies on a single shared `/data` path inside containers,
backed by one NFS mount on the host, to keep downloads, imports, and hardlinks consistent
across all services.

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

    ```EnlighterJSRAW
    ┌─────────────────────────────────────────────────────────────┐
    │                          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 mount from Synology NAS                    │   │
    │  │        /mnt/media (single unified mount)              │   │
    │  └────────────────────────┬─────────────────────────────┘   │
    └───────────────────────────┼─────────────────────────────────┘
                                │ NFS
                                ▼
    ┌─────────────────────────────────────────────────────────────┐
    │                      SYNOLOGY NAS                           │
    │  /volume1/Media (single share)                             │
    │    ├── downloads  ← qBittorrent writes here                 │
    │    ├── movies     ← Radarr imports here                     │
    │    └── series     ← Sonarr imports here                     │
    └─────────────────────────────────────────────────────────────┘
    ```

### Data Flow: Requesting a New Movie

    ```EnlighterJSRAW
    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 /data/downloads
       │  7. Radarr detects completion, hardlinks file to /data/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

    ```EnlighterJSRAW
    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/media (shared NFS volume)
    ```

## Prerequisites

Before starting, you will need :

| **Requirement** | **Details** | 
| Proxmox VE host | Or a Linux VM/bare-metal server, 4GB+ RAM | 
| LXC container | Ubuntu 22.04 template, unprivileged, 6GB+ RAM recommended | 
| Docker + Docker Compose v2.x | Installed inside the LXC | 
| A Synology NAS (optional) | Or any external storage / local disk | 
| A domain name | Purchased from OVH, Namecheap, Cloudflare, etc. | 
| A public IP | From your ISP (static preferred) or use DDNS | 
| Port forwarding | Open 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

    ```EnlighterJSRAW
    # 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:

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

    # Create mount point (single unified mount)
    sudo mkdir -p /mnt/media

    # Mount entire media share (replace NAS_IP with your Synology IP)
    sudo mount -t nfs NAS_IP:/volume1/Media /mnt/media
    ```

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

    ```EnlighterJSRAW
    NAS_IP:/volume1/Media   /mnt/media   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:

| **Subdomain** | **Type** | **Value** | 
| ⁠jellyfin | A | ⁠YOUR_PUBLIC_IP | 
| ⁠jellyseerr | A | ⁠YOUR_PUBLIC_IP | 
| ⁠radarr | A | ⁠YOUR_PUBLIC_IP | 
| ⁠sonarr | A | ⁠YOUR_PUBLIC_IP | 
| ⁠prowlarr | A | ⁠YOUR_PUBLIC_IP | 
| ⁠qbit | A | ⁠YOUR_PUBLIC_IP | 
| ⁠traefik | A | ⁠YOUR_PUBLIC_IP | 
| homepage | A | ⁠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.

    ```EnlighterJSRAW
    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/media:/data: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/media:/data
        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/media:/data
        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/media:/data
        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/media:/data
        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/media:/data
        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`:

    ```EnlighterJSRAW
    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

    ```EnlighterJSRAW
    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/series`
 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:

    ```EnlighterJSRAW
    @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:

    ```EnlighterJSRAW
    # 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

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

### View logs

    ```EnlighterJSRAW
    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 :

    ```EnlighterJSRAW
    # 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:**

    ```EnlighterJSRAW
    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:**

    ```EnlighterJSRAW
    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:

| **Tool** | **Purpose** | 
| **Bazarr** | Automatic subtitle download for existing library | 
| **Jellystats** | Beautiful statistics dashboard for Jellyfin | 
| **Homepage** | Unified live dashboard for the entire stack | 
| **Maintainerr** | Automatic cleanup of unwatched content to reclaim storage | 
| **Notifiarr** | Centralized notification hub for all *arr apps | 
| **Recyclarr** | Sync quality profiles from the community TRaSH Guides | 
| **Watchtower** | Automatic Docker image updates | 
| **Cloudflare Tunnel** | No 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.**

---

    ```wp-block-preformatted
    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. Architecture: Unified /data mount, single NFS share from Synology, hardlink-capable imports.
    ```

**Update (September 2026):** The complete Docker Compose and mount architecture 
have been revised to use a single unified /mnt/media → /data bind mount. For migration
guidance from fragmented paths, see [Auditing and Consolidating a Self-Hosted Media Stack](https://blog.jouskaio.me/auditing-and-consolidating-a-self-hosted-media-stack-hardlinks-storage-cleanup-and-safe-migration/)—
includes real audit results with 150 GiB reclaimed and hardlink preservation validated.

## 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.

Name

Firstname

Email

I accept the privacy policy
