--- title: "Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin" date: 2026-02-02 tags: ["homelab","nginx","docker","nextcloud","jellyfin","cloudflare","tailscale","security","raspberrypi"] description: "Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable." draft: false --- > I didn’t “move Nextcloud to the cloud”. > I moved the **front door** to a VPS… and kept the house on my Raspberry Pi. 😄 This post documents the setup I ended up with: - A public **VPS** (host: `funbox`) running **Nginx** + **Let’s Encrypt** - A private **Raspberry Pi** (host: `iot-hub`) running Docker services (**Nextcloud**, **Jellyfin**, …) - A private backhaul using **Tailscale** (the `100.x.y.z` network) - A correct Nextcloud reverse-proxy configuration (**trusted_domains**, **trusted_proxies**, and overwrite values) - A pragmatic security layer for media: **Basic Auth at Nginx for Jellyfin** (in addition to Jellyfin’s own login) I’m writing this as a “future me” note and a “copy-paste friendly” guide. --- ## 0) Topology The request path looks like: ``` Browser ↓ HTTPS (public) Cloudflare DNS (optional proxy on/off) ↓ VPS (funbox) — Nginx reverse proxy + Let's Encrypt ↓ HTTP over Tailscale (private 100.x network) Raspberry Pi (iot-hub) — Docker: Nextcloud / Jellyfin / … ``` Why I like it: - The Pi can sit behind home router / CGNAT and still be reachable. - TLS, redirects, headers, auth, rate limits… all centralized on the VPS. - Internal IPs can change without breaking public URLs. --- ## 1) What’s running on the Pi? On `iot-hub` the “interesting” containers are: - `nextcloud` (Apache variant) - `nextcloud-db` (Postgres) - `nextcloud-redis` - `jellyfin` Example `docker ps` style output (yours may vary): ``` nextcloud nextcloud:apache 0.0.0.0:8080->80/tcp jellyfin jellyfin/jellyfin 0.0.0.0:8096->8096/tcp ``` So on the Pi, the services listen on: - `http://:8080` for Nextcloud - `http://:8096` for Jellyfin But in my setup, **the VPS does not reach those via LAN** — it reaches them via Tailscale IPs. --- ## 2) Tailscale: the private wire between VPS and Pi Tailscale assigns each node an address like `100.xx.yy.zz`. In my config, Nginx on `funbox` points to the Pi via Tailscale: - Nextcloud upstream: `http://100.104.127.96:8080` - Jellyfin upstream: `http://100.104.127.96:8096` (Use the Tailscale IP of your Pi.) Quick sanity checks from the VPS: ```bash # from funbox, make sure you can reach the Pi service: curl -I http://100.104.127.96:8080 curl -I http://100.104.127.96:8096 ``` If those don’t work: fix Tailscale connectivity first (ACLs, firewall, node online). --- ## 3) Nginx on the VPS: reverse proxy blocks ### 3.1 Nextcloud vhost (VPS → Pi via Tailscale) Create (or edit): `/etc/nginx/sites-available/nextcloud.alipourimjourneys.ir` and symlink into `sites-enabled`. Here is a complete working example: ```nginx # Redirect HTTP → HTTPS server { listen 80; listen [::]:80; server_name nextcloud.alipourimjourneys.ir; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name nextcloud.alipourimjourneys.ir; # Certbot-managed certs ssl_certificate /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # Big uploads (tune to taste) client_max_body_size 2G; # CalDAV/CardDAV redirects location = /.well-known/carddav { return 301 https://$host/remote.php/dav/; } location = /.well-known/caldav { return 301 https://$host/remote.php/dav/; } location / { proxy_pass http://100.104.127.96:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Sometimes helps apps behind multiple proxies proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; # Nextcloud + WebDAV can do long requests proxy_read_timeout 3600; proxy_send_timeout 3600; # Usually good for DAV/uploads proxy_buffering off; proxy_request_buffering off; } } ``` Then test + reload: ```bash sudo nginx -t sudo systemctl reload nginx ``` ### 3.2 Jellyfin vhost (with Basic Auth) Create: `/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir` Example: ```nginx server { listen 80; listen [::]:80; server_name jellyfin.alipourimjourneys.ir; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name jellyfin.alipourimjourneys.ir; ssl_certificate /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; client_max_body_size 512M; # ✅ Basic Auth gate (extra layer before Jellyfin) auth_basic "Jellyfin (private)"; auth_basic_user_file /etc/nginx/.htpasswd-jellyfin; location / { proxy_pass http://100.104.127.96:8096; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Jellyfin uses websockets proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600; proxy_send_timeout 3600; proxy_buffering off; } } ``` Enable: ```bash sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx ``` --- ## 4) Creating the Basic Auth password file Install tools (Debian/Ubuntu): ```bash sudo apt-get update sudo apt-get install -y apache2-utils ``` Create the password file: ```bash sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername ``` (If adding more users later, omit `-c`.) Lock it down: ```bash sudo chown root:root /etc/nginx/.htpasswd-jellyfin sudo chmod 640 /etc/nginx/.htpasswd-jellyfin ``` ### Notes on clients - Most browsers + most Jellyfin apps handle HTTP Basic Auth fine. - Some TV apps can be quirky. If a client can’t handle it, you can: - remove Basic Auth, or - keep it only on selected paths, or - use Cloudflare Access in front of Jellyfin instead (more work). --- ## 5) Nextcloud: configure it to behave behind the reverse proxy Nextcloud needs to know: - what hostnames are valid, - which proxy is trusted, - and what the “outside” URL scheme is. You can do it via `occ` inside the container (Apache image uses `www-data`). ### 5.1 trusted_domains Check current values: ```bash docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains ``` Add your public domain: ```bash docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains 1 --value="nextcloud.alipourimjourneys.ir" ``` (Keep your internal name too if you still use it, e.g. `rpi:8080`.) ### 5.2 trusted_proxies Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP. Example: ```bash docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies 0 --value="100.99.79.75" ``` (Use the **VPS’s Tailscale IP** as seen from the Pi.) ### 5.3 overwritehost / overwriteprotocol / overwrite.cli.url Tell Nextcloud “the world sees me as https://nextcloud.example”: ```bash docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value="nextcloud.alipourimjourneys.ir" docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value="https" docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value="https://nextcloud.alipourimjourneys.ir" ``` ### 5.4 forwarded_for_headers (optional, but often helpful) ```bash docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers 0 --value="HTTP_X_FORWARDED_FOR" ``` Restart Nextcloud container after config: ```bash docker restart nextcloud ``` --- ## 6) Sanity checks (curl is your friend) From anywhere public: ```bash curl -I http://nextcloud.alipourimjourneys.ir curl -I https://nextcloud.alipourimjourneys.ir curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav ``` Expected “good signs”: - HTTP returns **301** to HTTPS - HTTPS returns **302** to `/login` (or 200 if already authenticated) - `/.well-known/caldav` returns **301** to `/remote.php/dav/` If you see redirect loops or wrong hostnames: - revisit `overwritehost`, `overwriteprotocol`, `trusted_proxies`. --- ## 7) “Do I really need Cloudflare Access / WARP?” ### The honest answer If your setup is: - HTTPS only - strong passwords + MFA in Nextcloud/Jellyfin - your origin isn’t directly exposed (only the VPS is public) - you keep things patched …then you’re already in a **reasonable** place. ### “Can I skip Cloudflare Access?” Yes. In this topology, Cloudflare Access is optional. The main security boundary is: - **Public:** VPS + Nginx - **Private:** Pi over Tailscale For Jellyfin, Basic Auth adds a cheap extra gate that’s “family friendly”. --- ## 8) Cloudflare Access: One-time PIN not arriving + passkeys Two common gotchas: ### 8.1 One-time PIN email didn’t arrive That flow relies on email delivery. Check: - spam/junk folder - if your provider blocked it - the exact email allowlist in your policy If it’s flaky, I’d avoid One-time PIN and use a real identity provider. ### 8.2 Can I use passkeys / Apple / Google? Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys. Practical approach: - pick what your family already uses (Google or Apple), - configure that as the login method, - avoid WARP enrollment unless you specifically want device-based access. --- ## 9) Hardening checklist (tiny but effective) On the VPS: - Keep Ubuntu security updates on - firewall: allow only what you need (22/80/443) - optional: `fail2ban` for SSH On the Pi: - keep Docker images updated - Postgres/Redis not exposed publicly (Docker internal network) - backups: Nextcloud data + DB --- ## 10) TL;DR - VPS Nginx terminates TLS, proxies to Pi over Tailscale - Nextcloud must be told about: - `trusted_domains` - `trusted_proxies` - overwrite values (`overwritehost`, `overwriteprotocol`, `overwrite.cli.url`) - `curl -I` should show sane redirects + `/remote.php/dav/` - Jellyfin gets an extra gate with **Nginx Basic Auth** Boring is good. Boring runs for months. --- --- {{< sigdl >}}