A complete, self-hosted home server stack running on a Raspberry Pi 5 — media automation, private cloud, photo backup, smart home, SSO, and full monitoring. Everything containerized with Docker Compose.
- Architecture
- Service Overview
- Hardware & Storage
- Quick Start
- Service Setup Guides (Web UI)
- Security Notes
The diagram below follows the C4 container-level style (rendered with Mermaid, GitHub renders it natively). It shows how traffic flows from the internet and your LAN through the proxy layer into the individual services.
flowchart TB
subgraph INTERNET["🌍 Internet"]
USER["External User"]
CF["☁️ Cloudflare<br/>(DNS + Tunnel Edge)"]
TSUSER["Own Device on the go<br/>(Phone / Laptop)"]
TSNET["🔗 Tailscale<br/>(Coordination + DERP)"]
end
subgraph LAN["🏠 Home Network"]
CLIENT["LAN Client<br/>(Phone / PC / TV)"]
end
subgraph PI["🍓 Raspberry Pi 5 — Docker Host"]
subgraph EDGE["Edge / Ingress"]
CFD["cloudflared<br/>Tunnel Connector"]
NPM["Nginx Proxy Manager<br/>:80 :443 — Reverse Proxy + SSL"]
PIHOLE["Pi-hole<br/>:53 — DNS + Ad Blocking"]
TS["Tailscale (native)<br/>Subnet Router → LAN"]
end
subgraph AUTH["Identity"]
AUTHENTIK["Authentik :9001<br/>SSO / OAuth2 / 2FA"]
end
subgraph MEDIA["Media Stack"]
GLUETUN["Gluetun<br/>WireGuard VPN (ProtonVPN)"]
QBIT["qBittorrent :8090<br/>(runs INSIDE VPN)"]
PROWLARR["Prowlarr :9696<br/>Indexer Manager"]
SONARR["Sonarr :8989<br/>TV Shows"]
RADARR["Radarr :7878<br/>Movies"]
JELLYFIN["Jellyfin :8096<br/>Media Server"]
JELLYSEERR["Jellyseerr :5055<br/>Requests"]
FLARE["FlareSolverr :8191"]
end
subgraph APPS["Applications"]
NEXTCLOUD["Nextcloud AIO<br/>Private Cloud"]
IMMICH["Immich :2283<br/>Photo Backup + ML"]
HA["Home Assistant<br/>(host network)"]
N8N["n8n :5678<br/>Automation"]
end
subgraph MON["Monitoring"]
PROM["Prometheus :9090"]
GRAFANA["Grafana :3000"]
ALERT["Alertmanager :9093"]
NODEEXP["Node Exporter :9100"]
CADVISOR["cAdvisor :8082"]
end
subgraph MGMT["Management"]
PORTAINER["Portainer :9000"]
WATCHTOWER["Watchtower<br/>Auto Updates"]
DASHY["Dashy :4000<br/>Dashboard"]
end
end
TG["📨 Telegram"]
VPNPROVIDER["🔒 ProtonVPN"]
USER -->|HTTPS| CF
CF -->|"Encrypted Tunnel<br/>(no open ports!)"| CFD
CFD --> NPM
TSUSER -->|WireGuard| TSNET
TSNET -->|"Encrypted Mesh<br/>(no open ports!)"| TS
TS -.->|"full LAN access<br/>incl. admin UIs"| NPM
CLIENT -->|DNS Queries| PIHOLE
CLIENT -->|HTTPS via local DNS| NPM
NPM --> AUTHENTIK
NPM --> JELLYFIN
NPM --> JELLYSEERR
NPM --> NEXTCLOUD
NPM --> IMMICH
NPM --> HA
NPM --> N8N
NPM --> GRAFANA
NPM --> PORTAINER
NPM --> DASHY
AUTHENTIK -.->|OAuth2 / Proxy Auth| GRAFANA
AUTHENTIK -.->|OAuth2 / Proxy Auth| NPM
QBIT ===|all traffic forced through| GLUETUN
GLUETUN ===|WireGuard| VPNPROVIDER
PROWLARR --> FLARE
PROWLARR --> SONARR
PROWLARR --> RADARR
SONARR --> QBIT
RADARR --> QBIT
JELLYSEERR --> SONARR
JELLYSEERR --> RADARR
JELLYSEERR --> JELLYFIN
PROM --> NODEEXP
PROM --> CADVISOR
PROM --> ALERT
GRAFANA --> PROM
ALERT -->|Alerts| TG
style GLUETUN fill:#7c3aed,color:#fff
style QBIT fill:#7c3aed,color:#fff
style CF fill:#f48120,color:#fff
style PIHOLE fill:#96060c,color:#fff
style AUTHENTIK fill:#fd4b2d,color:#fff
style TS fill:#1f1f1f,color:#fff
style TSNET fill:#1f1f1f,color:#fff
Key design decisions:
- 🔐 Zero open ports — external access goes exclusively through a Cloudflare Tunnel (public services) and Tailscale (private admin access). The router has no port forwarding at all.
- 🧭 Two access tiers — public apps (Jellyfin, Nextcloud, …) via Cloudflare Tunnel; admin UIs (Portainer, NPM, Prometheus, …) only via LAN or Tailscale.
- 🛡 VPN-isolated torrenting — qBittorrent shares Gluetun's network namespace (
network_mode: service:gluetun). If the VPN drops, qBittorrent has no internet access (built-in kill switch). - 🔑 Single Sign-On — Authentik provides OAuth2/OIDC for supported apps (e.g. Grafana) and 2FA in front of everything else.
- 📊 Full observability — every container and the host itself is scraped by Prometheus; alerts land in Telegram within minutes.
| Service | Purpose | Port (internal) | Web UI |
|---|---|---|---|
| Nginx Proxy Manager | Reverse proxy + Let's Encrypt SSL | 80 / 443 / 81 | http://<pi-ip>:81 |
| Pi-hole | Network-wide DNS ad blocking | 53 / 8081 | http://<pi-ip>:8081/admin |
| Cloudflared | Secure tunnel for external access | — | (Cloudflare Dashboard) |
| Tailscale | Mesh VPN + LAN subnet router (native, no Docker) | — | (Tailscale Admin Console) |
| Authentik | SSO, OAuth2, 2FA | 9001 | http://<pi-ip>:9001 |
| Portainer | Docker management UI | 9000 / 9443 | http://<pi-ip>:9000 |
| Jellyfin | Media streaming server | 8096 | http://<pi-ip>:8096 |
| Jellyseerr | Media request management | 5055 | http://<pi-ip>:5055 |
| Sonarr | TV show automation | 8989 | http://<pi-ip>:8989 |
| Radarr | Movie automation | 7878 | http://<pi-ip>:7878 |
| Prowlarr | Indexer management | 9696 | http://<pi-ip>:9696 |
| qBittorrent | Download client (via VPN) | 8090 | http://<pi-ip>:8090 |
| FlareSolverr | Cloudflare challenge solver | 8191 | (API only) |
| Nextcloud AIO | Private cloud (files, calendar, office) | 8080 (AIO) | https://<pi-ip>:8080 |
| Immich | Photo/video backup with ML search | 2283 | http://<pi-ip>:2283 |
| Home Assistant | Smart home hub | 8123 (host) | http://<pi-ip>:8123 |
| n8n | Workflow automation | 5678 | http://<pi-ip>:5678 |
| Grafana | Metric dashboards | 3000 | http://<pi-ip>:3000 |
| Prometheus | Metrics collection | 9090 | http://<pi-ip>:9090 |
| Alertmanager | Alert routing → Telegram | 9093 | http://<pi-ip>:9093 |
| Dashy | Start page / service dashboard | 4000 | http://<pi-ip>:4000 |
| Watchtower | Automatic container updates | — | — |
Raspberry Pi 5
├── /mnt/ssd/ → SSD: Docker configs, databases, app state (fast, many small writes)
└── /mnt/hdd/ → HDD: media library, downloads, Nextcloud files (bulk storage)
| Path | Used for |
|---|---|
/mnt/ssd/docker/<service>/ |
Compose files + persistent config per service |
/mnt/hdd/media/movies |
Movie library (Radarr → Jellyfin) |
/mnt/hdd/media/tv |
TV library (Sonarr → Jellyfin) |
/mnt/hdd/media/downloads |
qBittorrent download target |
/mnt/hdd/nextcloud-external |
Nextcloud external storage |
/opt/immich/library |
Immich photo originals |
💡 Why split SSD/HDD? Databases (Postgres for Authentik, Immich, n8n) hate slow random I/O — they live on the SSD. Big sequential media files don't care — they live on the cheap HDD.
# 1. Clone
git clone https://github.com/jankln/HomeLabSetup.git
cd HomeLabSetup
# 2. For every service you want: create your .env from the example
cd <service>
cp .env.example .env
nano .env # fill in YOUR passwords/keys — see comments inside
# 3. Start it
docker compose up -d
# 4. Check it's running
docker compose psRecommended startup order (later services depend on earlier ones):
npm→ 2.pihole→ 3.cloudflared→ 4.authentik→ 5.portainer→ 6. everything else
Generate strong secrets with:
openssl rand -base64 36 # passwords / secret keys
openssl rand -hex 32 # n8n encryption keyThe compose files get the containers running — but most services need a few clicks in their web UI to actually work together. This is the part most guides skip. Here it is.
First login: http://<pi-ip>:81 → default credentials admin@example.com / changeme → you're forced to change them immediately.
For every service you want under a nice domain:
- Hosts → Proxy Hosts → Add Proxy Host
- Domain Names: e.g.
jellyfin.yourdomain.com - Scheme:
http, Forward Hostname:<pi-ip>(or the container name if on the same Docker network), Forward Port: e.g.8096 - Enable Block Common Exploits and Websockets Support (needed for Jellyfin, Home Assistant, n8n!)
- Tab SSL → Request a new SSL Certificate → enable Force SSL + HTTP/2
⚠️ For Home Assistant you must also adduse_x_frame_options/ trusted proxy config in itsconfiguration.yaml, otherwise logins fail behind a proxy.
First login: http://<pi-ip>:8081/admin with the password from your .env.
- Settings → DNS → choose upstream servers (e.g. Cloudflare
1.1.1.1or Quad99.9.9.9) - Local DNS → DNS Records → add an A-record per subdomain:
jellyfin.yourdomain.com → <pi-ip>. This makes your domains resolve locally so LAN traffic never leaves the house. - Adlists → add block lists (e.g. the defaults + firebog.net "ticked" lists) → then Tools → Update Gravity
- In your router: set the Pi's IP as the only DHCP DNS server — now every device in the house is ad-filtered automatically.
All configuration happens in the Cloudflare Zero Trust Dashboard (the container just needs the token):
- one.dash.cloudflare.com → Networks → Tunnels → Create a tunnel → type Cloudflared
- Copy the token into
cloudflared/.env→docker compose up -d - The tunnel shows HEALTHY once connected
- Public Hostname tab → add one entry per service:
- Subdomain:
jellyfin, Domain:yourdomain.com - Service:
http://<pi-ip>:8096— or point everything at NPM (https://<pi-ip>:443) and let NPM route it
- Subdomain:
- Optional but recommended: Access → Applications → put a Cloudflare Access policy (email OTP / SSO) in front of admin UIs.
🔒 This is why the router needs zero open ports: cloudflared dials out to Cloudflare and keeps the connection alive.
First setup: go to http://<pi-ip>:9001/if/flow/initial-setup/ ← note the special URL! Set the akadmin password there.
Connect an app via OAuth2 (example: Grafana):
- Applications → Providers → Create → OAuth2/OpenID Provider
- Redirect URI:
https://monitor.yourdomain.com/login/generic_oauth - Note the Client ID and Client Secret
- Redirect URI:
- Applications → Applications → Create → name it
Grafana, select the provider - Put Client ID/Secret into
monitoring/.env→ restart Grafana - Login page now shows "Sign in with Authentik" ✨
Enforce 2FA: Flows & Stages → Flows → edit the authentication flow → add a TOTP/WebAuthn stage. Every user is prompted to enroll on next login.
http://<pi-ip>:9000→ create the admin account within 5 minutes of first start (it locks itself otherwise — justdocker restart portainerif that happens)- Choose Get Started → manages the local Docker socket
- You now see all stacks, containers, logs, and consoles in one UI.
This is a chain — configure it in this order:
a) qBittorrent (http://<pi-ip>:8090)
- First password: check the container log →
docker logs qbittorrentshows a temporary password - Settings → WebUI → set your own password
- Settings → Downloads → Default Save Path:
/downloads - Verify the VPN works:
docker exec gluetun wget -qO- ifconfig.me→ must show a ProtonVPN IP, not yours!
b) Prowlarr (http://<pi-ip>:9696) — the indexer hub
- Indexers → Add Indexer → add your torrent indexers
- For Cloudflare-protected indexers: Settings → Indexers → Add FlareSolverr →
http://<pi-ip>:8191 - Settings → Apps → add Sonarr (
http://<pi-ip>:8989+ its API key) and Radarr (http://<pi-ip>:7878+ API key) → Prowlarr now syncs all indexers to both automatically
c) Sonarr (http://<pi-ip>:8989) & Radarr (http://<pi-ip>:7878)
- API key is under Settings → General
- Settings → Download Clients → Add → qBittorrent → host
<pi-ip>, port8090 - Settings → Media Management → Root folder:
/tv(Sonarr) //movies(Radarr) - Enable Rename Episodes/Movies for a clean library
d) Jellyfin (http://<pi-ip>:8096)
- Setup wizard → create admin → Add Media Library: type Shows →
/tv, type Movies →/movies - Dashboard → Playback → enable hardware transcoding if available
e) Jellyseerr (http://<pi-ip>:5055)
- Sign in with your Jellyfin account → import Jellyfin libraries
- Connect Sonarr + Radarr (URL + API key, set quality profile + root folder)
- Done: users request a movie in Jellyseerr → Radarr grabs it → qBittorrent downloads through the VPN → it appears in Jellyfin. Fully automatic. 🎬
Grafana (http://<pi-ip>:3000, admin password from .env):
- The Prometheus datasource is auto-provisioned (see
monitoring/provisioning/) — nothing to do - Dashboards → Import → these IDs cover everything:
1860— Node Exporter Full (host: CPU, RAM, disk, temperature)14282— cAdvisor (per-container resources)
- Optional: hook up Authentik SSO (see section 4)
Alertmanager → Telegram:
- Create a bot: message @BotFather →
/newbot→ copy the token - Get your chat ID: message @userinfobot
- Put both into
monitoring/alertmanager/alertmanager.yml→docker compose restart alertmanager - Test:
docker stop jellyfin→ within ~2 minutes Telegram pings you. (docker start jellyfin😉)
Included alert rules: host down, CPU > 90 %, RAM > 90 %, disk > 85 %/95 %, Pi temperature > 75 °C/82 °C, restart loops, OOM kills, and a missing-container alert for every important service.
http://<pi-ip>:2283→ create the admin account- Install the Immich mobile app (iOS/Android) → server URL:
https://photos.yourdomain.com→ enable auto backup - Administration → Settings → Machine Learning — smart search & face recognition work out of the box (the ML container handles it; first indexing run takes a while on a Pi)
- Map external libraries if you have existing photo folders.
- Open
https://<pi-ip>:8080(the AIO master container) - It shows a one-time passphrase → save it — it's your AIO admin login
- Enter your domain (
cloud.yourdomain.com) — AIO validates it must already resolve to the server (via NPM/Cloudflared) - Select optional components (Collabora office, whiteboard, …) → Start containers
- AIO downloads, configures, and health-checks the whole Nextcloud suite automatically, including built-in backups (set a backup target in the AIO UI!)
https://n8n.yourdomain.com→ create owner account- Workflows talk to anything: webhooks, Telegram, the *arr APIs, Home Assistant, …
⚠️ TheN8N_ENCRYPTION_KEYin.envencrypts stored credentials — lose it and all saved credentials are unrecoverable. Back it up.
http://<pi-ip>:8123→ onboarding wizard (account, location, units)- Settings → Devices & Services — integrations on your network are auto-discovered
- For access through NPM, add to
configuration.yaml:
http:
use_x_forwarded_for: true
trusted_proxies:
- 172.16.0.0/12 # Docker network rangeThe config lives in dashy/conf.yml — edit it, replace yourdomain.com with your domain, restart the container. Built-in status checks ping every service and show a green/red dot. Set it as your browser start page. 🏁
The only service that runs natively on the host instead of Docker — as a subnet router it needs the host network stack directly. Full guide in tailscale/README.md; the short version:
# Install + enable IP forwarding, then:
sudo tailscale up --advertise-routes=192.168.178.0/24 # your LAN subnet- Authenticate via the printed login URL (no auth key ever touches the disk)
- Admin console → the Pi → Edit route settings → approve the subnet route → disable key expiry
- Install the app on phone/laptop → you can now reach every LAN device from anywhere — this is the only way the admin UIs (Portainer, NPM, Prometheus, …) are reachable from outside
- Bonus: add the Pi's Tailscale IP as DNS server in the admin console → Pi-hole ad blocking everywhere you go
- ✅ No secrets in this repo — every credential lives in a gitignored
.env; each service ships a documented.env.example - ✅ No open router ports — public traffic via Cloudflare Tunnel, private/admin traffic via Tailscale; both connect outbound only
- ✅ Admin UIs never public — Portainer, NPM, Prometheus & co. are reachable exclusively over LAN or Tailscale
- ✅ Kill-switch torrenting — qBittorrent can physically only reach the internet through the VPN container
- ✅ SSO + 2FA via Authentik in front of sensitive UIs
- ✅ Alerting — if anything dies, Telegram knows before you do
⚠️ Keep host SSH key-based and disable password auth:PasswordAuthentication noin/etc/ssh/sshd_config⚠️ Watchtower auto-updates all images — convenient, but pin versions for critical services if you prefer stability over freshness
MIT — do whatever you want with it. If it helps you build your own lab, even better. 🚀