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.znetwork) - 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:
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-redisjellyfin
Example docker ps style output (yours may vary):
So on the Pi, the services listen on:
http://<pi>:8080for Nextcloudhttp://<pi>:8096for 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:
# 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:
# 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:
sudo nginx -t
sudo systemctl reload nginx
3.2 Jellyfin vhost (with Basic Auth)
Create:
/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir
Example:
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:
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):
sudo apt-get update
sudo apt-get install -y apache2-utils
Create the password file:
sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
(If adding more users later, omit -c.)
Lock it down:
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:
docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
Add your public domain:
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:
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”:
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)
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:
docker restart nextcloud
6) Sanity checks (curl is your friend)
From anywhere public:
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/caldavreturns 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:
fail2banfor 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_domainstrusted_proxies- overwrite values (
overwritehost,overwriteprotocol,overwrite.cli.url)
curl -Ishould show sane redirects +/remote.php/dav/- Jellyfin gets an extra gate with Nginx Basic Auth
Boring is good. Boring runs for months.
Downloads: Markdown · Signature (.asc)
View OpenPGP signature
-----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM jSomZA/+LsB+V0BnPki5qaDc+tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er vfg48uYNp4fBnoyfJrgURx+OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC+58lGs +qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ iVH+ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy+7/id2yz3eXYWn2xKMvp SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx kOvBdjQf4wDqUuNv7NKA+OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/ Gs8K9nADnHhAjWgwTq3D+2qRWcF0tlLSTmKg+95yaYi0XWWMFGTgCv2odPsgFlIS 3FiLJC3rV73prsk+7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v TJ2+amlKyT+Nq9wGrAez+dTvR0FKuEvA5OO693Aibv/iwOX6UPU= =2UUg -----END PGP SIGNATURE-----
Verify locally:
torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md torsocks curl -fSLO http://fjthpp2h3mj2rup25r3psmqamutnkbvxbpltlohdthw6fscgo3t6bpad.onion/sources/posts/pi_service_touchup.md.asc gpg --verify pi_service_touchup.md.asc pi_service_touchup.md