mysite/public-clearnet/posts/pi_service_touchup/index.html

157 lines
No EOL
43 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html><html lang=en dir=auto><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1,shrink-to-fit=no"><meta name=robots content="noindex, nofollow"><title>Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin | AlipourIm journeys</title>
<meta name=keywords content="homelab,nginx,docker,nextcloud,jellyfin,cloudflare,tailscale,security,raspberrypi"><meta name=description content="Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable."><meta name=author content="Iman Alipour"><link rel=canonical href=https://blog.alipour.eu/posts/pi_service_touchup/><link crossorigin=anonymous href=/assets/css/stylesheet.31b150d909186c48d6dbbf653acc2489945fa9ac2c8f8cd3fe8448e89e40fccf.css integrity="sha256-MbFQ2QkYbEjW279lOswkiZRfqawsj4zT/oRI6J5A/M8=" rel="preload stylesheet" as=style><link rel=icon href=https://blog.alipour.eu/favicon.ico><link rel=icon type=image/png sizes=16x16 href=https://blog.alipour.eu/favicon-16x16.png><link rel=icon type=image/png sizes=32x32 href=https://blog.alipour.eu/favicon-32x32.png><link rel=apple-touch-icon href=https://blog.alipour.eu/apple-touch-icon.png><link rel=mask-icon href=https://blog.alipour.eu/safari-pinned-tab.svg><meta name=theme-color content="#2e2e33"><meta name=msapplication-TileColor content="#2e2e33"><link rel=alternate hreflang=en href=https://blog.alipour.eu/posts/pi_service_touchup/><noscript><style>#theme-toggle,.top-link{display:none}</style><style>@media(prefers-color-scheme:dark){:root{--theme:rgb(29, 30, 32);--entry:rgb(46, 46, 51);--primary:rgb(218, 218, 219);--secondary:rgb(155, 156, 157);--tertiary:rgb(65, 66, 68);--content:rgb(196, 196, 197);--code-block-bg:rgb(46, 46, 51);--code-bg:rgb(55, 56, 62);--border:rgb(51, 51, 51)}.list{background:var(--theme)}.list:not(.dark)::-webkit-scrollbar-track{background:0 0}.list:not(.dark)::-webkit-scrollbar-thumb{border-color:var(--theme)}}</style></noscript><script>(function(){window.goatcounter={endpoint:"/count"};const e=document.createElement("script");e.async=!0,e.src="/js/count.js",document.head.appendChild(e)})()</script><script>(function(){const s="",t=new Intl.NumberFormat,o="visit",i="visits";function a(e){document.readyState==="complete"||document.readyState==="interactive"?setTimeout(e,0):document.addEventListener("DOMContentLoaded",e)}function r(){try{return window.goatcounter&&window.goatcounter.get_data?window.goatcounter.get_data().p:location.pathname}catch{return location.pathname}}async function e(e,t){try{const o=new URLSearchParams({t:String(Date.now())});t&&o.set("start",t);const a=`${s}/counter/${encodeURIComponent(e)}.json?${o}`,n=await fetch(a,{credentials:"omit"});if(n.status===404)return 0;if(!n.ok)throw 0;const r=await n.json(),i=Number(String(r.count).replace(/,/g,""));return Number.isFinite(i)?i:0}catch{return null}}const n=e=>e===null?"—":`${t.format(e)} ${e===1?o:i}`;a(async function(){const s=document.querySelector(".post-single");if(s){const t=s.querySelector(".post-header .post-meta, .post-header .entry-meta, .post-meta"),o=await e(r());if(t){let e=t.querySelector(".post-views");e||(e=document.createElement("span"),e.className="post-views",t.appendChild(e)),e.textContent=n(o??0)}}const i=document.querySelectorAll("article.post-entry");await Promise.all(Array.from(i).map(async t=>{const s=t.querySelector(".entry-footer, .post-meta"),o=t.querySelector("a.entry-link, h2 a, .entry-title a");if(!s||!o)return;try{const i=new URL(o.getAttribute("href"),location.origin);if(i.origin!==location.origin)return;let t=s.querySelector(".post-views");t||(t=document.createElement("span"),t.className="post-views",s.appendChild(t)),t.textContent=n(await e(i.pathname)??0)}catch{}}));const o=document.getElementById("gc_site_visitors_week");if(o){const n=await e("TOTAL","week");o.textContent=n===null?"—":t.format(n)}})})()</script></head><body id=top><script>localStorage.getItem("pref-theme")==="dark"?document.body.classList.add("dark"):localStorage.getItem("pref-theme")==="light"?document.body.classList.remove("dark"):window.matchMedia("(prefers-color-scheme: dark)").matches&&document.body.classList.add("dark")</script><header class=header><nav class=nav><div class=logo><a href=https://blog.alipour.eu/ accesskey=h title="AlipourIm journeys (Alt + H)">AlipourIm journeys</a><div class=logo-switches><button id=theme-toggle accesskey=t title="(Alt + T)" aria-label="Toggle theme"><svg id="moon" width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg><svg id="sun" width="24" height="18" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></button></div></div><ul id=menu><li><a href=https://blog.alipour.eu/posts/ title=Posts><span>Posts</span></a></li><li><a href=https://blog.alipour.eu/phd_journey/ title=PhD_journey><span>PhD_journey</span></a></li><li><a href=https://blog.alipour.eu/about/ title=About><span>About</span></a></li></ul></nav></header><main class=main><article class=post-single><header class=post-header><div class=breadcrumbs><a href=https://blog.alipour.eu/>Home</a>&nbsp;»&nbsp;<a href=https://blog.alipour.eu/posts/>Posts</a></div><h1 class="post-title entry-hint-parent">Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin</h1><div class=post-description>Cloudflare DNS + Nginx on a VPS + Tailscale to the Pi. Small, boring, and reliable.</div><div class=post-meta><span title='2026-02-02 00:00:00 +0000 UTC'>February 2, 2026</span>&nbsp;·&nbsp;6 min&nbsp;·&nbsp;Iman Alipour
<span class=post-meta-item><a href=https://blog.alipour.eu/posts/pi_service_touchup/#isso-thread class=isso-comments-link>Comments</a></span></div></header><div class=post-content><blockquote><p>I didnt “move Nextcloud to the cloud”.<br>I moved the <strong>front door</strong> to a VPS… and kept the house on my Raspberry Pi. 😄</p></blockquote><p>This post documents the setup I ended up with:</p><ul><li>A public <strong>VPS</strong> (host: <code>funbox</code>) running <strong>Nginx</strong> + <strong>Lets Encrypt</strong></li><li>A private <strong>Raspberry Pi</strong> (host: <code>iot-hub</code>) running Docker services (<strong>Nextcloud</strong>, <strong>Jellyfin</strong>, …)</li><li>A private backhaul using <strong>Tailscale</strong> (the <code>100.x.y.z</code> network)</li><li>A correct Nextcloud reverse-proxy configuration (<strong>trusted_domains</strong>, <strong>trusted_proxies</strong>, and overwrite values)</li><li>A pragmatic security layer for media: <strong>Basic Auth at Nginx for Jellyfin</strong><br>(in addition to Jellyfins own login)</li></ul><p>Im writing this as a “future me” note and a “copy-paste friendly” guide.</p><hr><h2 id=0-topology>0) Topology<a hidden class=anchor aria-hidden=true href=#0-topology>#</a></h2><p>The request path looks like:</p><div class=mermaid>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 / …</div><p>Why I like it:</p><ul><li>The Pi can sit behind home router / CGNAT and still be reachable.</li><li>TLS, redirects, headers, auth, rate limits… all centralized on the VPS.</li><li>Internal IPs can change without breaking public URLs.</li></ul><hr><h2 id=1-whats-running-on-the-pi>1) Whats running on the Pi?<a hidden class=anchor aria-hidden=true href=#1-whats-running-on-the-pi>#</a></h2><p>On <code>iot-hub</code> the “interesting” containers are:</p><ul><li><code>nextcloud</code> (Apache variant)</li><li><code>nextcloud-db</code> (Postgres)</li><li><code>nextcloud-redis</code></li><li><code>jellyfin</code></li></ul><p>Example <code>docker ps</code> style output (yours may vary):</p><div class=mermaid>nextcloud nextcloud:apache 0.0.0.0:8080->80/tcp
jellyfin jellyfin/jellyfin 0.0.0.0:8096->8096/tcp</div><p>So on the Pi, the services listen on:</p><ul><li><code>http://&lt;pi>:8080</code> for Nextcloud</li><li><code>http://&lt;pi>:8096</code> for Jellyfin</li></ul><p>But in my setup, <strong>the VPS does not reach those via LAN</strong> — it reaches them via Tailscale IPs.</p><hr><h2 id=2-tailscale-the-private-wire-between-vps-and-pi>2) Tailscale: the private wire between VPS and Pi<a hidden class=anchor aria-hidden=true href=#2-tailscale-the-private-wire-between-vps-and-pi>#</a></h2><p>Tailscale assigns each node an address like <code>100.xx.yy.zz</code>.</p><p>In my config, Nginx on <code>funbox</code> points to the Pi via Tailscale:</p><ul><li>Nextcloud upstream: <code>http://100.104.127.96:8080</code></li><li>Jellyfin upstream: <code>http://100.104.127.96:8096</code></li></ul><p>(Use the Tailscale IP of your Pi.)</p><p>Quick sanity checks from the VPS:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span><span style=color:#75715e># from funbox, make sure you can reach the Pi service:</span>
</span></span><span style=display:flex><span>curl -I http://100.104.127.96:8080
</span></span><span style=display:flex><span>curl -I http://100.104.127.96:8096
</span></span></code></pre></div><p>If those dont work: fix Tailscale connectivity first (ACLs, firewall, node online).</p><hr><h2 id=3-nginx-on-the-vps-reverse-proxy-blocks>3) Nginx on the VPS: reverse proxy blocks<a hidden class=anchor aria-hidden=true href=#3-nginx-on-the-vps-reverse-proxy-blocks>#</a></h2><h3 id=31-nextcloud-vhost-vps--pi-via-tailscale>3.1 Nextcloud vhost (VPS → Pi via Tailscale)<a hidden class=anchor aria-hidden=true href=#31-nextcloud-vhost-vps--pi-via-tailscale>#</a></h3><p>Create (or edit):</p><p><code>/etc/nginx/sites-available/nextcloud.alipourimjourneys.ir</code></p><p>and symlink into <code>sites-enabled</code>.</p><p>Here is a complete working example:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-nginx data-lang=nginx><span style=display:flex><span><span style=color:#75715e># Redirect HTTP → HTTPS
</span></span></span><span style=display:flex><span><span style=color:#75715e></span><span style=color:#66d9ef>server</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#ae81ff>80</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#e6db74>[::]:80</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>server_name</span> <span style=color:#e6db74>nextcloud.alipourimjourneys.ir</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>return</span> <span style=color:#ae81ff>301</span> <span style=color:#e6db74>https://</span>$host$request_uri;
</span></span><span style=display:flex><span>}
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#66d9ef>server</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#ae81ff>443</span> <span style=color:#e6db74>ssl</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#e6db74>[::]:443</span> <span style=color:#e6db74>ssl</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>server_name</span> <span style=color:#e6db74>nextcloud.alipourimjourneys.ir</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># Certbot-managed certs
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>ssl_certificate</span> <span style=color:#e6db74>/etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/fullchain.pem</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_certificate_key</span> <span style=color:#e6db74>/etc/letsencrypt/live/nextcloud.alipourimjourneys.ir/privkey.pem</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>include</span> <span style=color:#e6db74>/etc/letsencrypt/options-ssl-nginx.conf</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_dhparam</span> <span style=color:#e6db74>/etc/letsencrypt/ssl-dhparams.pem</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># Big uploads (tune to taste)
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>client_max_body_size</span> <span style=color:#e6db74>2G</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># CalDAV/CardDAV redirects
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>location</span> = <span style=color:#e6db74>/.well-known/carddav</span> { <span style=color:#f92672>return</span> <span style=color:#ae81ff>301</span> <span style=color:#e6db74>https://</span>$host/remote.php/dav/; }
</span></span><span style=display:flex><span> <span style=color:#f92672>location</span> = <span style=color:#e6db74>/.well-known/caldav</span> { <span style=color:#f92672>return</span> <span style=color:#ae81ff>301</span> <span style=color:#e6db74>https://</span>$host/remote.php/dav/; }
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>location</span> <span style=color:#e6db74>/</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_pass</span> <span style=color:#e6db74>http://100.104.127.96:8080</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_http_version</span> <span style=color:#ae81ff>1</span><span style=color:#e6db74>.1</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Host</span> $host;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Real-IP</span> $remote_addr;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-For</span> $proxy_add_x_forwarded_for;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-Proto</span> $scheme;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># Sometimes helps apps behind multiple proxies
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-Host</span> $host;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-Port</span> $server_port;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># Nextcloud + WebDAV can do long requests
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>proxy_read_timeout</span> <span style=color:#ae81ff>3600</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_send_timeout</span> <span style=color:#ae81ff>3600</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># Usually good for DAV/uploads
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>proxy_buffering</span> <span style=color:#66d9ef>off</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_request_buffering</span> <span style=color:#66d9ef>off</span>;
</span></span><span style=display:flex><span> }
</span></span><span style=display:flex><span>}
</span></span></code></pre></div><p>Then test + reload:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sudo nginx -t
</span></span><span style=display:flex><span>sudo systemctl reload nginx
</span></span></code></pre></div><h3 id=32-jellyfin-vhost-with-basic-auth>3.2 Jellyfin vhost (with Basic Auth)<a hidden class=anchor aria-hidden=true href=#32-jellyfin-vhost-with-basic-auth>#</a></h3><p>Create:</p><p><code>/etc/nginx/sites-available/jellyfin.alipourimjourneys.ir</code></p><p>Example:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-nginx data-lang=nginx><span style=display:flex><span><span style=color:#66d9ef>server</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#ae81ff>80</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#e6db74>[::]:80</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>server_name</span> <span style=color:#e6db74>jellyfin.alipourimjourneys.ir</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>return</span> <span style=color:#ae81ff>301</span> <span style=color:#e6db74>https://</span>$host$request_uri;
</span></span><span style=display:flex><span>}
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span><span style=color:#66d9ef>server</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#ae81ff>443</span> <span style=color:#e6db74>ssl</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>listen</span> <span style=color:#e6db74>[::]:443</span> <span style=color:#e6db74>ssl</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>server_name</span> <span style=color:#e6db74>jellyfin.alipourimjourneys.ir</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_certificate</span> <span style=color:#e6db74>/etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/fullchain.pem</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_certificate_key</span> <span style=color:#e6db74>/etc/letsencrypt/live/jellyfin.alipourimjourneys.ir/privkey.pem</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>include</span> <span style=color:#e6db74>/etc/letsencrypt/options-ssl-nginx.conf</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>ssl_dhparam</span> <span style=color:#e6db74>/etc/letsencrypt/ssl-dhparams.pem</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>client_max_body_size</span> <span style=color:#e6db74>512M</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># ✅ Basic Auth gate (extra layer before Jellyfin)
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>auth_basic</span> <span style=color:#e6db74>&#34;Jellyfin</span> <span style=color:#e6db74>(private)&#34;</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>auth_basic_user_file</span> <span style=color:#e6db74>/etc/nginx/.htpasswd-jellyfin</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>location</span> <span style=color:#e6db74>/</span> {
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_pass</span> <span style=color:#e6db74>http://100.104.127.96:8096</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_http_version</span> <span style=color:#ae81ff>1</span><span style=color:#e6db74>.1</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Host</span> $host;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Real-IP</span> $remote_addr;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-For</span> $proxy_add_x_forwarded_for;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>X-Forwarded-Proto</span> $scheme;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#75715e># Jellyfin uses websockets
</span></span></span><span style=display:flex><span><span style=color:#75715e></span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Upgrade</span> $http_upgrade;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_set_header</span> <span style=color:#e6db74>Connection</span> <span style=color:#e6db74>&#34;upgrade&#34;</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_read_timeout</span> <span style=color:#ae81ff>3600</span>;
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_send_timeout</span> <span style=color:#ae81ff>3600</span>;
</span></span><span style=display:flex><span>
</span></span><span style=display:flex><span> <span style=color:#f92672>proxy_buffering</span> <span style=color:#66d9ef>off</span>;
</span></span><span style=display:flex><span> }
</span></span><span style=display:flex><span>}
</span></span></code></pre></div><p>Enable:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sudo ln -s /etc/nginx/sites-available/jellyfin.alipourimjourneys.ir /etc/nginx/sites-enabled/
</span></span><span style=display:flex><span>sudo nginx -t
</span></span><span style=display:flex><span>sudo systemctl reload nginx
</span></span></code></pre></div><hr><h2 id=4-creating-the-basic-auth-password-file>4) Creating the Basic Auth password file<a hidden class=anchor aria-hidden=true href=#4-creating-the-basic-auth-password-file>#</a></h2><p>Install tools (Debian/Ubuntu):</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sudo apt-get update
</span></span><span style=display:flex><span>sudo apt-get install -y apache2-utils
</span></span></code></pre></div><p>Create the password file:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sudo htpasswd -c /etc/nginx/.htpasswd-jellyfin yourusername
</span></span></code></pre></div><p>(If adding more users later, omit <code>-c</code>.)</p><p>Lock it down:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>sudo chown root:root /etc/nginx/.htpasswd-jellyfin
</span></span><span style=display:flex><span>sudo chmod <span style=color:#ae81ff>640</span> /etc/nginx/.htpasswd-jellyfin
</span></span></code></pre></div><h3 id=notes-on-clients>Notes on clients<a hidden class=anchor aria-hidden=true href=#notes-on-clients>#</a></h3><ul><li>Most browsers + most Jellyfin apps handle HTTP Basic Auth fine.</li><li>Some TV apps can be quirky. If a client cant handle it, you can:<ul><li>remove Basic Auth, or</li><li>keep it only on selected paths, or</li><li>use Cloudflare Access in front of Jellyfin instead (more work).</li></ul></li></ul><hr><h2 id=5-nextcloud-configure-it-to-behave-behind-the-reverse-proxy>5) Nextcloud: configure it to behave behind the reverse proxy<a hidden class=anchor aria-hidden=true href=#5-nextcloud-configure-it-to-behave-behind-the-reverse-proxy>#</a></h2><p>Nextcloud needs to know:</p><ul><li>what hostnames are valid,</li><li>which proxy is trusted,</li><li>and what the “outside” URL scheme is.</li></ul><p>You can do it via <code>occ</code> inside the container (Apache image uses <code>www-data</code>).</p><h3 id=51-trusted_domains>5.1 trusted_domains<a hidden class=anchor aria-hidden=true href=#51-trusted_domains>#</a></h3><p>Check current values:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>docker exec -u www-data nextcloud php /var/www/html/occ config:system:get trusted_domains
</span></span></code></pre></div><p>Add your public domain:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_domains <span style=color:#ae81ff>1</span> --value<span style=color:#f92672>=</span><span style=color:#e6db74>&#34;nextcloud.alipourimjourneys.ir&#34;</span>
</span></span></code></pre></div><p>(Keep your internal name too if you still use it, e.g. <code>rpi:8080</code>.)</p><h3 id=52-trusted_proxies>5.2 trusted_proxies<a hidden class=anchor aria-hidden=true href=#52-trusted_proxies>#</a></h3><p>Because requests arrive from the VPS (over Tailscale), Nextcloud must trust that proxy IP.</p><p>Example:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>docker exec -u www-data nextcloud php /var/www/html/occ config:system:set trusted_proxies <span style=color:#ae81ff>0</span> --value<span style=color:#f92672>=</span><span style=color:#e6db74>&#34;100.99.79.75&#34;</span>
</span></span></code></pre></div><p>(Use the <strong>VPSs Tailscale IP</strong> as seen from the Pi.)</p><h3 id=53-overwritehost--overwriteprotocol--overwritecliurl>5.3 overwritehost / overwriteprotocol / overwrite.cli.url<a hidden class=anchor aria-hidden=true href=#53-overwritehost--overwriteprotocol--overwritecliurl>#</a></h3><p>Tell Nextcloud “the world sees me as <a href=https://nextcloud.example>https://nextcloud.example</a>”:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwritehost --value<span style=color:#f92672>=</span><span style=color:#e6db74>&#34;nextcloud.alipourimjourneys.ir&#34;</span>
</span></span><span style=display:flex><span>docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwriteprotocol --value<span style=color:#f92672>=</span><span style=color:#e6db74>&#34;https&#34;</span>
</span></span><span style=display:flex><span>docker exec -u www-data nextcloud php /var/www/html/occ config:system:set overwrite.cli.url --value<span style=color:#f92672>=</span><span style=color:#e6db74>&#34;https://nextcloud.alipourimjourneys.ir&#34;</span>
</span></span></code></pre></div><h3 id=54-forwarded_for_headers-optional-but-often-helpful>5.4 forwarded_for_headers (optional, but often helpful)<a hidden class=anchor aria-hidden=true href=#54-forwarded_for_headers-optional-but-often-helpful>#</a></h3><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>docker exec -u www-data nextcloud php /var/www/html/occ config:system:set forwarded_for_headers <span style=color:#ae81ff>0</span> --value<span style=color:#f92672>=</span><span style=color:#e6db74>&#34;HTTP_X_FORWARDED_FOR&#34;</span>
</span></span></code></pre></div><p>Restart Nextcloud container after config:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>docker restart nextcloud
</span></span></code></pre></div><hr><h2 id=6-sanity-checks-curl-is-your-friend>6) Sanity checks (curl is your friend)<a hidden class=anchor aria-hidden=true href=#6-sanity-checks-curl-is-your-friend>#</a></h2><p>From anywhere public:</p><div class=highlight><pre tabindex=0 style=color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4><code class=language-bash data-lang=bash><span style=display:flex><span>curl -I http://nextcloud.alipourimjourneys.ir
</span></span><span style=display:flex><span>curl -I https://nextcloud.alipourimjourneys.ir
</span></span><span style=display:flex><span>curl -I https://nextcloud.alipourimjourneys.ir/.well-known/caldav
</span></span></code></pre></div><p>Expected “good signs”:</p><ul><li>HTTP returns <strong>301</strong> to HTTPS</li><li>HTTPS returns <strong>302</strong> to <code>/login</code> (or 200 if already authenticated)</li><li><code>/.well-known/caldav</code> returns <strong>301</strong> to <code>/remote.php/dav/</code></li></ul><p>If you see redirect loops or wrong hostnames:</p><ul><li>revisit <code>overwritehost</code>, <code>overwriteprotocol</code>, <code>trusted_proxies</code>.</li></ul><hr><h2 id=7-do-i-really-need-cloudflare-access--warp>7) “Do I really need Cloudflare Access / WARP?”<a hidden class=anchor aria-hidden=true href=#7-do-i-really-need-cloudflare-access--warp>#</a></h2><h3 id=the-honest-answer>The honest answer<a hidden class=anchor aria-hidden=true href=#the-honest-answer>#</a></h3><p>If your setup is:</p><ul><li>HTTPS only</li><li>strong passwords + MFA in Nextcloud/Jellyfin</li><li>your origin isnt directly exposed (only the VPS is public)</li><li>you keep things patched</li></ul><p>…then youre already in a <strong>reasonable</strong> place.</p><h3 id=can-i-skip-cloudflare-access>“Can I skip Cloudflare Access?”<a hidden class=anchor aria-hidden=true href=#can-i-skip-cloudflare-access>#</a></h3><p>Yes. In this topology, Cloudflare Access is optional. The main security boundary is:</p><ul><li><strong>Public:</strong> VPS + Nginx</li><li><strong>Private:</strong> Pi over Tailscale</li></ul><p>For Jellyfin, Basic Auth adds a cheap extra gate thats “family friendly”.</p><hr><h2 id=8-cloudflare-access-one-time-pin-not-arriving--passkeys>8) Cloudflare Access: One-time PIN not arriving + passkeys<a hidden class=anchor aria-hidden=true href=#8-cloudflare-access-one-time-pin-not-arriving--passkeys>#</a></h2><p>Two common gotchas:</p><h3 id=81-one-time-pin-email-didnt-arrive>8.1 One-time PIN email didnt arrive<a hidden class=anchor aria-hidden=true href=#81-one-time-pin-email-didnt-arrive>#</a></h3><p>That flow relies on email delivery. Check:</p><ul><li>spam/junk folder</li><li>if your provider blocked it</li><li>the exact email allowlist in your policy</li></ul><p>If its flaky, Id avoid One-time PIN and use a real identity provider.</p><h3 id=82-can-i-use-passkeys--apple--google>8.2 Can I use passkeys / Apple / Google?<a hidden class=anchor aria-hidden=true href=#82-can-i-use-passkeys--apple--google>#</a></h3><p>Yes — but passkeys typically come via an identity provider (IdP) that supports WebAuthn/passkeys.
Practical approach:</p><ul><li>pick what your family already uses (Google or Apple),</li><li>configure that as the login method,</li><li>avoid WARP enrollment unless you specifically want device-based access.</li></ul><hr><h2 id=9-hardening-checklist-tiny-but-effective>9) Hardening checklist (tiny but effective)<a hidden class=anchor aria-hidden=true href=#9-hardening-checklist-tiny-but-effective>#</a></h2><p>On the VPS:</p><ul><li>Keep Ubuntu security updates on</li><li>firewall: allow only what you need (22/80/443)</li><li>optional: <code>fail2ban</code> for SSH</li></ul><p>On the Pi:</p><ul><li>keep Docker images updated</li><li>Postgres/Redis not exposed publicly (Docker internal network)</li><li>backups: Nextcloud data + DB</li></ul><hr><h2 id=10-tldr>10) TL;DR<a hidden class=anchor aria-hidden=true href=#10-tldr>#</a></h2><ul><li>VPS Nginx terminates TLS, proxies to Pi over Tailscale</li><li>Nextcloud must be told about:<ul><li><code>trusted_domains</code></li><li><code>trusted_proxies</code></li><li>overwrite values (<code>overwritehost</code>, <code>overwriteprotocol</code>, <code>overwrite.cli.url</code>)</li></ul></li><li><code>curl -I</code> should show sane redirects + <code>/remote.php/dav/</code></li><li>Jellyfin gets an extra gate with <strong>Nginx Basic Auth</strong></li></ul><p>Boring is good. Boring runs for months.</p><hr><hr><div class=signature-block style=margin-top:1rem><p><strong>Downloads:</strong>
<a href=/sources/posts/pi_service_touchup.md>Markdown</a> ·
<a href=/sources/posts/pi_service_touchup.md.asc>Signature (.asc)</a></p><details class=signature><summary>View OpenPGP signature</summary><pre style=font-size:.85em;overflow-x:auto;padding:.75rem>-----BEGIN PGP SIGNATURE-----
iQIzBAABCgAdFiEEVaKl3oR5K6zGyu4/tYgoUOBMjSoFAmpkbuYACgkQtYgoUOBM
jSomZA/&#43;LsB&#43;V0BnPki5qaDc&#43;tRu6EXM5kPuH7G8x5ar4opsKgbfsRKEwT6/Q0Er
vfg48uYNp4fBnoyfJrgURx&#43;OwS7EVJRCmXaRsdilEEGHF7cT3qHhlczYaC&#43;58lGs
&#43;qXaHjYE8x0byAZ8iHNxNUhIyILVrcsdua3JZ3D3J/8r93dfVgrWg41Nrtj4tPUE
QQMW/KxTe/TOED4iivXxFlDCiT13KmgB/B9HeunGPqu8lPMTORf4ePoPzeYEeuuZ
iVH&#43;ssNZ9XB00Z4a/c3I0d2ALLYoA/dXmrJkl0qCCpCy&#43;7/id2yz3eXYWn2xKMvp
SAMTYaFAV6Fd7xdmxGYEeoCSDu8P57P7QfdPVEU1oUTANO21tEh1vGnjxcFdJUBx
kOvBdjQf4wDqUuNv7NKA&#43;OHOzhJ3Wf3VLb/ZNq6Y2okEibsCygm3XDn0008WeKPv
ncB0gceY6nFYzbyhuUIhPtQJJWDNi5KG/KMEvYcebEwDzn7TErg/v3Bp8ZCdWRx/
Gs8K9nADnHhAjWgwTq3D&#43;2qRWcF0tlLSTmKg&#43;95yaYi0XWWMFGTgCv2odPsgFlIS
3FiLJC3rV73prsk&#43;7eZftBTYCJN0Xk92YFj4a6bTYeuLcC20VbAA98Bpi0pCyO0v
TJ2&#43;amlKyT&#43;Nq9wGrAez&#43;dTvR0FKuEvA5OO693Aibv/iwOX6UPU=
=2UUg
-----END PGP SIGNATURE-----
</pre></details><p><em>Verify locally:</em></p><pre style=font-size:.85em;overflow-x:auto;padding:.75rem>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
</pre></div></div><footer class=post-footer><ul class=post-tags><li><a href=https://blog.alipour.eu/tags/homelab/>Homelab</a></li><li><a href=https://blog.alipour.eu/tags/nginx/>Nginx</a></li><li><a href=https://blog.alipour.eu/tags/docker/>Docker</a></li><li><a href=https://blog.alipour.eu/tags/nextcloud/>Nextcloud</a></li><li><a href=https://blog.alipour.eu/tags/jellyfin/>Jellyfin</a></li><li><a href=https://blog.alipour.eu/tags/cloudflare/>Cloudflare</a></li><li><a href=https://blog.alipour.eu/tags/tailscale/>Tailscale</a></li><li><a href=https://blog.alipour.eu/tags/security/>Security</a></li><li><a href=https://blog.alipour.eu/tags/raspberrypi/>Raspberrypi</a></li></ul><nav class=paginav><a class=prev href=https://blog.alipour.eu/posts/h3ll0_fr1end/><span class=title>« Prev</span><br><span>H3ll0 Fr1end</span>
</a><a class=next href=https://blog.alipour.eu/posts/blackout/><span class=title>Next »</span><br><span>Blackout</span></a></nav></footer><div class=comments-switch style="display:flex;gap:.5rem;margin:.5rem 0 1rem"><button id=btn-isso type=button aria-pressed=true>Anonymous (Isso)</button>
<button id=btn-giscus type=button aria-pressed=false>GitHub (Giscus)</button>
<a href=https://github.com/AlipourIm/blog-comments/discussions/categories/comments target=_blank rel=noopener>Open on GitHub ↗</a></div><div id=panel-giscus style=display:none><section id=giscus-thread></section></div><div id=panel-isso><section id=isso-thread data-isso-id=/posts/pi_service_touchup/ data-title="Nextcloud on a Raspberry Pi, Fronted by a Tiny VPS — Nginx Reverse Proxy, Trusted Proxies, and Basic Auth for Jellyfin"><noscript>Javascript needs to be activated to view comments.</noscript></section><script src=/isso/js/embed.min.js data-isso=/isso/ data-isso-css=false data-isso-lang=en data-isso-max-comments-nested=5 data-isso-sorting=newest async></script><small class=isso-powered style="display:block;margin:.5rem 0;color:var(--secondary,#888)">Comments powered by <a href=https://isso-comments.de target=_blank rel=noopener>Isso</a></small></div><script>(function(){var i=document.getElementById("panel-isso"),a=document.getElementById("panel-giscus"),e=document.getElementById("btn-isso"),t=document.getElementById("btn-giscus"),s=!1;function r(){if(n=localStorage.getItem("pref-theme"),n==="dark")return!0;if(n==="light")return!1;var n,e=document.documentElement,t=document.body;return!!(e.classList&&e.classList.contains("dark")||t.classList&&t.classList.contains("dark")||e.getAttribute("data-theme")==="dark"||t.getAttribute("data-theme")==="dark"||e.classList&&e.classList.contains("theme-dark")||t.classList&&t.classList.contains("theme-dark"))||window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches}function o(){return r()?"dark_dimmed":"noborder_light"}function c(){if(s)return;var t,n,e=document.createElement("script");e.src="https://giscus.app/client.js",e.async=!0,e.crossOrigin="anonymous",e.setAttribute("data-repo","AlipourIm/blog-comments"),e.setAttribute("data-repo-id","R_kgDOQGARyA"),e.setAttribute("data-category","Comments"),e.setAttribute("data-category-id","DIC_kwDOQGARyM4Cw3x-"),e.setAttribute("data-mapping","pathname"),e.setAttribute("data-strict","0"),e.setAttribute("data-reactions-enabled","1"),e.setAttribute("data-emit-metadata","0"),e.setAttribute("data-input-position","bottom"),e.setAttribute("data-lang","en"),e.setAttribute("data-theme",o()),document.getElementById("giscus-thread").appendChild(e);function i(){var e=document.querySelector("iframe.giscus-frame");e&&e.contentWindow&&e.contentWindow.postMessage({giscus:{setConfig:{theme:o()}}},"https://giscus.app")}t=document.getElementById("theme-toggle"),t&&t.addEventListener("click",function(){setTimeout(i,0)}),n=new MutationObserver(function(){i()}),n.observe(document.documentElement,{attributes:!0,attributeFilter:["class","data-theme"]}),n.observe(document.body,{attributes:!0,attributeFilter:["class","data-theme"]}),s=!0}function n(n){var s=n==="isso";i.style.display=s?"block":"none",a.style.display=s?"none":"block",e&&e.setAttribute("aria-pressed",s?"true":"false"),t&&t.setAttribute("aria-pressed",s?"false":"true"),s||c()}e&&e.addEventListener("click",function(e){e.preventDefault(),n("isso")}),t&&t.addEventListener("click",function(e){e.preventDefault(),n("giscus")}),n("isso")})()</script></article></main><footer class=footer><span>&copy; 2026 <a href=https://blog.alipour.eu/>AlipourIm journeys</a></span> ·
<span>Powered by
<a href=https://gohugo.io/ rel="noopener noreferrer" target=_blank>Hugo</a> &
<a href=https://github.com/adityatelange/hugo-PaperMod/ rel=noopener target=_blank>PaperMod</a></span></footer><a href=#top aria-label="go to top" title="Go to Top (Alt + G)" class=top-link id=top-link accesskey=g><svg viewBox="0 0 12 6" fill="currentcolor"><path d="M12 6H0l6-6z"/></svg></a><div class=site-meta-row style=display:flex;align-items:center;gap:.75rem;flex-wrap:wrap><a href=/index.xml rel=alternate type=application/rss+xml title=RSS class=rss-link style=display:inline-flex;align-items:center;gap:.35rem><svg viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/><circle cx="5" cy="19" r="1"/></svg>
<span>RSS</span>
</a><span aria-hidden=true>·</span>
<span>Visitors this week: <span id=gc_site_visitors_week></span></span></div><script>let menu=document.getElementById("menu");menu&&(menu.scrollLeft=localStorage.getItem("menu-scroll-position"),menu.onscroll=function(){localStorage.setItem("menu-scroll-position",menu.scrollLeft)}),document.querySelectorAll('a[href^="#"]').forEach(e=>{e.addEventListener("click",function(e){e.preventDefault();var t=this.getAttribute("href").substr(1);window.matchMedia("(prefers-reduced-motion: reduce)").matches?document.querySelector(`[id='${decodeURIComponent(t)}']`).scrollIntoView():document.querySelector(`[id='${decodeURIComponent(t)}']`).scrollIntoView({behavior:"smooth"}),t==="top"?history.replaceState(null,null," "):history.pushState(null,null,`#${t}`)})})</script><script>var mybutton=document.getElementById("top-link");window.onscroll=function(){document.body.scrollTop>800||document.documentElement.scrollTop>800?(mybutton.style.visibility="visible",mybutton.style.opacity="1"):(mybutton.style.visibility="hidden",mybutton.style.opacity="0")}</script><script>document.getElementById("theme-toggle").addEventListener("click",()=>{document.body.className.includes("dark")?(document.body.classList.remove("dark"),localStorage.setItem("pref-theme","light")):(document.body.classList.add("dark"),localStorage.setItem("pref-theme","dark"))})</script><script>document.querySelectorAll("pre > code").forEach(e=>{const n=e.parentNode.parentNode,t=document.createElement("button");t.classList.add("copy-code"),t.innerHTML="copy";function s(){t.innerHTML="copied!",setTimeout(()=>{t.innerHTML="copy"},2e3)}t.addEventListener("click",t=>{if("clipboard"in navigator){navigator.clipboard.writeText(e.textContent),s();return}const n=document.createRange();n.selectNodeContents(e);const o=window.getSelection();o.removeAllRanges(),o.addRange(n);try{document.execCommand("copy"),s()}catch{}o.removeRange(n)}),n.classList.contains("highlight")?n.appendChild(t):n.parentNode.firstChild==n||(e.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName=="TABLE"?e.parentNode.parentNode.parentNode.parentNode.parentNode.appendChild(t):e.parentNode.appendChild(t))})</script></body></html>