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:

# 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/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.



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:

curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md
curl -fSLO https://blog.alipour.eu/sources/posts/pi_service_touchup.md.asc
gpg --verify pi_service_touchup.md.asc pi_service_touchup.md