Pi Stack — Raspberry Pi 5 Home Server

A production-ready Docker Compose stack for a Raspberry Pi 5 running Ubuntu Server 24.04 LTS (ARM64). Pi Stack combines 24 self-hosted services into a single-command home server: DNS ad-blocking via Pi-hole (upstream to OPNsense Unbound for recursive resolution), media automation via the ARR stack, media streaming via Jellyfin, personal cloud storage via Nextcloud, photo backup via Immich, reverse proxy with SSL via Nginx Proxy Manager, SSO via Pocket ID, dynamic DNS via ddns-updater (Dynu), service monitoring via Uptime Kuma and Scrutiny, container management via Portainer and Watchtower, nightly encrypted backups via Duplicati, and a unified Homepage dashboard — all managed as infrastructure-as-code. VPN routing and DNS recursion are handled at the router level by OPNsense on a Protectli V1211. All externally-accessible services are available over HTTPS via Let's Encrypt certificates.


Service Map

All services run at 192.168.1.5 (static Pi IP via OPNsense DHCP reservation). Externally-accessible services have HTTPS via Nginx Proxy Manager with Let's Encrypt certificates at *.jenrpi.ddnsfree.com. VPN and recursive DNS are handled by OPNsense (Protectli V1211), not by containers. Remote access is via OPNsense WireGuard server (not a container).

DNS

Service Port URL Purpose First-Run Action
Pi-hole 8080 http://192.168.1.5:8080/admin DNS ad-blocking Set password from .env, confirm upstream is OPNsense Unbound (192.168.1.1)

ARR

Service Port URL Purpose First-Run Action
qBittorrent 8090 http://192.168.1.5:8090 Torrent client Login admin/adminadmin, change password
Sonarr 8989 http://192.168.1.5:8989 TV download management Link to Prowlarr + qBittorrent
Radarr 7878 http://192.168.1.5:7878 Movie download management Link to Prowlarr + qBittorrent
Prowlarr 9696 http://192.168.1.5:9696 Indexer aggregation Add indexers, link to Sonarr + Radarr
Bazarr 6767 http://192.168.1.5:6767 Automatic subtitle downloads Link to Sonarr + Radarr, add OpenSubtitles provider, set English language profile

Media

Service Port URL Purpose First-Run Action
Jellyfin 8096 http://192.168.1.5:8096 Media streaming + transcoding Complete wizard, add libraries, enable V4L2 HW decode (deprecated — see notes)
Jellyseerr 5055 http://192.168.1.5:5055 Media request management Link to Jellyfin + Sonarr + Radarr

Personal Cloud

Service Port URL Purpose First-Run Action
Nextcloud 8443 https://nextcloud.jenrpi.ddnsfree.com Personal cloud storage Complete wizard: MariaDB host=mariadb, db=nextcloud, user=nextcloud
Immich 2283 https://immich.jenrpi.ddnsfree.com Photo backup Create admin account, install mobile app

Access + SSL

Service Port URL Purpose First-Run Action
Nginx Proxy Manager 80/81/443 http://192.168.1.5:81 Reverse proxy + SSL Default login admin@example.com / changeme, add proxy hosts
Pocket ID 1411 https://pocketid.jenrpi.ddnsfree.com Passkey SSO provider Register admin passkey at /setup
DDNS Updater No web UI Dynamic DNS (Dynu) Set DYNU_* vars in .env

