← All Tutorials

Homelab Media Server — Complete Docker Stack on Repurposed Laptop

Infrastructure & DevOps Beginner 32 min read #44

Tutorial 44: Homelab Media Server — Complete Docker Stack on Repurposed Laptop

Transform an old laptop into a powerful self-hosted media server running Jellyfin, the *Arr suite, qBittorrent, AdGuard Home, Nextcloud, and 15+ services — all managed through Docker Compose with Intel QSV hardware transcoding, automated media management, and remote access via Tailscale.

Difficulty: Beginner-Intermediate Reading time: ~60 minutes Prerequisites: A laptop or mini PC with an Intel CPU (6th gen+ for QSV), 16GB+ RAM, Ubuntu 24.04 installed Technologies: Docker, Jellyfin, Radarr, Sonarr, Prowlarr, qBittorrent, AdGuard, Nginx Proxy Manager, Tailscale, Tdarr, Nextcloud, Portainer OS: Ubuntu 24.04 LTS


Table of Contents

  1. Introduction — Why Self-Host a Media Server
  2. Hardware Overview & Storage Strategy
  3. Ubuntu Installation & System Configuration
  4. Environment Configuration — Centralized .env File
  5. Management Stack — Portainer, Tailscale, Watchtower
  6. Network & Security — AdGuard Home & Nginx Proxy Manager
  7. Download Stack — qBittorrent
  8. *Arr Media Automation Suite
  9. Jellyfin Media Server
  10. Nextcloud — Self-Hosted Cloud Storage
  11. Media Processing — Tdarr & Ollama
  12. Monitoring — Uptime Kuma
  13. Integration & Automation — Connecting Everything Together
  14. Backup & Disk Protection
  15. Dashboard — Landing Page with Service Links
  16. Troubleshooting — Common Issues and Fixes
  17. Conclusion

1. Introduction — Why Self-Host a Media Server

The Case for Self-Hosting

Streaming services keep raising prices, splitting content across platforms, and removing shows without warning. A self-hosted media server gives you:

Why a Laptop?

Repurposing an old laptop is one of the best homelab decisions you can make:

Advantage Details
Built-in UPS Battery survives short power outages — no separate UPS needed
Low power 15-20W idle (vs 60-100W for a desktop/tower server)
Silent Laptop fans are quiet, often fanless at idle
Compact Sits on a shelf, no rack needed
Built-in display Useful for initial setup, then run headless
Intel QSV Most Intel laptops support Quick Sync Video for hardware transcoding

An Intel i5 (11th gen or newer) with 16-32GB RAM and an NVMe SSD is the sweet spot. The integrated Intel Iris Xe GPU provides excellent hardware transcoding — H.264, H.265/HEVC, VP9, and AV1 decode, all in hardware.

What We Are Building

By the end of this tutorial, you will have a fully automated media server with this architecture:

User requests movie     Radarr searches indexers      qBittorrent downloads
via Jellyseerr    -->   via Prowlarr             -->  to NVMe SSD
                                                           |
                                                           v
                                                      Download completes
                                                           |
                                                           v
                        Radarr imports movie          qBittorrent seeds to
                        to media library         <--  ratio 1.0, auto-removes
                              |
                              v
                        Jellyfin detects new movie    Bazarr auto-downloads
                        in library               -->  subtitles (.srt/.ass)
                              |
                              v
                        Tdarr (optional) transcodes
                        to H.265 using Intel QSV
                              |
                              v
                        Movie available for playback
                        on all devices (TV, phone, web)

Service Overview — Port Map

Port Service Purpose Access Level
8096 Jellyfin Media streaming server LAN + Tailscale
5055 Jellyseerr Request portal (Netflix-like UI) LAN + Tailscale
8088 Nextcloud Self-hosted cloud storage LAN + Tailscale
8080 qBittorrent Torrent download client LAN only
8989 Sonarr TV show automation LAN only
7878 Radarr Movie automation LAN only
8686 Lidarr Music automation LAN only
9696 Prowlarr Indexer manager LAN only
6767 Bazarr Automatic subtitles LAN only
53 AdGuard Home DNS-level ad blocking LAN
8053 AdGuard Home Admin Ad blocker management UI LAN only
81 Nginx Proxy Manager Reverse proxy admin LAN only
80/443 NPM (proxy) Public reverse proxy WAN (if configured)
9000 Portainer Docker management GUI LAN only
3001 Uptime Kuma Service health monitoring LAN only
8265 Tdarr Automated transcoding LAN only
11434 Ollama Local AI/LLM API LAN only
8888 Dashboard Landing page with service links LAN + Tailscale

2. Hardware Overview & Storage Strategy

Recommended Hardware Specs

Spec Minimum Recommended Notes
CPU Intel i5 6th gen Intel i5 11th gen+ Must have Intel Quick Sync (QSV)
GPU Intel UHD 630 Intel Iris Xe Iris Xe adds AV1 decode, better HEVC
RAM 16GB DDR4 32GB DDR4 16GB is tight with Nextcloud + Ollama
Boot Drive 256GB SSD 500GB NVMe SSD OS + Docker + configs + downloads
Media Drive - 500GB+ SSD or HDD Separate drive for media library
TDP - 28W (PL1) ~15-20W idle

Laptop-Specific Considerations

Storage Strategy — Dual-Drive Setup

The ideal setup uses two drives: an NVMe SSD for the OS, Docker, and temporary downloads, and a second SSD (or HDD) for the media library.

