Initial import of Hugo site to Forgejo
This commit is contained in:
commit
cb1ba0317f
1259 changed files with 236349 additions and 0 deletions
418
static/sources/posts/pi_service_touchup.md
Normal file
418
static/sources/posts/pi_service_touchup.md
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
---
|
||||
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://<pi>:8080` for Nextcloud
|
||||
- `http://<pi>: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 >}}
|
||||
Loading…
Add table
Add a link
Reference in a new issue