Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 credentialsIMMICH_DB_PASSWORD— Immich PostgreSQL passwordREDIS_PASSWORD— shared cache passwordDUPLICATI_WEBUI_PASSWORD— Duplicati web UI loginDUPLICATI_SETTINGS_KEY— encrypts Duplicati's local settings databaseDUPLICATI_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 2–5 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
- Visit http://192.168.1.5:8080/admin
- Log in with
PIHOLE_PASSWORDfrom .env - Go to Settings > DNS — confirm upstream is
192.168.1.1#53(OPNsense Unbound) - Test:
dig @192.168.1.5 google.comshould resolve
VPN verification
VPN is handled by OPNsense (WireGuard to ProtonVPN), not by a container. To verify:
- SSH to the Pi:
curl -s https://ipinfo.io/ip— should show a ProtonVPN IP - Or from any device on the network: visit
whatismyip.com— should show ProtonVPN IP
qBittorrent
- Visit http://192.168.1.5:8090
- Default login: admin / adminadmin
- Change password in Settings > Web UI > Authentication
- Set download paths: /downloads (TV) and /downloads (movies)
Sonarr, Radarr, Prowlarr
- Visit Prowlarr at http://192.168.1.5:9696, add indexers
- In Prowlarr > Settings > Apps, add Sonarr and Radarr connections
- Visit Sonarr at http://192.168.1.5:8989 — link to qBittorrent (http://localhost:8090), verify Prowlarr sync
- Visit Radarr at http://192.168.1.5:7878 — same setup as Sonarr
Bazarr
- Visit http://192.168.1.5:6767
- Settings > Sonarr: add
192.168.1.5:8989+ Sonarr API key - Settings > Radarr: add
192.168.1.5:7878+ Radarr API key - Settings > Providers: add OpenSubtitles.com (free account required)
- Settings > Languages: create "English" language profile, set as default for Series and Movies
Jellyfin
- Visit http://192.168.1.5:8096
- Complete the setup wizard
- Add media libraries: TV shows at
/data/tvshows, movies at/data/movies - 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.
- See Hardware Transcoding section for details and fallback instructions
Jellyseerr
- Visit http://192.168.1.5:5055
- Choose "Sign in with Jellyfin" — point to http://192.168.1.5:8096
- Configure Sonarr (http://localhost:8989 from within arr_network) and Radarr (http://localhost:7878)
Nextcloud
- Visit https://192.168.1.5:8443 (accept the self-signed cert warning)
- Complete the setup wizard:
- Storage:
/data(already mapped to /media/nvme/nextcloud) - Database: MariaDB
- Host:
mariadb - Database:
nextcloud - User:
nextcloud - Password:
MARIADB_PASSWORDfrom .env
- Host:
- Storage:
- Install the Nextcloud mobile app and add your server
Immich
- Visit http://192.168.1.5:2283
- Create admin account
- Install the Immich mobile app (iOS/Android) and point it at http://192.168.1.5:2283
- Trigger initial library scan: Administration > Jobs > Library > Scan All Libraries
Gitea
- Visit http://192.168.1.5:3001
- Complete the installation wizard (database: SQLite3)
- Create admin account
- Add your SSH key: Settings > SSH / GPG Keys
Uptime Kuma
- Visit http://192.168.1.5:3002
- Create admin account
- Add monitors for each service — use the port table above for URLs
Scrutiny
- Visit http://192.168.1.5:8085
- The NVMe at /dev/nvme0 should appear automatically
- Confirm SMART data is being collected
Portainer
- Visit http://192.168.1.5:9000
- Set admin password on first visit (must be done within 5 minutes of container start)
- 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
- Visit http://192.168.1.5:8200
- Log in with
DUPLICATI_WEBUI_PASSWORDfrom .env - 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):
- Dashboard > Playback > Transcoding
- Hardware acceleration:
Video4Linux2 (V4L2) - Enable HEVC hardware decoding checkbox (H.264 is CPU-only on Pi 5)
- 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.
- Open Duplicati web UI at http://192.168.1.5:8200
- Click Restore > select
Pi Stack Local Backup - Browse to a small known file (e.g. a photo in
/source/photos) - Restore it to
/tmp/restore-test/ - SSH to the Pi and compare:
diff /media/nvme/photos/<filename> /tmp/restore-test/<filename> - Confirm exit code 0 (files are identical — backup is intact)
- 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/usbis just a directory (not a mounted drive), Duplicati will write backups to the NVMe root filesystem rather than the USB. Always verify withdf -h /media/usbbefore 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_serversmust be set totruein 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 |