NVMe SSD (boot drive) — OS, Docker, downloads, configs
├── /                     OS + Docker (~20GB)
├── /opt/appdata          Container configs + databases (~5-10GB)
├── /opt/docker           Docker compose files + scripts
├── /mnt/media            Media library root
│   ├── downloads/        Torrent downloads (temporary storage)
│   ├── movies/           -> BIND MOUNT to /mnt/ssd/movies (second drive)
│   ├── tv/               TV shows
│   ├── music/            Music
│   └── books/            Books
└── /var/lib/docker       Container images + volumes (~10-20GB)

Second SSD/HDD (/dev/sda1)
└── /mnt/ssd              Mounted via fstab (by UUID)
    └── movies/           Movie library, bind-mounted to /mnt/media/movies

Key design decision: The second drive is bind-mounted to /mnt/media/movies via fstab. This makes the second drive completely transparent to all containers -- Jellyfin, Radarr, Tdarr, and Bazarr all see /mnt/media/movies as if it were a local directory. No Docker volume changes needed when adding or replacing drives.


3. Ubuntu Installation & System Configuration

3.1 — Install Ubuntu 24.04 LTS

Download Ubuntu 24.04 LTS (Server or Desktop edition) and install it on the laptop. Desktop edition uses slightly more RAM but gives you a GUI for initial setup.

3.2 — Update System

sudo apt update && sudo apt upgrade -y
sudo reboot

3.3 — Disable Lid Close Sleep

Edit /etc/systemd/logind.conf and set:

HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore

Then restart the login manager:

sudo systemctl restart systemd-logind

3.4 — Set Static IP (Recommended)

If your system uses NetworkManager (Ubuntu Desktop), use nmcli:

# For Ethernet:
sudo nmcli con add con-name "static-eth" ifname YOUR_ETH_INTERFACE type ethernet \
  ipv4.method manual ipv4.addresses YOUR_IP/24 \
  ipv4.gateway YOUR_GATEWAY ipv4.dns "1.1.1.1,8.8.8.8"
sudo nmcli con up static-eth

# For WiFi (adjust connection name from 'nmcli con show'):
sudo nmcli con modify "YOUR_WIFI_CONNECTION" \
  ipv4.method manual ipv4.addresses YOUR_IP/24 \
  ipv4.gateway YOUR_GATEWAY ipv4.dns "1.1.1.1,8.8.8.8"

# Verify
ip addr show

3.5 — Install Intel Quick Sync (QSV) Drivers

sudo apt install -y intel-media-va-driver vainfo mesa-va-drivers

Verify the installation:

vainfo

You should see output like:

VA-API version: 1.20
Driver version: Intel iHD driver for Intel(R) Gen Graphics - 24.1.0

Confirm codec support (look for H.264, HEVC, VP9, AV1 entries). GPU devices should appear at /dev/dri/card0 (or card1) and /dev/dri/renderD128.

Note: The free intel-media-va-driver package includes all commonly needed codecs. If you need extra codec profiles later, install intel-media-va-driver-non-free instead.

3.6 — Install Docker & Docker Compose

# Install Docker using the official convenience script
curl -fsSL https://get.docker.com | sh

# Add your user to the docker group (avoids needing sudo for docker commands)
sudo usermod -aG docker $USER

# Log out and back in for group change to take effect
# Verify:
docker --version
docker compose version

3.7 — Create Directory Structure

If your boot drive uses LVM (common with Ubuntu Server), expand it to use the full disk first:

# Expand LVM to use full disk (Ubuntu often allocates only ~100GB during install)
sudo lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv
sudo resize2fs /dev/ubuntu-vg/ubuntu-lv

Create the media directories:

# Create all directories
sudo mkdir -p /opt/appdata /opt/docker/compose
sudo mkdir -p /mnt/media/{downloads/{torrents/{movies,tv,music},usenet/{movies,tv,music}},movies,tv,music,books,nextcloud}

# Set ownership to your user (UID/GID 1000)
sudo chown -R 1000:1000 /opt/appdata /opt/docker /mnt/media

3.8 — Configure Power Management for 24/7 Operation

# Disable sleep/suspend/hibernate
sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target

# Disable Wi-Fi power management (prevents random disconnects if using WiFi)
# Edit /etc/NetworkManager/conf.d/default-wifi-powersave-on.conf
# Change wifi.powersave = 2 (or 3) to:
# wifi.powersave = 0

3.9 — Set Up Automatic Security Updates

sudo apt install -y unattended-upgrades

For laptops, also install thermal management:

sudo apt install -y thermald

4. Environment Configuration — Centralized .env File

All containers share a single .env file for common settings like user IDs, timezone, and paths. This avoids hardcoding values in every docker run command.

Create /opt/docker/.env:

# User/Group IDs (run 'id' to check yours — usually 1000:1000)
PUID=1000
PGID=1000
TZ=YOUR_TIMEZONE

# Paths
MEDIA_ROOT=/mnt/media
APPDATA=/opt/appdata

# Gluetun VPN (optional — fill in if you want VPN-protected torrents)
# VPN_SERVICE_PROVIDER=mullvad
# VPN_TYPE=wireguard
# WIREGUARD_PRIVATE_KEY=your_key_here
# WIREGUARD_ADDRESSES=your_address_here
# SERVER_COUNTRIES=Netherlands