External HTTPS Subdomains (via NPM + Let's Encrypt)

Subdomain Backend Backend Port
jellyfin.jenrpi.ddnsfree.com jellyfin 8096
nextcloud.jenrpi.ddnsfree.com nextcloud 443
immich.jenrpi.ddnsfree.com immich-server 2283
gitea.jenrpi.ddnsfree.com gitea 3000
aiostreams.jenrpi.ddnsfree.com aiostreams 3010
pocketid.jenrpi.ddnsfree.com pocket-id 1411
jellyseerr.jenrpi.ddnsfree.com jellyseerr 5055

Monitoring

Service Port URL Purpose First-Run Action
Uptime Kuma 3002 http://192.168.1.5:3002 Service uptime monitoring Create admin, add monitors for each service
Scrutiny 8085 http://192.168.1.5:8085 NVMe drive SMART health Confirm /dev/nvme0 appears in dashboard
Portainer 9000 http://192.168.1.5:9000 Docker container management Set admin password on first visit

Streaming

Service Port URL Purpose First-Run Action
AIOStreams 3010 https://aiostreams.jenrpi.ddnsfree.com TorBox Stremio addon Configure TorBox API key, enable addons, install in Stremio

Services

Service Port URL Purpose First-Run Action
Homepage 3000 http://192.168.1.5:3000 Unified dashboard Auto-configured via homepage/
Gitea 3001 http://192.168.1.5:3001 Self-hosted Git server Complete installation wizard, add SSH key
Watchtower No web UI Auto-update containers (3 AM daily) Verify via docker logs watchtower
Duplicati 8200 http://192.168.1.5:8200 Encrypted nightly backups Configure backup job (see Backup section)

Internal Services (no host ports)

Service Internal Port Purpose
MariaDB 3306 Nextcloud database
Redis 6379 Shared cache (Nextcloud + Immich)
Immich PostgreSQL 5432 Immich photo database (pgvector/VectorChord)

Hardware Requirements

  • Raspberry Pi 5 — 4GB RAM minimum, 8GB recommended
  • NVMe SSD mounted at /media/nvme — media, photos, Nextcloud data (256GB+ recommended)
  • USB drive mounted at /media/usb — Duplicati backup destination (match or exceed NVMe size)
  • Ethernet — wired connection recommended (WiFi adds latency to media streaming)
  • Static IP — set Pi IP to 192.168.1.5 in OPNsense DHCP reservation (MAC: 88:A2:9E:09:D4:F1)

First-Run Setup

Step 1: Clone the repository

git clone <repo-url> ~/pi-stack
cd ~/pi-stack

Step 2: Copy .env template

cp .env.example .env

Step 3: Fill credentials in .env

Open .env in a text editor and replace every CHANGE_ME value:

nano .env

Critical values to fill:

  • PIHOLE_PASSWORD — Pi-hole web UI login
  • ProtonVPN WireGuard credentials are configured in OPNsense, not in .env
  • MARIADB_ROOT_PASSWORD, MARIADB_PASSWORD — Nextcloud database credentials
  • IMMICH_DB_PASSWORD — Immich PostgreSQL password
  • REDIS_PASSWORD — shared cache password
  • DUPLICATI_WEBUI_PASSWORD — Duplicati web UI login
  • DUPLICATI_SETTINGS_KEY — encrypts Duplicati's local settings database
  • DUPLICATI_PASSPHRASE — encrypts backup archives

Step 4: Mount USB backup drive

Before starting the stack, mount your USB drive so Duplicati writes to it rather than the NVMe:

# Identify your USB device
lsblk

# Mount it (replace /dev/sda1 with your device)
sudo mount /dev/sda1 /media/usb

# Add to /etc/fstab for persistence across reboots
echo "/dev/sda1 /media/usb ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab

Verify it is a real mount (not just a directory):

df -h /media/usb
# Should show /dev/sda1 (or similar), NOT rootfs

Step 5: Run setup.sh

setup.sh performs host provisioning in one pass: disables the systemd-resolved stub listener (frees port 53 for Pi-hole), installs Docker Engine, installs V4L2 utilities for Jellyfin hardware decoding (deprecated but still functional — see Hardware Transcoding section), creates the /media/nvme directory structure, and initializes the git repository.

chmod +x setup.sh
sudo ./setup.sh

Step 6: Start the stack

docker compose up -d

Watch services come up:

docker compose ps

All healthy services will show (healthy) after their start_period completes. This takes 25 minutes on first pull.

Step 7: Per-service first-run setup

Complete these in order — some services depend on others being configured first.

Pi-hole

  1. Visit http://192.168.1.5:8080/admin
  2. Log in with PIHOLE_PASSWORD from .env
  3. Go to Settings > DNS — confirm upstream is 192.168.1.1#53 (OPNsense Unbound)
  4. Test: dig @192.168.1.5 google.com should resolve

VPN verification

VPN is handled by OPNsense (WireGuard to ProtonVPN), not by a container. To verify:

  1. SSH to the Pi: curl -s https://ipinfo.io/ip — should show a ProtonVPN IP
  2. Or from any device on the network: visit whatismyip.com — should show ProtonVPN IP

qBittorrent

  1. Visit http://192.168.1.5:8090
  2. Default login: admin / adminadmin
  3. Change password in Settings > Web UI > Authentication
  4. Set download paths: /downloads (TV) and /downloads (movies)

Sonarr, Radarr, Prowlarr

  1. Visit Prowlarr at http://192.168.1.5:9696, add indexers
  2. In Prowlarr > Settings > Apps, add Sonarr and Radarr connections
  3. Visit Sonarr at http://192.168.1.5:8989 — link to qBittorrent (http://localhost:8090), verify Prowlarr sync
  4. Visit Radarr at http://192.168.1.5:7878 — same setup as Sonarr

Bazarr

  1. Visit http://192.168.1.5:6767
  2. Settings > Sonarr: add 192.168.1.5:8989 + Sonarr API key
  3. Settings > Radarr: add 192.168.1.5:7878 + Radarr API key
  4. Settings > Providers: add OpenSubtitles.com (free account required)
  5. Settings > Languages: create "English" language profile, set as default for Series and Movies

Jellyfin

  1. Visit http://192.168.1.5:8096
  2. Complete the setup wizard
  3. Add media libraries: TV shows at /data/tvshows, movies at /data/movies
  4. Enable hardware decoding: Dashboard > Playback > Transcoding > Hardware acceleration: Video4Linux2 (V4L2) — this is deprecated in Jellyfin but still works as of 2026. If a future Jellyfin update removes V4L2, set this to "None" instead.
  5. See Hardware Transcoding section for details and fallback instructions

Jellyseerr

  1. Visit http://192.168.1.5:5055
  2. Choose "Sign in with Jellyfin" — point to http://192.168.1.5:8096
  3. Configure Sonarr (http://localhost:8989 from within arr_network) and Radarr (http://localhost:7878)

Nextcloud

  1. Visit https://192.168.1.5:8443 (accept the self-signed cert warning)
  2. Complete the setup wizard:
    • Storage: /data (already mapped to /media/nvme/nextcloud)
    • Database: MariaDB
      • Host: mariadb
      • Database: nextcloud
      • User: nextcloud
      • Password: MARIADB_PASSWORD from .env
  3. Install the Nextcloud mobile app and add your server

Immich

  1. Visit http://192.168.1.5:2283
  2. Create admin account
  3. Install the Immich mobile app (iOS/Android) and point it at http://192.168.1.5:2283
  4. Trigger initial library scan: Administration > Jobs > Library > Scan All Libraries

Gitea

  1. Visit http://192.168.1.5:3001
  2. Complete the installation wizard (database: SQLite3)
  3. Create admin account
  4. Add your SSH key: Settings > SSH / GPG Keys

Uptime Kuma

  1. Visit http://192.168.1.5:3002
  2. Create admin account
  3. Add monitors for each service — use the port table above for URLs

Scrutiny

  1. Visit http://192.168.1.5:8085
  2. The NVMe at /dev/nvme0 should appear automatically
  3. Confirm SMART data is being collected

Portainer

  1. Visit http://192.168.1.5:9000
  2. Set admin password on first visit (must be done within 5 minutes of container start)
  3. Connect to the local Docker environment

Homepage

Homepage is auto-configured via homepage/services.yaml — all active services appear with live Docker status on first visit at http://192.168.1.5:3000. No setup required.

Duplicati

  1. Visit http://192.168.1.5:8200
  2. Log in with DUPLICATI_WEBUI_PASSWORD from .env
  3. Create a new backup job with these settings:
Setting Value
Backup name Pi Stack Local Backup
Encryption AES-256-CBC with DUPLICATI_PASSPHRASE from .env
Source paths /source/photos and /source/nextcloud
Destination Local folder: /backup
Schedule 0 2 * * * (2 AM daily)
Keep backups 7 versions

Note: /source maps to the Pi's /media/nvme (read-only). /backup maps to /media/usb. Named Docker volumes (Gitea repos, Pi-hole config) are not included — see Known Limitations.

Watchtower

Watchtower runs automatically at 3 AM daily. No setup required.

Verify it is working:

docker logs watchtower

Architecture

+------------------------------------------------------------------+
|  OPNsense (Protectli V1211) — Router                             |
|  WireGuard -> ProtonVPN (all traffic, kill switch enabled)       |
|  WireGuard server (HomeVPN, port 51821) for remote access        |
|  Unbound (recursive DNS -> root servers, zero third-party DNS)   |
|  NAT port forward: 80/443 -> 192.168.1.5 (NPM)                 |
+------------------------------------------------------------------+
         |
         | 192.168.1.1 (gateway + recursive DNS)
         |
+------------------------------------------------------------------+
|  Raspberry Pi 5 - Pi Stack (192.168.1.5)                         |
|                                                                  |
|  +------------------+    +-----------------------------+         |
|  | dns_network      |    | arr_network                 |         |
|  |                  |    |                             |         |
|  | Pi-hole          |    | qBittorrent (8090)          |         |
|  | (port 53)        |    | Sonarr      (8989)          |         |
|  +------------------+    | Radarr      (7878)          |         |
|          ^               | Prowlarr    (9696)          |         |
|          |               | Bazarr      (6767)          |         |
|          | dns: 192.168.1.5 (all containers)            |         |
|  +-------+---------------------------------------------+         |
|  | app_network                                         |         |
|  |                                                     |         |
|  | Jellyfin    Jellyseerr   Nextcloud   Immich         |         |
|  | MariaDB     Redis        Immich-Postgres            |         |
|  | Uptime Kuma Scrutiny     Portainer   Homepage       |         |
|  | Gitea       Duplicati    Watchtower  AIOStreams      |         |
|  | NPM         Pocket ID    ddns-updater               |         |
|  +-----------------------------------------------------+         |
+------------------------------------------------------------------+

External access (HTTPS via NPM + Let's Encrypt):
  *.jenrpi.ddnsfree.com -> OPNsense:443 -> NPM -> container
  Split DNS: Pi-hole resolves subdomains to 192.168.1.5 on LAN

DNS chain:
  All devices -> Pi-hole (192.168.1.5) -> OPNsense Unbound (192.168.1.1) -> Root servers
  Zero third-party DNS involvement (no Quad9, no Cloudflare)

VPN:
  OPNsense routes ALL network traffic through ProtonVPN WireGuard
  Kill switch: "Skip rules when gateway is down" — blocks internet if VPN drops
  No per-container VPN needed — all traffic is VPN'd at the router

Remote access:
  OPNsense WireGuard server (HomeVPN) on port 51821
  Full tunnel mode — remote clients route all traffic through home network

Hardware Transcoding

Deprecation warning: V4L2 is deprecated in Jellyfin upstream (as of late 2025). It still works as of March 2026, but a future Jellyfin update may remove it. If V4L2 stops working after an update, set Hardware Acceleration to "None" — Jellyfin will fall back to full software transcoding on the Pi 5's quad-core Cortex-A76, which is adequate for 1-2 concurrent streams.

Jellyfin uses V4L2 hardware-accelerated video decoding on the Pi 5. Encoding is always software.

What works today:

  • Hardware decode: H.265 (HEVC) only via /dev/video19 (Pi 5 VideoCore VII — device number varies by kernel/firmware). H.264 is software-decoded by the CPU.
  • Software encode: ARM Cortex-A76 CPU handles output (no hardware encoder on Pi 5)

What does not work:

  • Hardware encode — no VPU encoder on Pi 5; Jellyfin falls back to software x264/x265

Setup in Jellyfin (recommended while V4L2 still works):

  1. Dashboard > Playback > Transcoding
  2. Hardware acceleration: Video4Linux2 (V4L2)
  3. Enable HEVC hardware decoding checkbox (H.264 is CPU-only on Pi 5)
  4. Save and restart Jellyfin

Fallback if V4L2 is removed: Set Hardware acceleration to None. Everything will still work — transcoding will just use more CPU.

Device mapping: The compose file maps /dev/video19. Device numbers can change after kernel updates — check that your V4L2 decode device still exists:

ls /dev/video*
v4l2-ctl --list-devices

Common Commands

# Start all services
docker compose up -d

# Stop all services
docker compose down

# View live logs for a service
docker compose logs -f <service>

# Restart a single service
docker compose restart <service>

# Check service health and uptime
docker compose ps

# Pull latest images without restarting (preview)
docker compose pull

# Update all services (pull + recreate)
docker compose up -d --pull always

# Force Watchtower to run an update check now (dry run)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  nickfedor/watchtower --run-once --dry-run

# View Watchtower update log
docker logs watchtower

# View Duplicati backup log
docker logs duplicati

# Check which containers Watchtower is monitoring
docker ps --format "table {{.Names}}\t{{.Labels}}" | grep watchtower

Backup

What is backed up

Data Host Path Container Source Path
Immich photos /media/nvme/photos /source/photos
Nextcloud data /media/nvme/nextcloud /source/nextcloud

Backups are encrypted with DUPLICATI_PASSPHRASE from .env and written to /media/usb (USB drive).

Schedule and retention

  • Schedule: 2 AM nightly (configured in Duplicati web UI)
  • Retention: 7 daily versions
  • Encryption: AES-256-CBC

Named volume limitation

Gitea repositories and Pi-hole config live in Docker named volumes at /var/lib/docker/volumes/ on the host. These are NOT visible to Duplicati via the /media/nvme:/source:ro bind mount and are therefore not included in the backup.

To back up named volumes in the future, a pre-backup script using docker exec to dump data to /media/nvme would be needed, then Duplicati would pick them up. This is a known limitation — treat Gitea and Pi-hole as re-configurable rather than backup-restorable for now.


Restore-Test Procedure

Run this procedure monthly to confirm backups are usable before you need them.

  1. Open Duplicati web UI at http://192.168.1.5:8200
  2. Click Restore > select Pi Stack Local Backup
  3. Browse to a small known file (e.g. a photo in /source/photos)
  4. Restore it to /tmp/restore-test/
  5. SSH to the Pi and compare:
    diff /media/nvme/photos/<filename> /tmp/restore-test/<filename>
    
  6. Confirm exit code 0 (files are identical — backup is intact)
  7. Clean up:
    sudo rm -rf /tmp/restore-test
    

Watchtower Auto-Updates

Watchtower runs at 3 AM daily and automatically updates container images for all services not excluded by label.

Excluded containers (will NOT auto-update)

Only two containers are excluded from Watchtower — everything else auto-updates:

Container Reason
immich-postgres Pinned to 14-vectorchord0.4.3; other versions cause jemalloc crash on Pi 5
watchtower Self-excluded — cannot update itself while running

All other containers (including redis, mariadb, nextcloud, immich-server) auto-update safely. Data lives in Docker volumes, so a bad update only causes temporary downtime — rollback the image tag and run docker compose up -d.

Exclusion is enforced via Docker label com.centurylinklabs.watchtower.enable: "false" on each container.

Verify exclusions are working

Run a dry-run to see what Watchtower would update:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  nickfedor/watchtower --run-once --dry-run

Containers with the exclusion label should not appear in the update list.


Known Limitations

  • Named volume backup not included: Gitea repos and Pi-hole config live in Docker named volumes, not on the NVMe bind mount. Duplicati only sees /media/nvme. Backup gap is documented above.

  • Immich ML inference is CPU-only: No GPU or NPU on the Pi 5. Smart search and face recognition will be slow for large photo libraries. Consider scheduling ML jobs (Administration > Jobs) off-peak or disabling facial recognition entirely.

  • V4L2 hardware transcoding is deprecated (HEVC decode-only): The Pi 5 has no hardware video encoder and only supports HEVC (H.265) hardware decode — H.264 is decoded in software by the CPU. Jellyfin uses V4L2 for HEVC decoding but falls back to software encoding (x264/x265) for transcoding output. V4L2 is deprecated in Jellyfin as of late 2025 — it still works today but may be removed in a future release. If that happens, set Hardware Acceleration to "None" and rely on software transcoding.

  • USB drive must be mounted before docker compose up: If /media/usb is just a directory (not a mounted drive), Duplicati will write backups to the NVMe root filesystem rather than the USB. Always verify with df -h /media/usb before starting the stack.

  • Nextcloud OIDC requires allow_local_remote_servers: Pocket ID SSO resolves to a local IP via split DNS. Nextcloud's allow_local_remote_servers must be set to true in config.php for OIDC login to work.


Port Reference

All 24 active services on distinct non-conflicting ports:

Port Protocol Service
53 TCP+UDP Pi-hole DNS
80 TCP Nginx Proxy Manager HTTP
81 TCP Nginx Proxy Manager Web UI
443 TCP Nginx Proxy Manager HTTPS
1411 TCP Pocket ID SSO
2222 TCP Gitea SSH
2283 TCP Immich Web UI
3000 TCP Homepage
3001 TCP Gitea HTTP
3002 TCP Uptime Kuma
3010 TCP AIOStreams (TorBox Stremio addon)
3306 TCP MariaDB (internal only)
5055 TCP Jellyseerr
6379 TCP Redis (internal only)
6767 TCP Bazarr
7878 TCP Radarr
8080 TCP Pi-hole Web UI
8085 TCP Scrutiny
8090 TCP qBittorrent Web UI
8096 TCP Jellyfin
8200 TCP Duplicati
8388 TCP+UDP qBittorrent BitTorrent port
8443 TCP Nextcloud (HTTPS)
8989 TCP Sonarr
9000 TCP Portainer
9696 TCP Prowlarr
Description
No description provided
Readme 1,004 KiB
Languages
Shell 99.5%
CSS 0.5%