Replace YOUR_TIMEZONE with your timezone identifier (e.g., Europe/London, America/New_York, Europe/Berlin, Asia/Tokyo). This ensures all container logs and scheduled tasks use the correct time. All docker run commands below reference ${TZ} from this file, or you can hardcode the timezone directly in each command.


5. Management Stack — Portainer, Tailscale, Watchtower

5.1 — Portainer (Docker GUI)

Portainer gives you a web-based GUI for managing all your Docker containers, images, volumes, and networks.

docker run -d \
  --name portainer \
  --restart=always \
  -p 9000:9000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /opt/appdata/portainer:/data \
  portainer/portainer-ce:latest

Access: http://YOUR_IP:9000

5.2 — Tailscale (Remote Access VPN)

Tailscale creates a secure mesh VPN so you can access your media server from anywhere (phone on 4G, laptop at a coffee shop, etc.) without exposing ports to the internet.

# Install Tailscale directly on the host (not Docker — more reliable on laptops)
curl -fsSL https://tailscale.com/install.sh | sh

# Start and authenticate
sudo tailscale up

# Follow the URL printed to authenticate with your Tailscale account
# (create free account at https://tailscale.com if you don't have one)

# Verify
tailscale status

Once connected, your server gets a Tailscale IP (100.x.x.x). You can access all services from anywhere using this IP.

Tip: Disable Tailscale's DNS management to avoid it hijacking your local DNS (especially important if you run AdGuard):

sudo tailscale set --accept-dns=false

5.3 — Watchtower (Automatic Container Updates)

Watchtower monitors your running containers and automatically pulls updated images, recreating containers with the same configuration.

docker run -d \
  --name watchtower \
  --restart=always \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e DOCKER_API_VERSION=1.53 \
  -e TZ=Europe/London \
  -e WATCHTOWER_CLEANUP=true \
  -e WATCHTOWER_SCHEDULE="0 0 4 * * *" \
  containrrr/watchtower:latest

This checks for container updates daily at 4:00 AM and removes old images after updating.

Note: If you see Docker API version errors, check your Docker API version with docker version --format '{{.Server.APIVersion}}' and set DOCKER_API_VERSION accordingly.


6. Network & Security — AdGuard Home & Nginx Proxy Manager

6.1 — AdGuard Home (Network-Wide Ad Blocking)

AdGuard Home is a DNS-level ad blocker that works for every device on your network -- phones, smart TVs, IoT devices -- without installing anything on the devices themselves.

docker run -d \
  --name adguardhome \
  --restart=always \
  -p 53:53/tcp \
  -p 53:53/udp \
  -p 3000:3000/tcp \
  -p 8053:80/tcp \
  -v /opt/appdata/adguardhome/work:/opt/adguardhome/work \
  -v /opt/appdata/adguardhome/conf:/opt/adguardhome/conf \
  adguard/adguardhome:latest

First-time setup at http://YOUR_IP:3000:

  1. Set the admin web interface to port 8053 (avoids conflicts with other services)
  2. Set DNS to port 53
  3. Create an admin username/password
  4. Add filter lists (recommended: AdGuard default + Steven Black's unified hosts)

After setup, the admin panel moves to: http://YOUR_IP:8053

Important: Set your router's DNS server to your media server's IP address so all devices on the network use AdGuard for DNS resolution.

6.2 — Nginx Proxy Manager (Reverse Proxy + SSL)

NPM lets you access services via clean domain names (e.g., jellyfin.yourdomain.com) instead of IP:port combinations, with automatic Let's Encrypt SSL certificates.

Note: If you have Nextcloud installed as a snap, remap its port first to free port 80 for NPM:

sudo snap set nextcloud ports.http=8088 && sudo snap restart nextcloud
docker run -d \
  --name nginx-proxy-manager \
  --restart=always \
  -p 80:80 \
  -p 443:443 \
  -p 81:81 \
  -v /opt/appdata/npm/data:/data \
  -v /opt/appdata/npm/letsencrypt:/etc/letsencrypt \
  jc21/nginx-proxy-manager:latest

Access: http://YOUR_IP:81 Default login: [email protected] / changeme (change immediately!)

You will configure proxy hosts here later for each service (e.g., jellyfin.yourdomain.com -> http://YOUR_IP:8096).


7. Download Stack — qBittorrent

qBittorrent is a torrent client with a web UI. We run it without a VPN here for simplicity, but you can add Gluetun (VPN container) later for privacy.

Privacy note: Without a VPN, your real IP is visible in torrent swarms. If you want VPN protection, deploy Gluetun and set network_mode: "service:gluetun" on the qBittorrent container.

7.1 — Deploy qBittorrent

Create the compose file at /opt/docker/compose/downloads.yml:

services:
  qbittorrent:
    image: linuxserver/qbittorrent:latest
    container_name: qbittorrent
    ports:
      - 8080:8080    # qBittorrent WebUI
      - 6881:6881    # Torrent port (TCP)
      - 6881:6881/udp
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
      - WEBUI_PORT=8080
    volumes:
      - /opt/appdata/qbittorrent:/config
      - ${MEDIA_ROOT}/downloads/torrents:/downloads
    restart: always

Deploy:

cd /opt/docker
docker compose -f compose/downloads.yml --env-file .env up -d

Access: http://YOUR_IP:8080 Default login: admin / check logs for the temporary password:

docker logs qbittorrent 2>&1 | grep "temporary password"

7.2 — Configure qBittorrent Settings

These settings are important for disk management and integration with the *Arr apps:

  1. Settings -> Downloads -> Default Save Path: /downloads/
  2. Settings -> Downloads -> Keep incomplete in: /downloads/incomplete/
  3. Settings -> BitTorrent -> Seeding limits: Set max ratio to 1.0 (see Section 14 for details)
  4. Settings -> Web UI: Change the default password

7.3 — SABnzbd (Usenet Downloads — Optional)

Skip this if you only use torrents. Add later if needed.

docker run -d \
  --name sabnzbd \
  --restart=always \
  -p 8888:8080 \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Europe/London \
  -v /opt/appdata/sabnzbd:/config \
  -v /mnt/media/downloads/usenet:/downloads \
  linuxserver/sabnzbd:latest

Access: http://YOUR_IP:8888


8. *Arr Media Automation Suite

The *Arr apps form the brain of your media automation pipeline. Each one manages a specific media type: movies, TV shows, music, and subtitles. Prowlarr acts as the central indexer manager, and Jellyseerr provides a user-friendly request portal.

8.1 — Prowlarr (Indexer Manager)

Prowlarr manages all your torrent indexers in one place and syncs them to Radarr, Sonarr, and Lidarr automatically.

docker run -d \
  --name prowlarr \
  --restart=always \
  -p 9696:9696 \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Europe/London \
  -v /opt/appdata/prowlarr:/config \
  linuxserver/prowlarr:latest

Access: http://YOUR_IP:9696

Setup:

  1. Settings -> General -> Set authentication (Forms, username/password)
  2. Indexers -> Add your torrent indexers (1337x, RARBG, etc.)
  3. Settings -> Apps -> Add Radarr, Sonarr, and Lidarr (after deploying them below)

8.2 — Radarr (Movies)

Radarr automates movie downloads. It searches indexers, sends torrents to qBittorrent, imports completed downloads, renames files, and manages your movie library.

docker run -d \
  --name radarr \
  --restart=always \
  -p 7878:7878 \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Europe/London \
  -v /opt/appdata/radarr:/config \
  -v /mnt/media:/media \
  linuxserver/radarr:latest

Access: http://YOUR_IP:7878

Setup:

  1. Settings -> General -> Set authentication
  2. Settings -> Media Management:
    • Root Folder: /media/movies
    • Enable "Rename Movies"
    • Enable "Use Hardlinks instead of Copy" -> YES (saves disk space when source and destination are on the same filesystem)
  3. Settings -> Download Clients -> Add qBittorrent:
    • Host: YOUR_IP
    • Port: 8080
    • Category: radarr
  4. Settings -> Quality Profiles -> Select "HD-1080p" as default
  5. Settings -> Indexers -> (these sync automatically from Prowlarr)

8.3 — Sonarr (TV Shows)

Sonarr is the same concept as Radarr but for TV shows. It monitors series, downloads new episodes automatically, and organizes them by season.

docker run -d \
  --name sonarr \
  --restart=always \
  -p 8989:8989 \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Europe/London \
  -v /opt/appdata/sonarr:/config \
  -v /mnt/media:/media \
  linuxserver/sonarr:latest

Access: http://YOUR_IP:8989

Setup (same pattern as Radarr):

  1. Authentication in Settings -> General
  2. Root Folder: /media/tv
  3. Download Client: qBittorrent (category: sonarr)
  4. Quality Profile: HD-1080p
  5. Enable hardlinks

8.4 — Lidarr (Music)

Lidarr manages your music library the same way Radarr manages movies.

docker run -d \
  --name lidarr \
  --restart=always \
  -p 8686:8686 \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Europe/London \
  -v /opt/appdata/lidarr:/config \
  -v /mnt/media:/media \
  linuxserver/lidarr:latest

Access: http://YOUR_IP:8686 Root Folder: /media/music, download category: lidarr

8.5 — Bazarr (Automatic Subtitles)

Bazarr automatically downloads subtitles for your movies and TV shows by connecting to Radarr and Sonarr.

docker run -d \
  --name bazarr \
  --restart=always \
  -p 6767:6767 \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Europe/London \
  -v /opt/appdata/bazarr:/config \
  -v /mnt/media:/media \
  linuxserver/bazarr:latest

Access: http://YOUR_IP:6767

Setup:

  1. Settings -> Subtitles -> Languages: Add your preferred languages
  2. Settings -> Providers -> Add OpenSubtitles.com (free account required)
  3. Settings -> Sonarr/Radarr -> Connect to both using their API keys (found in each app under Settings -> General)

8.6 — Jellyseerr (Request Portal)

Jellyseerr gives your users (family, friends) a Netflix-like interface to browse and request movies and TV shows. Requests are automatically forwarded to Radarr/Sonarr for download.

docker run -d \
  --name jellyseerr \
  --restart=always \
  -p 5055:5055 \
  -e TZ=Europe/London \
  -v /opt/appdata/jellyseerr:/app/config \
  fallenbagel/jellyseerr:latest

Access: http://YOUR_IP:5055 Connect to Jellyfin + Radarr + Sonarr during the setup wizard.

8.7 — Connect Prowlarr to All *Arr Apps

Once all *Arr apps are running, go back to Prowlarr (http://YOUR_IP:9696):

  1. Settings -> Apps -> Add:
    • Radarr: URL http://YOUR_IP:7878, API key from Radarr -> Settings -> General
    • Sonarr: URL http://YOUR_IP:8989, API key from Sonarr -> Settings -> General
    • Lidarr: URL http://YOUR_IP:8686, API key from Lidarr -> Settings -> General
  2. Click "Sync" — all indexers now appear in every *Arr app automatically

9. Jellyfin Media Server

Jellyfin is the heart of the stack — a free, open-source media server that streams your content to any device (web browser, smart TV, phone, tablet).

9.1 — Deploy Jellyfin with Hardware Transcoding

The key to smooth streaming is passing the Intel GPU device into the container for hardware-accelerated transcoding.

docker run -d \
  --name jellyfin \
  --restart=always \
  -p 8096:8096 \
  --device=/dev/dri/renderD128:/dev/dri/renderD128 \
  --device=/dev/dri/card1:/dev/dri/card1 \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Europe/London \
  -e JELLYFIN_PublishedServerUrl=http://YOUR_IP:8096 \
  -v /opt/appdata/jellyfin:/config \
  -v /mnt/media/movies:/data/movies \
  -v /mnt/media/tv:/data/tv \
  -v /mnt/media/music:/data/music \
  -v /mnt/media/books:/data/books \
  jellyfin/jellyfin:latest

Note: The GPU device path may be /dev/dri/card0 instead of /dev/dri/card1 on your system. Check with ls -la /dev/dri/ to confirm.

Access: http://YOUR_IP:8096

First-time setup wizard:

  1. Create an admin account
  2. Add media libraries:
    • Movies -> /data/movies
    • Shows -> /data/tv
    • Music -> /data/music
  3. Set preferred language and metadata language

9.2 — Enable QSV Hardware Transcoding

In Jellyfin's admin dashboard:

  1. Dashboard -> Playback -> Transcoding
  2. Hardware acceleration: Intel QuickSync (QSV)
  3. Enable hardware decoding for: H264, HEVC, VP9, AV1 (Intel Iris Xe supports all)
  4. Enable Hardware encoding -> YES
  5. Enable Tone mapping -> YES
  6. Preferred hardware encoder: QSV
  7. Save

9.3 — Verify Transcoding Works

  1. Play a video in the Jellyfin web player
  2. During playback, check if hardware transcoding is active:
# Should show GPU activity (render/video percentages above 0%)
sudo intel_gpu_top
# (install with: sudo apt install -y intel-gpu-tools)

9.4 — Client Apps

Point all apps at: http://YOUR_IP:8096 For remote access (outside home): Use the Tailscale IP http://100.x.x.x:8096


10. Nextcloud — Self-Hosted Cloud Storage

Nextcloud replaces Google Drive, Dropbox, and iCloud with a self-hosted solution. You can sync files, auto-upload photos from your phone, and share files with links.

Option A: Nextcloud Snap (Simpler)

The Ubuntu snap is the easiest way to run Nextcloud. It includes its own database and web server.

sudo snap install nextcloud

If you need port 80 for Nginx Proxy Manager, remap Nextcloud's HTTP port:

sudo snap set nextcloud ports.http=8088
sudo snap restart nextcloud

Access: http://YOUR_IP:8088

Option B: Nextcloud Docker (More Flexible)

If you prefer Docker for consistency with the rest of the stack:

Step 1 — MariaDB (database):

docker run -d \
  --name nextcloud-db \
  --restart=always \
  -e MYSQL_ROOT_PASSWORD=CHANGE_ME_ROOT_PW \
  -e MYSQL_DATABASE=nextcloud \
  -e MYSQL_USER=nextcloud \
  -e MYSQL_PASSWORD=CHANGE_ME_NC_PW \
  -v /opt/appdata/nextcloud-db:/var/lib/mysql \
  mariadb:latest \
  --transaction-isolation=READ-COMMITTED \
  --log-bin=binlog \
  --binlog-format=ROW

Step 2 — Nextcloud:

docker run -d \
  --name nextcloud \
  --restart=always \
  -p 8443:80 \
  -e MYSQL_HOST=nextcloud-db \
  -e MYSQL_DATABASE=nextcloud \
  -e MYSQL_USER=nextcloud \
  -e MYSQL_PASSWORD=CHANGE_ME_NC_PW \
  -e NEXTCLOUD_ADMIN_USER=admin \
  -e NEXTCLOUD_ADMIN_PASSWORD=CHANGE_ME_ADMIN_PW \
  -e NEXTCLOUD_TRUSTED_DOMAINS="YOUR_IP localhost" \
  --link nextcloud-db:nextcloud-db \
  -v /opt/appdata/nextcloud:/var/www/html \
  -v /mnt/media/nextcloud:/var/www/html/data \
  nextcloud:latest

Access: http://YOUR_IP:8443

Phone Setup

  1. Install the Nextcloud app from your phone's app store
  2. Server: http://YOUR_IP:8088 (snap) or http://YOUR_IP:8443 (Docker) — use the Tailscale IP for remote access
  3. Login with admin credentials
  4. For photo auto-upload: Nextcloud app -> Auto Upload -> Enable

Tip: PhotoSync app (iOS, one-time purchase) provides more reliable photo backup to Nextcloud via WebDAV than the native Nextcloud auto-upload.


11. Media Processing — Tdarr & Ollama

11.1 — Tdarr (Automated Library Transcoding)

Tdarr scans your existing media library and automatically re-encodes files to more efficient codecs (e.g., H.264 -> H.265/HEVC) using Intel QSV hardware acceleration. This can save 40-60% disk space with no visible quality loss.

docker run -d \
  --name tdarr \
  --restart=always \
  -p 8265:8265 \
  -p 8266:8266 \
  --device=/dev/dri:/dev/dri \
  -e PUID=1000 \
  -e PGID=1000 \
  -e TZ=Europe/London \
  -e serverIP=0.0.0.0 \
  -e serverPort=8266 \
  -e webUIPort=8265 \
  -e internalNode=true \
  -e inContainer=true \
  -e nodeName=MediaServer \
  -v /opt/appdata/tdarr/server:/app/server \
  -v /opt/appdata/tdarr/configs:/app/configs \
  -v /opt/appdata/tdarr/logs:/app/logs \
  -v /mnt/media:/media \
  -v /opt/appdata/tdarr/transcode_cache:/temp \
  ghcr.io/haveagitgat/tdarr:latest

Access: http://YOUR_IP:8265

Setup:

  1. Libraries -> Add:
    • Movies: Source /media/movies, Transcode cache /temp
    • TV: Source /media/tv, Transcode cache /temp
  2. Plugins -> Use community plugin: Tdarr_Plugin_MC93_Migz1FFMPEG
    • Target codec: HEVC (H.265)
    • Use hardware: Intel QSV
  3. Schedule (recommended to prevent daytime CPU load):
    • Node -> Schedule -> Enable, set hours like 01:00-07:00

11.2 — Ollama (Local LLM — Optional)

Ollama runs large language models locally on your server. Useful for a self-hosted AI chatbot, or just experimenting with local LLMs.

docker run -d \
  --name ollama \
  --restart=unless-stopped \
  -p 11434:11434 \
  -v /opt/appdata/ollama:/root/.ollama \
  ollama/ollama:latest

Pull a model:

docker exec ollama ollama pull mistral:7b
# Or a smaller model if RAM is tight:
docker exec ollama ollama pull phi3:mini

Test:

docker exec ollama ollama run mistral:7b "What is a homelab?"

12. Monitoring — Uptime Kuma

Uptime Kuma provides a clean, self-hosted status page that monitors all your services and alerts you when something goes down.

docker run -d \
  --name uptime-kuma \
  --restart=always \
  -p 3001:3001 \
  -v /opt/appdata/uptime-kuma:/app/data \
  louislam/uptime-kuma:latest

Access: http://YOUR_IP:3001

Add monitors for each service:

Service Type URL
Jellyfin HTTP http://YOUR_IP:8096
Radarr HTTP http://YOUR_IP:7878
Sonarr HTTP http://YOUR_IP:8989
qBittorrent HTTP http://YOUR_IP:8080
Prowlarr HTTP http://YOUR_IP:9696
Bazarr HTTP http://YOUR_IP:6767
Jellyseerr HTTP http://YOUR_IP:5055
AdGuard HTTP http://YOUR_IP:8053
Nextcloud HTTP http://YOUR_IP:8088

Set check interval to 60 seconds. Enable notifications (email, Telegram, Discord, etc.) to be alerted when a service goes down.


13. Integration & Automation — Connecting Everything Together

This is where the magic happens. Once all services are connected, a single movie request triggers a fully automated pipeline.

13.1 — *Arr Download Client Configuration

In Radarr (and repeat for Sonarr, Lidarr):

  1. Settings -> Download Clients -> Add -> qBittorrent
    • Host: YOUR_IP
    • Port: 8080
    • Username/Password: your qBittorrent credentials
    • Category: radarr (or sonarr, lidarr respectively)
    • Test -> Save

13.2 — Bazarr Integration

In Bazarr:

  1. Settings -> Sonarr -> Enable, enter URL + API key
  2. Settings -> Radarr -> Enable, enter URL + API key
  3. Settings -> Languages -> Add preferred subtitle languages
  4. Settings -> Providers -> Add OpenSubtitles.com

13.3 — Jellyseerr Integration

In Jellyseerr (first-time wizard):

  1. Sign in with Jellyfin -> enter URL + credentials
  2. Add Radarr server: URL + API key + quality profile + root folder
  3. Add Sonarr server: URL + API key + quality profile + root folder
  4. Now users can request movies/TV via http://YOUR_IP:5055

13.4 — The Complete Request Flow

User requests movie          Radarr searches indexers       qBittorrent downloads
via Jellyseerr (:5055)  -->  via Prowlarr (:9696)     -->  to NVMe: /downloads/torrents/
                                                                |
                                                                v
                                                           Download completes
                                                                |
                                                                v
                              Radarr detects completion,   qBittorrent seeds to
                              imports (copies) movie   <-- ratio 1.0, then auto-removes
                              to SSD: /media/movies/       torrent from list
                                   |
                                   v
                              Radarr deletes source files
                              from /downloads/torrents/
                              (removeCompletedDownloads: true)
                                   |
                                   v
                              Jellyfin detects new movie     Bazarr auto-downloads
                              in /data/movies/ library  -->  subtitles (.srt/.ass)
                                   |
                                   v
                              Tdarr (optional) transcodes
                              to H.265 using Intel QSV
                                   |
                                   v
                              Movie available for playback
                              on all devices (TV, phone, web)

13.5 — End-to-End Test

  1. Go to Jellyseerr -> Search for a movie -> Request it
  2. Check Radarr -> should show the movie as "Searching"
  3. Check qBittorrent -> should start downloading
  4. Wait for download to complete
  5. Check Jellyfin -> movie should appear in library
  6. Play the movie -> verify hardware transcoding works
  7. Check Bazarr -> subtitles should auto-download

If all steps work, your media automation pipeline is fully operational.

13.6 — Where Files Live (Filesystem Map)

NVMe (boot drive) -- Fast, temporary storage      Second Drive -- Movie library
+----------------------------------+              +------------------------------+
| /mnt/media/downloads/torrents/   |              | /mnt/ssd/movies/             |
|   +-- Movie.Name.1080p.x264/    |--(Radarr)----| +-- Inception (2010)/        |
|   |   +-- movie.mkv (temp)      |  copies &    | |   +-- Inception.mkv        |
|   +-- (auto-cleaned after 7d)   |  deletes     | +-- Interstellar (2014)/     |
|                                  |  source      | |   +-- Interstellar.mkv     |
| /mnt/media/tv/                   |              | +-- ...                      |
|   +-- Show Name/Season 01/      |              |                              |
|   +-- ...                        |              | Bind-mounted to:             |
|                                  |              | /mnt/media/movies/           |
| /mnt/media/music/                |              | (transparent to containers)  |
| /mnt/media/books/                |              +------------------------------+
+----------------------------------+

Note: When source (NVMe) and destination (SSD) are on different filesystems, Radarr uses copy instead of hardlinks. This is expected and correct.


14. Backup & Disk Protection

14.1 — Config Backup Script

Your container configurations in /opt/appdata are the most important thing to back up. Media can be re-downloaded, but configs contain your settings, databases, and user accounts.

Create /opt/docker/backup.sh:

#!/bin/bash
# Backup all container configs
BACKUP_DIR="/mnt/media/backups"
DATE=$(date +%Y%m%d)

mkdir -p $BACKUP_DIR

# Stop containers that use databases for clean backup
docker stop nextcloud-db

# Create tarball of all configs
tar -czf $BACKUP_DIR/appdata-$DATE.tar.gz -C /opt appdata

# Restart stopped containers
docker start nextcloud-db

# Keep only last 7 backups
ls -tp $BACKUP_DIR/appdata-*.tar.gz | tail -n +8 | xargs -I {} rm -- {}

echo "Backup completed: $BACKUP_DIR/appdata-$DATE.tar.gz"
chmod +x /opt/docker/backup.sh

# Test it
/opt/docker/backup.sh

# Schedule weekly (Sunday 3AM)
crontab -e
# Add this line:
# 0 3 * * 0 /opt/docker/backup.sh >> /var/log/backup.log 2>&1

14.2 — Disk Protection — Three Layers

Running a media server on limited storage (especially a single NVMe) can quickly fill the disk if downloads are not cleaned up. Here is a three-layer protection strategy:

Layer Component Action Trigger
1 qBittorrent Auto-remove torrent after seeding Ratio 1.0 / 24h max / 24h inactive
2 Radarr/Sonarr Delete source files after import removeCompletedDownloads: true
3 disk-guard.sh Clean old downloads + Docker/snap/apt Every 15 min via cron

Layer 1: qBittorrent Seeding Limits

Add to /opt/appdata/qbittorrent/qBittorrent/qBittorrent.conf under [BitTorrent]:

Session\GlobalMaxRatio=1
Session\GlobalMaxSeedingMinutes=1440
Session\GlobalMaxInactiveSeedingMinutes=1440
Session\ShareLimitAction=Remove

Layer 2: *Arr Completed Download Handling

Already configured in Radarr and Sonarr:

Layer 3: disk-guard.sh — Automated Disk Cleanup

Create /opt/docker/disk-guard.sh and schedule it via root crontab:

# Root crontab entry:
*/15 * * * * /opt/docker/disk-guard.sh
Disk Usage Action
Always Clean Docker unused images, journal logs > 7 days
>= 85% Delete torrent downloads older than 7 days, aggressive Docker prune
>= 95% Emergency: Delete ALL torrent downloads, clear snap + apt cache

To check its activity:

# View recent log entries
tail -20 /var/log/disk-guard.log

# Manual run
sudo /opt/docker/disk-guard.sh

# Check current disk usage
df -h / /mnt/ssd

14.3 — SSD Expansion (Adding a Second Drive)

When you add a second drive (SSD or HDD) for extra media storage:

# Identify the new drive
lsblk

# Create GPT partition table + single partition
sudo fdisk /dev/sda
# g (new GPT), n (new partition), 1, Enter, Enter, w (write)

# Format as ext4
sudo mkfs.ext4 -L media-ssd /dev/sda1

# Create mount point and mount
sudo mkdir -p /mnt/ssd
sudo mount /dev/sda1 /mnt/ssd

# Get UUID and add to fstab for persistent mounting
UUID=$(sudo blkid -s UUID -o value /dev/sda1)
echo "UUID=$UUID /mnt/ssd ext4 defaults 0 2" | sudo tee -a /etc/fstab

Migrate content and create a bind mount:

# Create movies directory on the new drive
sudo mkdir -p /mnt/ssd/movies
sudo chown YOUR_USER:YOUR_USER /mnt/ssd /mnt/ssd/movies

# Copy movies to the new drive
cp -av /mnt/media/movies/* /mnt/ssd/movies/

# Verify both locations match
ls /mnt/media/movies/ | wc -l
ls /mnt/ssd/movies/ | wc -l

# Remove originals from the boot drive
rm -rf /mnt/media/movies/*

# Bind mount (immediate)
sudo mount --bind /mnt/ssd/movies /mnt/media/movies

# Persist in fstab
echo "/mnt/ssd/movies /mnt/media/movies none bind 0 0" | sudo tee -a /etc/fstab

# Restart Jellyfin to pick up the bind mount
docker restart jellyfin

Now /mnt/media/movies transparently serves content from the second drive. All containers work without any Docker changes.

14.4 — Reduce ext4 Reserved Blocks

By default, ext4 reserves 5% of disk space for root. On a homelab server, 1% is sufficient:

sudo tune2fs -m 1 /dev/mapper/ubuntu--vg-ubuntu--lv

This recovers ~16GB of usable space on a 466GB drive.

14.5 — DNS Fix (Tailscale + Disk Full Edge Case)

If the disk fills up, Tailscale's MagicDNS can break and hijack /etc/resolv.conf, causing DNS resolution to fail. Fix:

sudo tailscale set --accept-dns=false
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
sudo systemctl restart systemd-resolved

15. Dashboard — Landing Page with Service Links

A simple landing page gives you one-click access to all your services. You can build a custom HTML page or use an existing dashboard tool like Homer, Homarr, or Dashy.

The dashboard should include:

Deploy a simple dashboard container on port 8888 (or whichever port you prefer) and configure it with links to all your services from the port map in Section 1.


16. Troubleshooting — Common Issues and Fixes

Container Won't Start

docker logs <container_name>
# Check for permission errors — usually fix with:
sudo chown -R 1000:1000 /opt/appdata/<container_name>

Hardware Transcoding Not Working

# Check GPU device exists
ls -la /dev/dri/
# Should show: card0 (or card1) and renderD128

# Check VA-API driver
vainfo
# Should show "iHD driver"

# Fix permissions
sudo usermod -aG render $USER
sudo usermod -aG video $USER
# Log out/in, then restart Jellyfin container
docker restart jellyfin

Laptop Overheating

# Install thermal management
sudo apt install -y thermald

# Check temperatures
sensors
# (install with: sudo apt install -y lm-sensors && sudo sensors-detect)

Cannot Access Services Remotely

# Verify Tailscale is connected
tailscale status

# Check if firewall is blocking
sudo ufw status
# If active, allow ports:
sudo ufw allow 8096/tcp    # Jellyfin
sudo ufw allow 5055/tcp    # Jellyseerr
# (add other ports as needed)

USB Drive Disconnects Randomly

# Disable USB autosuspend
echo -1 | sudo tee /sys/module/usbcore/parameters/autosuspend

# Make permanent — add to GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub:
# usbcore.autosuspend=-1
sudo update-grub
sudo reboot

Disk Full / No Internet / Services Broken

# Check disk usage
df -h / /mnt/ssd

# Run disk cleanup manually
sudo /opt/docker/disk-guard.sh

# Check what is eating space
du -sh /mnt/media/downloads/torrents/   # Torrent downloads
du -sh /var/lib/docker/                 # Docker images/layers
du -sh /var/lib/snapd/cache/            # Snap cache

# If DNS is broken (Tailscale took over resolv.conf):
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
sudo systemctl restart systemd-resolved

# Check disk-guard log
tail -20 /var/log/disk-guard.log

Quick Reference: Container Management

# See all running containers
docker ps

# See all containers (including stopped)
docker ps -a

# View container logs
docker logs <container_name>
docker logs -f <container_name>    # Follow (live)

# Restart a container
docker restart <container_name>

# Stop / Start
docker stop <container_name>
docker start <container_name>

# Remove a container (data in /opt/appdata is preserved)
docker stop <container_name> && docker rm <container_name>

# Update a container manually
docker pull <image_name>:latest
docker stop <container_name> && docker rm <container_name>
# Then re-run the original docker run command

# Check Docker disk usage
docker system df

# Clean up unused images/containers
docker system prune -a

Quick Reference: Useful Paths

Path Contents Disk
/opt/appdata/ All container configs — BACK THIS UP Boot drive
/opt/docker/ Docker compose files, .env, scripts Boot drive
/mnt/media/ All media content Boot drive
/mnt/media/downloads/ Active torrent downloads (auto-cleaned) Boot drive
/mnt/media/movies/ Radarr-managed movies (bind mount to second drive) Second drive
/mnt/media/tv/ Sonarr-managed TV shows Boot drive
/mnt/media/music/ Lidarr-managed music Boot drive
/mnt/media/books/ Books Boot drive
/mnt/media/nextcloud/ Nextcloud user data Boot drive
/mnt/ssd/ Second drive mount point Second drive
/opt/docker/disk-guard.sh Disk protection script (cron every 15 min) Boot drive
/var/log/disk-guard.log Disk guard log Boot drive

17. Conclusion

You now have a fully automated, self-hosted media server running on a repurposed laptop. Here is what the complete stack provides:

Next Steps

The entire stack runs on ~15-20W at idle, costs nothing per month, and gives you complete ownership of your media library. Every service is containerized, backed up weekly, and auto-updated -- set it and forget it.

Need expert help with your setup?

VoIP infrastructure consulting, AI voice agent integration, monitoring stacks, scaling — I've done it all in production.

Get a Free Consultation