<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: John</title>
    <description>The latest articles on DEV Community by John (@john_182319291).</description>
    <link>https://dev.to/john_182319291</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4063265%2F912320f7-d3d8-4432-876b-a7e372adb01a.jpg</url>
      <title>DEV Community: John</title>
      <link>https://dev.to/john_182319291</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/john_182319291"/>
    <language>en</language>
    <item>
      <title>Tailscale vs Cloudflare Tunnel: How to Reach Self-Hosted Docker Services Without a Public IP</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 06 Oct 2026 07:06:43 +0000</pubDate>
      <link>https://dev.to/john_182319291/tailscale-vs-cloudflare-tunnel-how-to-reach-self-hosted-docker-services-without-a-public-ip-4420</link>
      <guid>https://dev.to/john_182319291/tailscale-vs-cloudflare-tunnel-how-to-reach-self-hosted-docker-services-without-a-public-ip-4420</guid>
      <description>&lt;p&gt;Use Tailscale when every person and device that needs access can install a client, and use Cloudflare Tunnel when someone with only a browser and a link has to get in. Tailscale builds an encrypted WireGuard mesh between your own machines, so your Docker services stay invisible to the public internet and any TCP or UDP port works. Cloudflare Tunnel runs an outbound-only connector from your host to Cloudflare's edge, publishing a hostname on the public internet with TLS terminated by Cloudflare. Neither needs a static IP, a port forward or a manually issued certificate, and running both on the same host is a normal, supported setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by reader profile:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two person startup running internal tools&lt;/strong&gt; (founders sharing Gitea, Grafana and a Postgres box): Tailscale, because nothing you self-host ever gets a public hostname and both of you already control every laptop involved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freelancer showing a client a staging site&lt;/strong&gt; (a preview build that must open in Safari on someone else's phone): Cloudflare Tunnel, because the visitor installs nothing and the link just works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sysadmin living in SSH, RDP and database clients&lt;/strong&gt; (managing five Docker hosts across two locations): Tailscale, because arbitrary TCP and UDP ports and direct peer-to-peer routing matter more than a pretty URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homelabber streaming media to family&lt;/strong&gt; (Jellyfin and Navidrome for four relatives): Tailscale, because large non-HTML media served through Cloudflare's free proxy sits against its terms, and direct WireGuard paths beat a proxied round trip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small team publishing a real public service&lt;/strong&gt; (a marketing site plus a webhook receiver for Stripe): Cloudflare Tunnel, because you want external systems and strangers to reach it without an invitation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone who needs both at once&lt;/strong&gt; (private admin panel, public app, one host): run both connectors side by side and split by hostname.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is simple: Tailscale asks you to install a client on every device that needs access, while Cloudflare Tunnel asks you to route your traffic through a third party edge that terminates your TLS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What do Tailscale and Cloudflare Tunnel actually do differently?&lt;/li&gt;
&lt;li&gt;How does each one reach a Docker container with no public IP?&lt;/li&gt;
&lt;li&gt;How do you set up Tailscale on a Docker host, step by step?&lt;/li&gt;
&lt;li&gt;How do you set up Cloudflare Tunnel on a Docker host, step by step?&lt;/li&gt;
&lt;li&gt;Which one is better for private admin panels only your team touches?&lt;/li&gt;
&lt;li&gt;Which one is better for a service you need to share with clients?&lt;/li&gt;
&lt;li&gt;Do SSH, RDP, UDP and other non-HTTP services work on each one?&lt;/li&gt;
&lt;li&gt;How much latency and throughput do you give up with each?&lt;/li&gt;
&lt;li&gt;How much does each really cost over three years?&lt;/li&gt;
&lt;li&gt;Tailscale Serve and Funnel vs Cloudflare public hostnames: where do they overlap?&lt;/li&gt;
&lt;li&gt;Who can see your traffic, and what does each side log?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What do Tailscale and Cloudflare Tunnel actually do differently?
&lt;/h2&gt;

&lt;p&gt;They solve two different problems that happen to share a symptom. Tailscale is a private network. Cloudflare Tunnel is a publishing pipe. Both remove the need for port forwarding, and that is where the similarity ends.&lt;/p&gt;

&lt;p&gt;Tailscale installs a daemon, &lt;code&gt;tailscaled&lt;/code&gt;, on each machine and gives it a stable address in the 100.64.0.0/10 range. Peers then negotiate direct WireGuard sessions with each other. Cloudflare Tunnel installs &lt;code&gt;cloudflared&lt;/code&gt;, which dials out to Cloudflare's edge over HTTPS and waits for inbound requests aimed at a hostname on a domain in your Cloudflare account.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Tailscale&lt;/th&gt;
&lt;th&gt;Cloudflare Tunnel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it creates&lt;/td&gt;
&lt;td&gt;A private mesh between your own devices, addressed over 100.64.0.0/10&lt;/td&gt;
&lt;td&gt;A public hostname on a domain you have delegated to Cloudflare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who can connect&lt;/td&gt;
&lt;td&gt;Only devices enrolled in your tailnet and allowed by your ACL policy&lt;/td&gt;
&lt;td&gt;Anyone on the internet, unless you add a Cloudflare Access policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transport&lt;/td&gt;
&lt;td&gt;WireGuard, direct peer-to-peer when NAT traversal succeeds, DERP relay when it fails&lt;/td&gt;
&lt;td&gt;TCP connections from &lt;code&gt;cloudflared&lt;/code&gt; to Cloudflare, then HTTPS from the edge to the visitor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol scope&lt;/td&gt;
&lt;td&gt;Any IP traffic, including UDP, SMB, RDP and database wire protocols&lt;/td&gt;
&lt;td&gt;HTTP and HTTPS by default, other protocols only through extra client tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client requirement&lt;/td&gt;
&lt;td&gt;A Tailscale client on every participating device&lt;/td&gt;
&lt;td&gt;Nothing on the visitor side, just a browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS ownership&lt;/td&gt;
&lt;td&gt;End to end between peers, no certificate to manage&lt;/td&gt;
&lt;td&gt;Terminated at Cloudflare's edge, certificate issued and held there&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The free tiers reflect that split: Tailscale's Personal plan counts devices and users, while Cloudflare's Zero Trust free tier counts seats behind Access policies.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does each one reach a Docker container with no public IP?
&lt;/h2&gt;

&lt;p&gt;Both approaches ignore your router entirely. Neither one opens an inbound port, because both rely on connections that your host makes outward.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale on the host, services on published ports:&lt;/strong&gt; you install the client on the Docker host, then reach containers at &lt;code&gt;http://100.x.y.z:8096&lt;/code&gt; using whatever port each container publishes. This is the simplest layout and needs no compose changes at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale as a sidecar container:&lt;/strong&gt; you run the &lt;code&gt;tailscale/tailscale&lt;/code&gt; image with &lt;code&gt;NET_ADMIN&lt;/code&gt; and &lt;code&gt;/dev/net/tun&lt;/code&gt;, then attach the app with &lt;code&gt;network_mode: "service:tailscale"&lt;/code&gt;. The app gets its own address and hostname in the tailnet, separate from the host, which is how you give one container an identity your ACLs can target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale as a subnet router:&lt;/strong&gt; you start the client with &lt;code&gt;--advertise-routes=172.18.0.0/16&lt;/code&gt; and approve the route in the admin console, so remote peers address containers on the Docker bridge network directly. Useful, but Docker's dynamically assigned subnets make this brittle unless you pin them in your compose file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cloudflared on the host or in compose:&lt;/strong&gt; the connector joins your Docker network and proxies to a service name, so an ingress rule points at &lt;code&gt;http://nextcloud:80&lt;/code&gt; rather than a published port. Your container never needs a host port binding, which is the cleanest firewall posture of the four.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outbound requirements for both:&lt;/strong&gt; &lt;code&gt;cloudflared&lt;/code&gt; needs outbound TCP 443 and works behind almost any NAT, while Tailscale prefers UDP 41641 outbound for direct paths and falls back to relaying over 443 when UDP is blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical difference: Tailscale gives the machine an address, Cloudflare Tunnel gives the service a URL.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you set up Tailscale on a Docker host, step by step?
&lt;/h2&gt;

&lt;p&gt;The whole process takes about ten minutes on a fresh Debian or Ubuntu host, and most of that is waiting for an apt install.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Create the tailnet first:&lt;/strong&gt; sign up, then note the ACL editor in the admin console. Everything you do later, including which laptop may reach which container, is expressed in that one JSON policy file, so it pays to open it before you enroll anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install the client on the host:&lt;/strong&gt; run &lt;code&gt;curl -fsSL https://tailscale.com/install.sh | sh&lt;/code&gt;, then &lt;code&gt;sudo tailscale up&lt;/code&gt;. The command prints a login URL. Approve it once and the machine keeps its address across reboots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use an auth key for unattended hosts:&lt;/strong&gt; generate a pre-authorized key in the admin console and pass it with &lt;code&gt;sudo tailscale up --authkey=tskey-auth-...&lt;/code&gt;. Tag the key, for example &lt;code&gt;--advertise-tags=tag:docker&lt;/code&gt;, so ACLs can match the host by role instead of by name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable key expiry on servers:&lt;/strong&gt; device keys expire by default, which silently drops a headless box off the tailnet. Set the node to never expire in the console, or plan a renewal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on MagicDNS and verify:&lt;/strong&gt; with MagicDNS enabled you reach the box at &lt;code&gt;http://docker-host:8096&lt;/code&gt; rather than memorizing a 100.x address. Confirm the path with &lt;code&gt;tailscale status&lt;/code&gt;, which marks each peer as &lt;code&gt;direct&lt;/code&gt; or &lt;code&gt;relay&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where that host lives is your decision. A home mini PC, a rented VPS, a NAS and a managed service are all valid. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. In each case the install steps above are identical, because Tailscale only needs outbound connectivity.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you set up Cloudflare Tunnel on a Docker host, step by step?
&lt;/h2&gt;

&lt;p&gt;The prerequisite is the part people miss: you need a domain whose nameservers point at Cloudflare. Without that delegation there is no hostname to publish, and no amount of connector configuration fixes it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delegate the domain:&lt;/strong&gt; add the zone in the Cloudflare dashboard and switch your registrar's nameservers to the pair Cloudflare assigns. Propagation is usually quick, but allow for a slow registrar before you plan the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create the tunnel and copy the token:&lt;/strong&gt; in Zero Trust, under Networks, create a tunnel and choose the Docker install method. Cloudflare hands you a long-lived token. Treat it as a credential, because anyone holding it can publish from your name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the connector as a container:&lt;/strong&gt; use &lt;code&gt;cloudflare/cloudflared:latest&lt;/code&gt; with the command &lt;code&gt;tunnel --no-autoupdate run --token $TUNNEL_TOKEN&lt;/code&gt;, put the token in an &lt;code&gt;.env&lt;/code&gt; file rather than inline in &lt;code&gt;docker-compose.yml&lt;/code&gt;, and attach the container to the same Docker network as your app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add public hostname routes:&lt;/strong&gt; map &lt;code&gt;app.example.com&lt;/code&gt; to a service URL such as &lt;code&gt;http://gitea:3000&lt;/code&gt;. The DNS record is created for you as a proxied CNAME, so there is nothing to add by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide on Access before you share the link:&lt;/strong&gt; a bare public hostname is open to the internet and to crawlers. Adding an Access policy with email one time passcodes puts a login in front of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where the connector runs:&lt;/strong&gt; a home server, a NAS, a VPS and Yundera are all workable hosts, and on Yundera each app is reachable on a public HTTPS subdomain via NSL.SH mesh routing, so a tunnel is one choice among several rather than the only route in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify with &lt;code&gt;cloudflared tunnel list&lt;/code&gt;, which shows the connection count.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which one is better for private admin panels only your team touches?
&lt;/h2&gt;

&lt;p&gt;Tailscale, and the margin is not close. A Portainer instance on port 9443, a Traefik dashboard or a Grafana admin login has no business resolving in public DNS, and with a tailnet it never does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing to discover:&lt;/strong&gt; a tailnet device has no public DNS record and answers nothing from the open internet, so credential stuffing and scanner traffic against your admin panel drop to zero by construction. A Cloudflare hostname stays publicly resolvable even with an Access policy in front, which means the login page itself is reachable and probeable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication you already own:&lt;/strong&gt; Tailscale authenticates the device through your existing identity provider at enrollment, then every later connection rides that enrolled key. Nobody types a password into a self-hosted admin panel from an unknown browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ACLs scoped to a port:&lt;/strong&gt; the policy file lets you write a rule granting &lt;code&gt;tag:founder&lt;/code&gt; access to &lt;code&gt;tag:docker:9443&lt;/code&gt; and nothing else. The same box can hold a database on 5432 that only one machine may reach, expressed in a few lines of JSON rather than in iptables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale SSH as a bonus:&lt;/strong&gt; the client can terminate SSH itself, so you get access to the Docker host without distributing authorized_keys files or leaving port 22 listening anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real cost:&lt;/strong&gt; every admin needs the client installed. On a two person team with two laptops and two phones, that is four installs inside the Personal plan's limits. On a 30 person company with contractors on their own hardware, it becomes an onboarding task.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use Cloudflare Tunnel for an admin panel only when you genuinely cannot install software on the accessing device, and then put an Access policy in front of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which one is better for a service you need to share with clients?
&lt;/h2&gt;

&lt;p&gt;Cloudflare Tunnel wins here for the same reason it loses on admin panels: the visitor needs nothing but a link. A client reviewing a staging build on an iPad in a meeting will not install a mesh VPN, and asking them to is how a review slips a week.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero friction on the far side:&lt;/strong&gt; &lt;code&gt;app.example.com&lt;/code&gt; resolves, serves a valid certificate and opens. No client, no invitation to accept, no device enrollment, no explanation of what a tailnet is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access policies instead of accounts:&lt;/strong&gt; a Cloudflare Access policy with email one time passcodes lets you allow a named list of client addresses, for example everyone at &lt;code&gt;@clientcompany.com&lt;/code&gt;, without creating users inside your self-hosted app. The visitor gets a six digit code by email and lands on your service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks and machine callers work:&lt;/strong&gt; Stripe, GitHub and any other service that posts to a URL can reach a tunnel hostname. They can never reach a tailnet address, so if your app receives webhooks, the decision is already made.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharing scales without seats on your side:&lt;/strong&gt; 50 clients browsing a public hostname cost you nothing extra in connector configuration, while 50 Tailscale users means 50 enrollments and a plan that counts them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What you give up:&lt;/strong&gt; your traffic transits Cloudflare's edge, your certificate lives there, and the hostname is visible in Certificate Transparency logs, so the existence of the service is public even when the content is gated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tailscale has one credible answer for shared access: node sharing, which invites an external person's own tailnet device to reach a single machine. It works well for a contractor who already uses Tailscale. It does not work for a client who does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Do SSH, RDP, UDP and other non-HTTP services work on each one?
&lt;/h2&gt;

&lt;p&gt;Tailscale treats everything as IP traffic, so the answer is yes by default. Cloudflare Tunnel is built around HTTP, and anything else needs either a client install on the far side or a paid Zero Trust feature, which undoes the main reason you picked it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Tailscale&lt;/th&gt;
&lt;th&gt;Cloudflare Tunnel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSH on 22&lt;/td&gt;
&lt;td&gt;Works natively, plus optional Tailscale SSH with ACL-based authorization&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;cloudflared access ssh&lt;/code&gt; on the client, or browser rendered SSH through Zero Trust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDP on 3389&lt;/td&gt;
&lt;td&gt;Works natively, connect to the peer address in any RDP client&lt;/td&gt;
&lt;td&gt;Needs the WARP client enrolled, or browser rendered RDP on a paid Zero Trust plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL on 5432 or MySQL on 3306&lt;/td&gt;
&lt;td&gt;Works natively, point your client at the tailnet hostname&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;cloudflared access tcp --hostname db.example.com --url localhost:5432&lt;/code&gt; on each developer machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMB on 445&lt;/td&gt;
&lt;td&gt;Works, which is why Tailscale is common for NAS shares across sites&lt;/td&gt;
&lt;td&gt;Not a practical fit, SMB over a public HTTP edge is not the intended use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UDP services, for example a game server on 25565 or a DNS resolver on 53&lt;/td&gt;
&lt;td&gt;Works, UDP is carried like any other IP traffic&lt;/td&gt;
&lt;td&gt;Private network UDP requires WARP, there is no plain tunnel equivalent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain HTTP and HTTPS apps&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;td&gt;Works, this is the native case&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is consistent. If the thing you need to reach speaks anything other than HTTP, Tailscale handles it with no extra moving parts, while Cloudflare asks you to install &lt;code&gt;cloudflared&lt;/code&gt; or WARP on the client. At that point you have a client install on every device anyway, and the comparison collapses back to which mesh you prefer.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much latency and throughput do you give up with each?
&lt;/h2&gt;

&lt;p&gt;Measure it yourself rather than trusting any published figure, because both results depend almost entirely on your network path. The shape of the cost, though, is predictable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale on a direct path:&lt;/strong&gt; two peers that complete NAT traversal exchange packets straight to each other, so added latency is roughly the encryption overhead plus the difference between the direct route and your old route. Confirm the path with &lt;code&gt;tailscale ping hostname&lt;/code&gt;, which reports whether it went direct or via a relay, and check candidate paths with &lt;code&gt;tailscale netcheck&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale on a DERP relay:&lt;/strong&gt; when UDP is blocked, traffic detours through a Cloudflare-independent relay server chosen by region, adding that round trip to every packet. Relayed sessions are the single biggest performance cliff in a tailnet, and &lt;code&gt;tailscale status&lt;/code&gt; flags them with &lt;code&gt;relay&lt;/code&gt; next to the peer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kernel versus userspace WireGuard:&lt;/strong&gt; on Linux the client uses the in-kernel WireGuard implementation where available and falls back to userspace &lt;code&gt;wireguard-go&lt;/code&gt; otherwise, which costs CPU on low-power hosts. Tailscale's default MTU of 1280 bytes also means slightly more packets per megabyte than an untunneled link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Tunnel is always a detour:&lt;/strong&gt; the visitor connects to a Cloudflare data centre, which forwards to wherever your connector sits. If the visitor is next to your server but the chosen edge is not, you pay that geography twice on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard protocol limits to plan around:&lt;/strong&gt; Cloudflare returns error 524 when an origin takes longer than 100 seconds to respond, and the free plan caps request body size at 100 MB. Large uploads and long-running jobs need chunking or a different route.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For bulk transfers and interactive SSH, a direct tailnet path is the better instrument.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much does each really cost over three years?
&lt;/h2&gt;

&lt;p&gt;Both start at zero for a two person team, so the three year number is driven by what grows: users on one side, domains and plan tiers on the other. Check current pricing pages before you commit, because per user rates change.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost line&lt;/th&gt;
&lt;th&gt;Tailscale over 36 months&lt;/th&gt;
&lt;th&gt;Cloudflare Tunnel over 36 months&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Entry tier&lt;/td&gt;
&lt;td&gt;Free Personal plan covers 3 users and 100 devices, which fits a founder pair with laptops, phones and several Docker hosts&lt;/td&gt;
&lt;td&gt;Tunnels are free on any Cloudflare plan, and Zero Trust access policies are free up to 50 seats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What makes it grow&lt;/td&gt;
&lt;td&gt;Headcount and contractors, since billing is per user per month on paid plans once you pass the free user count&lt;/td&gt;
&lt;td&gt;Feature tier, since larger upload limits, longer timeouts and advanced policies sit on paid plans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mandatory extras&lt;/td&gt;
&lt;td&gt;None, you do not need to own a domain&lt;/td&gt;
&lt;td&gt;A registered domain delegated to Cloudflare, renewed annually for the full three years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hidden operational cost&lt;/td&gt;
&lt;td&gt;Onboarding each new device and renewing or disabling key expiry on headless hosts&lt;/td&gt;
&lt;td&gt;Guarding the tunnel token, rotating it after staff changes, and auditing which hostnames are publicly resolvable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escape hatch if pricing shifts&lt;/td&gt;
&lt;td&gt;Headscale, an open source control server you self-host, keeps the same clients working&lt;/td&gt;
&lt;td&gt;Nothing equivalent, moving off means rebuilding ingress with a reverse proxy and your own certificates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest summary: for two people, both are effectively free for 36 months and the money is not the deciding factor. The decision becomes financial somewhere around the point where you add a tenth teammate, and at that point Tailscale's per user billing is the line that moves while Cloudflare's stays flat.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tailscale Serve and Funnel vs Cloudflare public hostnames: where do they overlap?
&lt;/h2&gt;

&lt;p&gt;This is the one place the two products compete directly. Funnel publishes a tailnet service to the open internet, which is exactly what a Cloudflare public hostname does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serve is internal only:&lt;/strong&gt; &lt;code&gt;tailscale serve --bg 8096&lt;/code&gt; puts a valid HTTPS certificate on your MagicDNS name, so you reach &lt;code&gt;https://docker-host.tailnet-name.ts.net&lt;/code&gt; instead of an IP and a port. Nothing becomes public. This is the fastest way to stop browser certificate warnings on a self-hosted app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Funnel is the public step:&lt;/strong&gt; &lt;code&gt;tailscale funnel --bg 8096&lt;/code&gt; takes that same service and makes it reachable from any browser. You must first enable HTTPS certificates for the tailnet and grant the node the &lt;code&gt;funnel&lt;/code&gt; attribute in your policy file, which is a deliberate speed bump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Funnel's port restriction is the catch:&lt;/strong&gt; inbound Funnel traffic is accepted on 443, 8443 and 10000 only. A Cloudflare hostname always answers on 443 with whatever path routing you configure, with no port list to work around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hostname is not yours:&lt;/strong&gt; Funnel serves on a subdomain of &lt;code&gt;ts.net&lt;/code&gt;, so you cannot publish &lt;code&gt;app.yourcompany.com&lt;/code&gt; through it. If branding or a customer-facing URL matters, Cloudflare Tunnel or a reverse proxy is the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throughput expectations differ:&lt;/strong&gt; Funnel traffic is relayed through Tailscale infrastructure and is positioned for demos, webhooks and light sharing rather than serving a busy site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where the service runs is a separate choice:&lt;/strong&gt; a home server, a NAS, a VPS and Yundera all host the same containers, and each app on Yundera already answers on a public HTTPS subdomain via NSL.SH mesh routing, so Funnel may be redundant there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use Funnel for a quick public link. Use Cloudflare for a hostname you intend to keep.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who can see your traffic, and what does each side log?
&lt;/h2&gt;

&lt;p&gt;This is the sharpest privacy difference between the two, and it is structural rather than a matter of policy. With Tailscale your payload stays encrypted end to end between peers. With Cloudflare Tunnel your TLS terminates at the edge, so Cloudflare handles your requests in cleartext.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale sees metadata, not content:&lt;/strong&gt; the coordination server distributes public keys and endpoint candidates so peers can find each other. It does not hold your private keys, which never leave the device. Even relayed traffic through DERP stays encrypted, because the relay forwards WireGuard packets it cannot read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare sees everything by design:&lt;/strong&gt; terminating TLS is how it serves a certificate, applies WAF rules and enforces Access policies. Request headers, URLs, cookies and bodies pass through its proxy in plaintext. That is not a flaw, it is the only way an HTTP edge can work, but it means your self-hosted service is no longer a two party conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What each dashboard records:&lt;/strong&gt; Tailscale keeps network flow logs and device audit logs on paid tiers, showing which node talked to which and when. Cloudflare keeps Access authentication events naming who logged in, and HTTP analytics on the zone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public exposure is permanent:&lt;/strong&gt; issuing a certificate for &lt;code&gt;app.example.com&lt;/code&gt; writes that name into Certificate Transparency logs, which are searchable forever. A tailnet address appears in no public log at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The mitigation if you need Cloudflare anyway:&lt;/strong&gt; keep anything sensitive behind a tailnet and publish only what genuinely needs a public URL, for example a marketing page or a webhook endpoint on a dedicated hostname.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your threat model includes the transit provider, that rules out any proxy that holds your certificate.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Odoo Community Self-Hosted vs Odoo Online: Is a Private Cloud ERP Worth It for a Family of Four?</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 03 Oct 2026 07:06:26 +0000</pubDate>
      <link>https://dev.to/john_182319291/odoo-community-self-hosted-vs-odoo-online-is-a-private-cloud-erp-worth-it-for-a-family-of-four-35ml</link>
      <guid>https://dev.to/john_182319291/odoo-community-self-hosted-vs-odoo-online-is-a-private-cloud-erp-worth-it-for-a-family-of-four-35ml</guid>
      <description>&lt;p&gt;For a family of four, self-hosted Odoo Community is worth it in exactly one case: you already run a home server or a VPS, and you want several household functions (budgeting, shared expenses, household inventory, a family project board, contacts) living in one database with one login and one backup. If that is not you, Odoo Online's free single-app tier or two small single-purpose apps will serve the household better, because Odoo's real cost is not the licence, which is zero for Community, it is the hours you spend on upgrades, backups and access rules. Odoo Community is a genuine ERP with no per-user fee and no feature meter, but it hands you the full administration burden of an ERP for a workload that four people could partly cover with a shared spreadsheet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by reader profile&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The household that already self-hosts (you run Docker on a mini PC and have Jellyfin, Nextcloud and Vaultwarden up):&lt;/strong&gt; go Odoo Community self-hosted, because the marginal cost is one more container, one more subdomain and one more line in your backup job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The single-purpose family (you only want to stop arguing about who paid for the groceries):&lt;/strong&gt; stay off Odoo entirely and use a focused tool, because one Odoo app for shared expenses means carrying the whole framework, the Postgres database and the upgrade cycle for a fraction of its surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The home-business household (a parent invoices freelance clients and the family also wants shared budgeting):&lt;/strong&gt; go Odoo Community self-hosted, because real invoicing, VAT handling and double entry accounting justify the suite, and per-user billing on the hosted side gets expensive once four people need logins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The non-technical family with a willing admin of one (one parent is comfortable, nobody else is):&lt;/strong&gt; stay on Odoo Online, because a database you cannot restore is worse than a subscription, and the single admin problem is the real failure mode here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The privacy-led household (you want financial records and household data off third party servers on principle):&lt;/strong&gt; go Odoo Community self-hosted, because Community has no licence lock, no telemetry requirement and the Postgres database is yours to dump, inspect and move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The curious tinkerer (you want to learn ERP concepts and do not mind breaking things):&lt;/strong&gt; go Odoo Community self-hosted on a disposable instance, because learning the data model is the point and nothing of value is at risk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is simple: Odoo Online charges you money per user and keeps the maintenance, while Odoo Community charges you nothing per user and hands you every upgrade, every backup and every access rule to manage yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What can Odoo actually do for a family of four?&lt;/li&gt;
&lt;li&gt;Odoo Community vs Odoo Online: what actually changes when you self-host&lt;/li&gt;
&lt;li&gt;How much does it really cost over three years?&lt;/li&gt;
&lt;li&gt;What hardware and resources does self-hosted Odoo need for four users?&lt;/li&gt;
&lt;li&gt;How do you run Odoo Community in Docker on a home server?&lt;/li&gt;
&lt;li&gt;Is moving a database off Odoo Online clean, or does it break?&lt;/li&gt;
&lt;li&gt;Which Odoo apps are worth enabling for a household, and which ones add noise?&lt;/li&gt;
&lt;li&gt;How do you give four family members the right level of access?&lt;/li&gt;
&lt;li&gt;What breaks at upgrade time, and how much work is it?&lt;/li&gt;
&lt;li&gt;Backups, restore and what happens when the household admin is away&lt;/li&gt;
&lt;li&gt;Odoo versus Firefly III, Grocy and Mealie: is one big ERP the right shape?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What can Odoo actually do for a family of four?
&lt;/h2&gt;

&lt;p&gt;Odoo is a modular business suite, not a personal finance app. You install individual apps onto one Postgres database, and each one adds models, menus and permissions to the same system. For a household, four or five of the roughly fifty official apps carry real weight, and the rest are commercial machinery you will never open.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accounting (Community edition, named Invoicing in some releases):&lt;/strong&gt; gives you double entry bookkeeping with bank accounts, journals and reconciliation, so shared household spending reconciles against a real statement import rather than a manual tally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expenses:&lt;/strong&gt; lets each family member log a receipt against a category and have it approved, which is the closest Odoo gets to the Splitwise style question of who paid for what.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventory:&lt;/strong&gt; tracks physical stock with locations and quantities, usable for a pantry, a freezer, tools or spare parts, with reordering rules that trigger when a quantity drops below a threshold you set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project and To-do:&lt;/strong&gt; provide shared task boards with assignees and deadlines, suitable for a renovation, a holiday or a weekly chore rota.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contacts and Calendar:&lt;/strong&gt; hold the household's address book and shared events, with the caveat that Odoo's calendar is not a CalDAV server, so phone sync is not native.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two honest limits. Odoo has no household mode, so every concept arrives in business vocabulary: your family members are partners or employees, and the grocery budget is an analytic account. And Community omits Odoo Studio, the no code customisation layer, so reshaping those labels means Python modules or XML views, not a settings screen.&lt;/p&gt;




&lt;h2&gt;
  
  
  Odoo Community vs Odoo Online: what actually changes when you self-host
&lt;/h2&gt;

&lt;p&gt;Odoo Online is the hosted SaaS run by Odoo SA, billed per user, with upgrades and backups handled for you. Odoo Community is the open source edition under LGPLv3 that you run yourself. They are not the same product with a different bill attached, and the gaps matter before you commit a household to one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What changes&lt;/th&gt;
&lt;th&gt;Odoo Online&lt;/th&gt;
&lt;th&gt;Odoo Community self-hosted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost model&lt;/td&gt;
&lt;td&gt;Per user per month, with a free plan limited to one app&lt;/td&gt;
&lt;td&gt;No licence fee, unlimited users, you pay for hardware and your time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise-only apps&lt;/td&gt;
&lt;td&gt;Studio, Sign, Documents and the IAP services are available&lt;/td&gt;
&lt;td&gt;Absent, so customisation means Python modules and XML views&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bank and OCR automation&lt;/td&gt;
&lt;td&gt;Bank statement sync and invoice digitisation sold as paid IAP credits&lt;/td&gt;
&lt;td&gt;Manual CSV or OFX statement import, no OCR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third party modules&lt;/td&gt;
&lt;td&gt;Blocked, you run Odoo's code only&lt;/td&gt;
&lt;td&gt;Any module, including the free OCA catalogue of community addons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upgrades&lt;/td&gt;
&lt;td&gt;Run by Odoo on request&lt;/td&gt;
&lt;td&gt;Yours, via the migration scripts or OpenUpgrade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database access&lt;/td&gt;
&lt;td&gt;No direct Postgres access&lt;/td&gt;
&lt;td&gt;Full &lt;code&gt;psql&lt;/code&gt; access, &lt;code&gt;pg_dump&lt;/code&gt; whenever you want&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where you run it is the next decision. Four users with light use is a small workload, so the realistic options are a home server or mini PC, a NAS that supports Docker, a self-managed VPS, or a managed personal server. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. All four keep the database on infrastructure you control, which is the actual point of leaving the hosted edition.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much does it really cost over three years?
&lt;/h2&gt;

&lt;p&gt;Do not compare a monthly subscription against zero. Compare it against hardware, electricity and your own hours. Odoo ships a major release roughly once a year, so a 36 month window contains two or three version jumps, and that is where self-hosting spends its real budget.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost line&lt;/th&gt;
&lt;th&gt;Odoo Online&lt;/th&gt;
&lt;th&gt;Odoo Community self-hosted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Licence&lt;/td&gt;
&lt;td&gt;Published per user, per month fee, multiplied by 4 users and 36 months&lt;/td&gt;
&lt;td&gt;Zero, LGPLv3, unlimited users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardware&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;One mini PC, NAS or VPS, amortised over 36 months, shared with your other containers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Electricity&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;A low power x86 box runs continuously, so count 36 months of standby draw at your tariff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upgrades&lt;/td&gt;
&lt;td&gt;Included, Odoo runs the migration&lt;/td&gt;
&lt;td&gt;2 to 3 major jumps, each a test restore plus a production run, measured in evenings not minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backups&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;Your own &lt;code&gt;pg_dump&lt;/code&gt; plus filestore copy, plus offsite storage you pay for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid add-ons&lt;/td&gt;
&lt;td&gt;Bank sync and OCR sold as IAP credits per document&lt;/td&gt;
&lt;td&gt;Not available, so the cost becomes your manual data entry time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest arithmetic is this. Take the published Odoo Online price for your country, multiply by 4 users and 36 months, and that is the number self-hosting must beat. Then price your own time at anything above zero and add 2 to 4 hours per upgrade cycle, plus the first weekend you lose to setup and chart of accounts configuration. If the box already exists and already has Docker, self-hosting usually wins on cash and loses on hours. If you would buy hardware purely for Odoo, the subscription often wins outright at four users.&lt;/p&gt;




&lt;h2&gt;
  
  
  What hardware and resources does self-hosted Odoo need for four users?
&lt;/h2&gt;

&lt;p&gt;Four users is a tiny workload. The constraint is not CPU, it is RAM, because Odoo keeps a Python worker process per concurrent request and Postgres wants cache of its own.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM, the real floor:&lt;/strong&gt; budget 4 GB for the box if Odoo and Postgres are the only things on it, and 8 GB if it shares a host with Jellyfin, Nextcloud or Immich, because Odoo's upstream sizing guidance assumes roughly a gigabyte per worker once the soft and hard memory limits are respected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers, sized down not up:&lt;/strong&gt; the documented rule is &lt;code&gt;workers = 2 * cores + 1&lt;/code&gt;, which on a 4 core mini PC suggests 9, and that is absurd for a household, so set &lt;code&gt;workers = 2&lt;/code&gt; in &lt;code&gt;odoo.conf&lt;/code&gt; and leave the rest of the memory to Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres, a separate container:&lt;/strong&gt; run a recent major version, 13 or newer for current Odoo releases, with its own named volume, and never let it share a volume with the Odoo filestore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk, driven by attachments not rows:&lt;/strong&gt; the database itself stays small for a family, but the filestore under &lt;code&gt;/var/lib/odoo/filestore&lt;/code&gt; grows with every scanned receipt and PDF invoice, so plan storage around how many documents you attach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture, check before you buy:&lt;/strong&gt; the official Odoo Docker images have historically targeted amd64, so a Raspberry Pi is not a safe default and an x86 mini PC or a small VPS is the lower risk choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where this runs is a separate question from how much it needs. A NAS with Docker, a self-managed VPS and a home mini PC all clear the bar. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user, and it sits in the same list of options.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you run Odoo Community in Docker on a home server?
&lt;/h2&gt;

&lt;p&gt;The shape is two containers and three volumes. Odoo publishes official images on Docker Hub, tagged by major version, so you pin &lt;code&gt;odoo:18&lt;/code&gt; rather than &lt;code&gt;latest&lt;/code&gt; and decide for yourself when to jump.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin both images:&lt;/strong&gt; one service from the &lt;code&gt;odoo&lt;/code&gt; image and one from &lt;code&gt;postgres&lt;/code&gt;, each with an explicit tag, because an unpinned Postgres that jumps a major version on &lt;code&gt;docker compose pull&lt;/code&gt; will refuse to start on the old data directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass the database credentials Odoo expects:&lt;/strong&gt; the image reads &lt;code&gt;HOST&lt;/code&gt;, &lt;code&gt;USER&lt;/code&gt; and &lt;code&gt;PASSWORD&lt;/code&gt;, and Postgres needs matching &lt;code&gt;POSTGRES_USER&lt;/code&gt;, &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; and &lt;code&gt;POSTGRES_DB&lt;/code&gt;, so a typo here produces a database selector that never loads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mount three things, not one:&lt;/strong&gt; a named volume on &lt;code&gt;/var/lib/odoo&lt;/code&gt; for the filestore and sessions, a Postgres volume on &lt;code&gt;/var/lib/postgresql/data&lt;/code&gt;, and a host directory on &lt;code&gt;/mnt/extra-addons&lt;/code&gt; so OCA modules survive a container rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expose 8069 and 8072:&lt;/strong&gt; the web interface answers on 8069, and longpolling or the gevent worker answers on 8072, which chat and live notifications need, so a reverse proxy that forwards only 8069 leaves the interface silently waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lock the first boot down:&lt;/strong&gt; set a real &lt;code&gt;admin_passwd&lt;/code&gt; in &lt;code&gt;odoo.conf&lt;/code&gt;, set &lt;code&gt;list_db = False&lt;/code&gt; once your database exists, and set &lt;code&gt;proxy_mode = True&lt;/code&gt; behind a reverse proxy so Odoo trusts the forwarded headers and generates correct HTTPS links.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That reverse proxy and certificate work is the part families underestimate. Yundera installs self-hosted apps from an app store in one click rather than assembling compose files by hand, and reaches each app on a public HTTPS subdomain via NSL.SH mesh routing, so no static IP, no port forwarding and no manual certificate setup are required. A self-managed VPS with Caddy or Nginx Proxy Manager, or a NAS with its own reverse proxy app, gets you to the same place by hand.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is moving a database off Odoo Online clean, or does it break?
&lt;/h2&gt;

&lt;p&gt;It is mechanically simple and semantically messy. Odoo Online lets you download a full backup, so you get your data out without a scraper, but a database that touched Enterprise apps will not load cleanly into Community.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Export the right artefact:&lt;/strong&gt; the Odoo Online database manager produces a zip containing &lt;code&gt;dump.sql&lt;/code&gt;, a &lt;code&gt;filestore/&lt;/code&gt; directory and &lt;code&gt;manifest.json&lt;/code&gt;, and you want the version with the filestore, because the SQL alone leaves every attachment and logo as a broken reference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the major version exactly:&lt;/strong&gt; a dump from Odoo 17 restores into Odoo 17, not into 18, so your first self-hosted deployment should pin the version you are leaving and upgrade afterwards as a separate, reversible project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uninstall Enterprise apps before you dump, not after:&lt;/strong&gt; &lt;code&gt;manifest.json&lt;/code&gt; lists installed modules, and Community has no &lt;code&gt;web_enterprise&lt;/code&gt;, Studio, Sign or Documents, so a restore that still references them throws module loading errors on first boot and leaves views blank.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect customisations to vanish:&lt;/strong&gt; anything built in Studio lives in Enterprise machinery, so field additions and custom views made there are the one category of work you genuinely lose, and you rebuild them as XML or accept the stock layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore twice, deliberately:&lt;/strong&gt; load the zip through the database manager with &lt;code&gt;list_db = True&lt;/code&gt; temporarily enabled, confirm your journals, partners and attachments open, then restore again into a clean container before you let the family near it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Budget a full evening. The data moves in minutes. Proving that four people's accounting history, document attachments and sequence numbers all survived is what takes the time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which Odoo apps are worth enabling for a household, and which ones add noise?
&lt;/h2&gt;

&lt;p&gt;Installing an Odoo app is close to irreversible. Each one pulls dependencies, adds menus and creates records, and uninstalling drops the data those modules own rather than politely hiding it. Enable fewer apps than you think you need, then add one at a time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;Enable or skip for a family&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Invoicing or Accounting&lt;/td&gt;
&lt;td&gt;Enable first, alone&lt;/td&gt;
&lt;td&gt;It forces the fiscal localisation choice, which is effectively permanent once journal entries exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project or To-do&lt;/td&gt;
&lt;td&gt;Enable&lt;/td&gt;
&lt;td&gt;Shared task boards with assignees cost nothing in configuration and get used weekly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inventory&lt;/td&gt;
&lt;td&gt;Enable only if you will actually count things&lt;/td&gt;
&lt;td&gt;It adds warehouses, operation types and stock moves, so a pantry list becomes 3 clicks instead of 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sales and CRM&lt;/td&gt;
&lt;td&gt;Skip unless a parent invoices clients&lt;/td&gt;
&lt;td&gt;They add pipelines, quotation templates and customer stages with no household meaning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website and eCommerce&lt;/td&gt;
&lt;td&gt;Skip&lt;/td&gt;
&lt;td&gt;They expose a public CMS surface you then have to secure and patch for zero family benefit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Employees and Payroll&lt;/td&gt;
&lt;td&gt;Skip&lt;/td&gt;
&lt;td&gt;Payroll localisations are an Enterprise concern, so Community gives you the HR shell without the useful part&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Point of Sale and Manufacturing&lt;/td&gt;
&lt;td&gt;Skip&lt;/td&gt;
&lt;td&gt;Both require session or routing workflows that make no sense at four users&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two practical rules. Test every app on a duplicate database first, because the database manager can clone yours in one action and the clone is the only safe place to discover what Website installs alongside itself. And watch the menu count: once the top bar carries more than 6 or 7 apps, the people who are not the household admin stop opening Odoo at all, which quietly ends the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you give four family members the right level of access?
&lt;/h2&gt;

&lt;p&gt;Community has no per-user fee, so create four real users rather than sharing one login. The work is not account creation, it is deciding who can see the bank journal.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate internal users from portal users:&lt;/strong&gt; internal users get the back office and count against nothing in Community, while a portal user sees only documents explicitly shared with them, which suits a teenager who needs to view a chore board but not the mortgage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant per app, never globally:&lt;/strong&gt; the Settings and Administration groups unlock everything including other users' records, so exactly 1 account in the household should hold them, and the other 3 get app level rights such as Accounting Billing instead of Billing Administrator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use record rules for the money:&lt;/strong&gt; group membership controls which menus appear, but row level visibility comes from &lt;code&gt;ir.rule&lt;/code&gt; records, so hiding one bank journal or one analytic account from the children means a rule, not a checkbox, and you need developer mode to see the full group list at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on two factor authentication for the admin:&lt;/strong&gt; the TOTP support in Community covers the account that can reassign every right, and that is the one worth protecting if the instance answers on a public hostname.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide about SSO deliberately:&lt;/strong&gt; the &lt;code&gt;auth_oauth&lt;/code&gt; and &lt;code&gt;auth_ldap&lt;/code&gt; modules exist in Community, so Odoo can join an existing household identity provider, but every extra authentication path is another thing that breaks at upgrade time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Expect iteration. The first configuration is always too open, the second too tight, and the version that actually works usually arrives after someone finds a report they should not have been able to open.&lt;/p&gt;




&lt;h2&gt;
  
  
  What breaks at upgrade time, and how much work is it?
&lt;/h2&gt;

&lt;p&gt;This is the single biggest difference between the two editions, and it is where a family instance either survives or quietly freezes on an old version forever. Odoo cuts a major release about once a year and maintains roughly the three most recent, so standing still is a decision with an expiry date.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There is no in place jump:&lt;/strong&gt; you move one major version at a time, 17 to 18 and then 18 to 19, because the database schema migration scripts are written per version step and skipping one leaves tables half converted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The free route is OpenUpgrade:&lt;/strong&gt; Odoo's own upgrade service is tied to its subscriptions and platforms, so the dependable Community path is the OCA OpenUpgrade project, which means running migration scripts yourself against a restored copy rather than clicking a button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third party modules decide your calendar:&lt;/strong&gt; every OCA addon in &lt;code&gt;/mnt/extra-addons&lt;/code&gt; needs a branch for the target version, and if one of your 3 or 4 installed addons has not been ported, you either wait, port it, or uninstall it and lose its data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres upgrades separately:&lt;/strong&gt; bumping the Odoo image does not touch the database engine, so plan a second, independent &lt;code&gt;pg_dump&lt;/code&gt; and restore cycle when you change Postgres major versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accounting is the risky surface:&lt;/strong&gt; reports, taxes and localisation modules change between releases, so verify a tax report and a reconciliation after the upgrade, not just that the login page loads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Realistic cost: one evening per major step on a test restore, plus a shorter production run once the test passed. Do it annually and it stays an evening. Skip three years and it becomes a project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Backups, restore and what happens when the household admin is away
&lt;/h2&gt;

&lt;p&gt;Two things must leave the box: the Postgres database and the filestore. A backup of one without the other restores an Odoo that opens, lists every invoice, and cannot show you a single attachment.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dump the database on a schedule:&lt;/strong&gt; a nightly &lt;code&gt;docker exec&lt;/code&gt; running &lt;code&gt;pg_dump -Fc&lt;/code&gt; into a dated file is enough at four users, and the compressed custom format restores selectively, which matters when you only want one table back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy the filestore in the same job:&lt;/strong&gt; archive &lt;code&gt;/var/lib/odoo/filestore&lt;/code&gt; alongside the dump and keep the pair together, because a mismatched dump and filestore produce broken document links that are tedious to trace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get a copy off the hardware:&lt;/strong&gt; restic, Borg or Duplicati to a second disk and a remote target satisfies the 3-2-1 rule, and the household's financial history is exactly the data you do not want living on 1 drive in 1 building.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test a restore every 3 months:&lt;/strong&gt; restore the latest pair into a throwaway container, log in, open a bank reconciliation and a receipt attachment, then delete it, because an untested backup is a hypothesis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the runbook down:&lt;/strong&gt; one page covering where backups live, the &lt;code&gt;admin_passwd&lt;/code&gt; master password, the restore commands and the hostname, stored in a shared vault such as Vaultwarden, is what turns a 2 week outage into a 1 hour one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The admin being away is the real risk, not disk failure. A hosted database has a support channel when the person who built it is on a plane. A self-hosted one has whoever else in the house can follow your page of instructions, so write them for someone who has never opened a terminal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Odoo versus Firefly III, Grocy and Mealie: is one big ERP the right shape?
&lt;/h2&gt;

&lt;p&gt;The fair comparison is not feature by feature, it is one database and one upgrade cycle against three or four small apps that each do one job properly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Household need&lt;/th&gt;
&lt;th&gt;Odoo Community&lt;/th&gt;
&lt;th&gt;The focused alternative&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shared budgeting and bank reconciliation&lt;/td&gt;
&lt;td&gt;Real double entry, journals and tax handling, configured once in business vocabulary&lt;/td&gt;
&lt;td&gt;Firefly III is built for personal finance, but its data model is per user, so a shared household view usually means 1 shared login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pantry and household stock&lt;/td&gt;
&lt;td&gt;Inventory with locations and reordering rules, overbuilt for a fridge&lt;/td&gt;
&lt;td&gt;Grocy tracks products, expiry dates, chores and batteries, in household language from the first screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recipes and meal planning&lt;/td&gt;
&lt;td&gt;Nothing native&lt;/td&gt;
&lt;td&gt;Mealie does recipes, meal plans and shopping lists, which Odoo cannot approach&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chores and shared tasks&lt;/td&gt;
&lt;td&gt;Project boards with assignees and deadlines&lt;/td&gt;
&lt;td&gt;Vikunja or a Nextcloud Deck board, lighter and faster to open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freelance invoicing from the same data&lt;/td&gt;
&lt;td&gt;Invoices, sequences and VAT reports that reference the same partners and accounts&lt;/td&gt;
&lt;td&gt;Needs a separate invoicing tool and manual reconciliation between systems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational load&lt;/td&gt;
&lt;td&gt;2 containers, 1 Postgres, 1 annual upgrade, 1 backup pair&lt;/td&gt;
&lt;td&gt;3 or 4 containers, 3 or 4 release cycles, 3 or 4 backup targets to remember&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The deciding question is whether your household genuinely needs one of those rows joined to another. If invoices, expenses and the shared budget must reconcile against each other, Odoo's single database earns its weight. If the needs are a budget, a pantry list and a chore board that never touch, the focused apps win, because each one is understandable by every member of the family rather than only the admin.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>opensource</category>
      <category>homelab</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How to Self-Host WordPress with Docker and Strip Every Third-Party Tracker</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 01 Oct 2026 07:06:23 +0000</pubDate>
      <link>https://dev.to/john_182319291/how-to-self-host-wordpress-with-docker-and-strip-every-third-party-tracker-4egb</link>
      <guid>https://dev.to/john_182319291/how-to-self-host-wordpress-with-docker-and-strip-every-third-party-tracker-4egb</guid>
      <description>&lt;p&gt;A self-hosted WordPress install is not private by default. A stock install of WordPress 6.x on Docker reaches out to api.wordpress.org on a schedule, loads visitor avatars from Gravatar, and the moment you add Jetpack, Akismet, Elementor or a theme that bundles Google Fonts remotely, your visitors' IP addresses start landing in logs you do not control. The fix is mechanical and takes an afternoon: a three-container compose stack, roughly a dozen constants in &lt;code&gt;wp-config.php&lt;/code&gt;, a handful of &lt;code&gt;remove_action&lt;/code&gt; calls in a must-use plugin, locally hosted fonts, and a network capture to prove the page now talks to exactly one domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The two person startup running its own marketing site (a founder and a designer, no sysadmin):&lt;/strong&gt; Self-host WordPress on a small VPS or a Personal Cloud Server and spend the afternoon on the de-telemetry pass, because the alternative is paying a hosted builder monthly and still handing visitor data to its CDN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agency shipping client sites under GDPR scrutiny (a two person studio with EU clients):&lt;/strong&gt; Do the full pass and keep it as a reusable mu-plugin, because "we removed Google Analytics" is not a defensible answer when the theme still pulls fonts.google.com on every page load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The homelab tinkerer running WordPress behind Tailscale or a reverse proxy:&lt;/strong&gt; Strip the outbound calls anyway, because an internal-only site that still phones home leaks the fact that your instance exists along with your server's IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The developer who wants automatic updates and plugin installs from the dashboard:&lt;/strong&gt; Use the partial profile, leave &lt;code&gt;DISALLOW_FILE_MODS&lt;/code&gt; off and block only the avatar, font and analytics calls, because full lockdown trades convenience for a monthly manual update ritual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The site owner with an existing WordPress install on shared hosting:&lt;/strong&gt; Migrate to Docker first, then strip, because you cannot capture outbound traffic or edit &lt;code&gt;php.ini&lt;/code&gt; on a host that gives you cPanel and nothing underneath it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reader who only needs a brochure site and no comments, no forms, no accounts:&lt;/strong&gt; Consider a static generator instead, because the cheapest way to leak nothing is to ship HTML with no PHP runtime at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff: every outbound call you cut removes a convenience that WordPress built on top of it, so you are trading dashboard update notices, spam filtering and free avatars for a page that touches your domain and nothing else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What does a stock WordPress install actually send to third parties?&lt;/li&gt;
&lt;li&gt;Which Docker setup do you need before you can audit anything?&lt;/li&gt;
&lt;li&gt;How do you capture every outbound request your site makes?&lt;/li&gt;
&lt;li&gt;What exactly does Jetpack send, and what breaks when you remove it?&lt;/li&gt;
&lt;li&gt;How do you stop Gravatar leaking your commenters' email hashes?&lt;/li&gt;
&lt;li&gt;How do you self-host Google Fonts without breaking your theme?&lt;/li&gt;
&lt;li&gt;Should you block api.wordpress.org, and what do you lose?&lt;/li&gt;
&lt;li&gt;Which plugins phone home silently, and how do you catch them?&lt;/li&gt;
&lt;li&gt;How do you replace Google Analytics with something you own?&lt;/li&gt;
&lt;li&gt;What cookies does WordPress set, and which ones actually need consent?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What does a stock WordPress install actually send to third parties?
&lt;/h2&gt;

&lt;p&gt;Download WordPress 6.x, run it with nothing added, and the install is already talking to servers outside your control. Most of it is core behaviour, not plugins, and none of it is announced in the admin UI.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Update checks to api.wordpress.org:&lt;/strong&gt; Core, themes and plugins each poll separate endpoints on a twice daily schedule through WP-Cron, and the core request sends your site URL, PHP version, MySQL version, locale and installed plugin list as query parameters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gravatar avatar requests to secure.gravatar.com:&lt;/strong&gt; Every comment and every author byline renders an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; pointing at an MD5 hash of the user's email address, so your visitor's browser, not your server, makes that request and exposes their IP to Automattic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browse Happy and Serve Happy notices:&lt;/strong&gt; The dashboard calls out to check whether the visiting browser or your PHP version is considered outdated, which fires from &lt;code&gt;wp-admin&lt;/code&gt; rather than the front end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundled remote fonts in the active theme:&lt;/strong&gt; Twenty Twenty-Four ships fonts locally, but a large share of commercial themes still enqueue &lt;code&gt;fonts.googleapis.com&lt;/code&gt; in &lt;code&gt;functions.php&lt;/code&gt;, which means a font request on every single page view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;oEmbed discovery for pasted links:&lt;/strong&gt; Paste a YouTube or X URL into the editor and WordPress fetches metadata from that provider at save time, then embeds an iframe that loads on render.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where you run the stack determines whether you can see any of this. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. A self-managed VPS, a home server, a NAS or Yundera all give you container level network visibility; shared cPanel hosting does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which Docker setup do you need before you can audit anything?
&lt;/h2&gt;

&lt;p&gt;You cannot see what you cannot intercept. Shared hosting hands you a dashboard and hides the network layer, so the first step is a stack where every container is yours and &lt;code&gt;docker compose logs&lt;/code&gt; is available.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three services, not one:&lt;/strong&gt; Run &lt;code&gt;wordpress:6-php8.3-fpm&lt;/code&gt; for PHP, &lt;code&gt;mariadb:11&lt;/code&gt; for the database and &lt;code&gt;nginx:alpine&lt;/code&gt; as the front end, defined in a single &lt;code&gt;docker-compose.yml&lt;/code&gt;. The all-in-one &lt;code&gt;wordpress:apache&lt;/code&gt; image works too, but splitting nginx out lets you log and block outbound traffic at one chokepoint later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bind mount, not an anonymous volume:&lt;/strong&gt; Map &lt;code&gt;./wp-content:/var/www/html/wp-content&lt;/code&gt; so you can grep a theme's &lt;code&gt;functions.php&lt;/code&gt; for &lt;code&gt;fonts.googleapis.com&lt;/code&gt; from the host without entering the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A WP-CLI sidecar:&lt;/strong&gt; Add a &lt;code&gt;wordpress:cli&lt;/code&gt; service sharing the same volume. Commands like &lt;code&gt;wp plugin list --format=csv&lt;/code&gt; and &lt;code&gt;wp cron event list&lt;/code&gt; become one-liners, and you will use both when you hunt for scheduled phone-homes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dedicated bridge network:&lt;/strong&gt; Put the three services on their own network rather than the default bridge. This becomes the boundary you watch, and it keeps MariaDB off any published port, so only port 443 reaches the internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real DNS and TLS before you test:&lt;/strong&gt; Font fallbacks, oEmbed and update checks behave differently over plain HTTP, so audit the site at its final HTTPS hostname, not at &lt;code&gt;localhost:8080&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any of a self-managed VPS, a home server, a NAS or Yundera gives you that container level control, and Yundera installs the stack from an app store rather than from a compose file you write yourself. What matters for the audit is only that you own the host and can reach the Docker socket.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you capture every outbound request your site makes?
&lt;/h2&gt;

&lt;p&gt;Two different leaks need two different instruments. Server side calls come from PHP via the WP HTTP API and never appear in your browser. Front end calls come from the visitor's browser and never appear in your server logs. Audit both, or you will declare victory while Gravatar is still loading.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;th&gt;What it misses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browser DevTools, Network tab, hard reload&lt;/td&gt;
&lt;td&gt;Every font, avatar, iframe, script and beacon the page loads, with the full third party domain list&lt;/td&gt;
&lt;td&gt;All PHP side requests, including update checks and oEmbed fetches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pre_http_request&lt;/code&gt; filter in a mu-plugin, logging to &lt;code&gt;error_log&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every WP HTTP API call with URL, method and calling plugin&lt;/td&gt;
&lt;td&gt;Direct &lt;code&gt;curl_exec&lt;/code&gt; or raw socket calls that bypass the API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tcpdump -i any -n 'port 53'&lt;/code&gt; inside the container&lt;/td&gt;
&lt;td&gt;DNS lookups for anything the stack resolves, including bypasses&lt;/td&gt;
&lt;td&gt;Nothing on the wire, but it shows domains without payloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nginx or firewall egress logs at the host&lt;/td&gt;
&lt;td&gt;Every connection the container actually opens, by destination IP&lt;/td&gt;
&lt;td&gt;Domain names, since you see IPs and must reverse them&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start with the filter. Drop a file in &lt;code&gt;wp-content/mu-plugins/audit.php&lt;/code&gt; that hooks &lt;code&gt;pre_http_request&lt;/code&gt;, writes the URL and &lt;code&gt;debug_backtrace()&lt;/code&gt; to the PHP log, and returns &lt;code&gt;null&lt;/code&gt; so nothing is blocked. Then run &lt;code&gt;wp cron event run --due-now&lt;/code&gt; to force the scheduled checks instead of waiting 12 hours. Finally load the front page in a private window with cache disabled and count the distinct domains in DevTools. On a stock install with one commercial theme you will typically see 3 to 6 external domains. Write that number down. It is your baseline.&lt;/p&gt;




&lt;h2&gt;
  
  
  What exactly does Jetpack send, and what breaks when you remove it?
&lt;/h2&gt;

&lt;p&gt;Jetpack is not a plugin so much as a client for WordPress.com. Activating it requires connecting your site to an Automattic account, and from that point several of its features work by proxying through Automattic infrastructure rather than running on your server. That is the design, not a flaw, and it is the reason a privacy clean install cannot keep it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stats and the activity log:&lt;/strong&gt; Page views are recorded by a pixel served from Automattic, so visitor IP and user agent are processed off your server. Replace it with a self-hosted analytics tool and you lose the dashboard widget, nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photon and the CDN for images:&lt;/strong&gt; Your uploads are fetched by Automattic, cached, and served to visitors from their edge. Turn it off and images come from your own &lt;code&gt;wp-content/uploads&lt;/code&gt;, which means your server now carries that bandwidth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Related Posts and search:&lt;/strong&gt; Both are computed from an index Automattic holds of your content. Removing them costs real functionality, and the local replacements are slower on sites past a few thousand posts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Akismet spam filtering:&lt;/strong&gt; Comment text, author name, email and IP are sent to Akismet for scoring. The honest replacement is closing comments, moving them to a self-hosted system, or accepting manual moderation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downtime monitoring and backups:&lt;/strong&gt; These depend on an external service by definition. Swap in your own uptime check and a &lt;code&gt;mysqldump&lt;/code&gt; cron.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Remove it cleanly with &lt;code&gt;wp plugin deactivate jetpack &amp;amp;&amp;amp; wp plugin delete jetpack&lt;/code&gt;, then check &lt;code&gt;wp option list --search='jetpack*'&lt;/code&gt; because the connection tokens and ten or more options survive deletion. Disconnect the site from WordPress.com before deleting, otherwise the stale connection lingers on their side.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you stop Gravatar leaking your commenters' email hashes?
&lt;/h2&gt;

&lt;p&gt;A Gravatar URL is not anonymous. WordPress hashes the lowercased email address and puts that hash in the image path, and because email addresses come from a small guessable space, anyone holding the hash can confirm a specific address by hashing it themselves. The request also carries the visitor's IP and the referring page, so Automattic sees who read which article, not just who commented.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turn avatars off globally:&lt;/strong&gt; Settings, then Discussion, then uncheck Show Avatars. From the CLI that is &lt;code&gt;wp option update show_avatars 0&lt;/code&gt;. This kills the request on the front end and in the comments list, and it is the only change that removes the hash entirely rather than hiding it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not rely on the default avatar setting:&lt;/strong&gt; Choosing Mystery Person or Blank still generates a &lt;code&gt;secure.gravatar.com&lt;/code&gt; URL with the &lt;code&gt;d=&lt;/code&gt; parameter, so the lookup happens anyway. Only the Gravatar logo option behaves differently, and it is still a remote call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serve local avatars if you need faces:&lt;/strong&gt; Simple Local Avatars stores an uploaded image per user in your own media library and short circuits &lt;code&gt;get_avatar&lt;/code&gt;. Useful on a multi author blog where bylines matter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter it in code for belt and braces:&lt;/strong&gt; In your mu-plugin, add &lt;code&gt;add_filter('pre_get_avatar', ...)&lt;/code&gt; returning an empty string, or &lt;code&gt;add_filter('option_show_avatars', '__return_zero')&lt;/code&gt;. A theme or plugin that calls Gravatar directly bypasses the setting, and the filter catches most of those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the admin side too:&lt;/strong&gt; The user list, the at a glance widget and comment moderation screens all render avatars, so confirm with DevTools on &lt;code&gt;/wp-admin/users.php&lt;/code&gt; after the change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reload the front page and the external domain count from your baseline should drop by one.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you self-host Google Fonts without breaking your theme?
&lt;/h2&gt;

&lt;p&gt;Remote font loading is the leak most people miss, because the page looks identical whether the font comes from your domain or from Google. A German court has already ruled that passing visitor IP addresses to Google this way without consent is unlawful, so this is not a theoretical concern for an EU facing site.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Find the enqueue first:&lt;/strong&gt; Run &lt;code&gt;grep -ri "fonts.googleapis\|fonts.gstatic" wp-content/&lt;/code&gt; on the host. Expect hits in the theme's &lt;code&gt;functions.php&lt;/code&gt;, in &lt;code&gt;theme.json&lt;/code&gt; under &lt;code&gt;fontFace&lt;/code&gt;, and inside page builders like Elementor or Astra, which often expose a Load Fonts Locally toggle in their own settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Download only what you use:&lt;/strong&gt; A typical theme requests 2 font families at 2 weights each. Pull the woff2 files, latin subset only, and you end up with 4 to 8 files of a few tens of kilobytes rather than a stylesheet that resolves to a second domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Declare them yourself:&lt;/strong&gt; Put the files under &lt;code&gt;wp-content/themes/&amp;lt;child&amp;gt;/fonts/&lt;/code&gt;, write &lt;code&gt;@font-face&lt;/code&gt; blocks with &lt;code&gt;font-display: swap&lt;/code&gt;, and keep the exact same &lt;code&gt;font-family&lt;/code&gt; names the theme uses in its CSS. Matching the names is what stops the layout shifting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dequeue the remote handle:&lt;/strong&gt; In your mu-plugin call &lt;code&gt;wp_dequeue_style()&lt;/code&gt; and &lt;code&gt;wp_deregister_style()&lt;/code&gt; against the theme's font handle, then enqueue your local stylesheet with a later priority. OMGF automates the same job if you prefer a plugin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preload the two critical faces:&lt;/strong&gt; Add &lt;code&gt;&amp;lt;link rel="preload" as="font" crossorigin&amp;gt;&lt;/code&gt; for the body and heading weights only. Preloading all eight costs more than it saves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Serving those files is your server's job now, which is true on a self-managed VPS, a home server, a NAS or Yundera alike. Verify with DevTools that &lt;code&gt;fonts.gstatic.com&lt;/code&gt; no longer appears.&lt;/p&gt;




&lt;h2&gt;
  
  
  Should you block api.wordpress.org, and what do you lose?
&lt;/h2&gt;

&lt;p&gt;This is the one call worth keeping. The update check tells your site that WordPress 6.x shipped a security release, and a site that never learns about security releases is a worse privacy outcome than one that pings a known endpoint twice a day. The useful move is to narrow the allowlist to that single destination and block everything else by default.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;How you do it&lt;/th&gt;
&lt;th&gt;What it costs you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Allowlist only wordpress.org&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;define('WP_HTTP_BLOCK_EXTERNAL', true);&lt;/code&gt; plus &lt;code&gt;define('WP_ACCESSIBLE_HOSTS', 'api.wordpress.org,*.wordpress.org');&lt;/code&gt; in &lt;code&gt;wp-config.php&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nothing functional, and any plugin that quietly calls a vendor endpoint now fails silently, which is how you find it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drop the cosmetic pings&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;remove_action('admin_init', 'wp_check_browser_version')&lt;/code&gt; and the Serve Happy hook in your mu-plugin&lt;/td&gt;
&lt;td&gt;Two dashboard notices you were ignoring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disable auto updates, keep checks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;define('AUTOMATIC_UPDATER_DISABLED', true);&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You now apply every release by hand, so budget 15 to 30 minutes per month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full lockdown&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;define('DISALLOW_FILE_MODS', true);&lt;/code&gt; plus unscheduling &lt;code&gt;wp_version_check&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No update notices, no plugin installs from the dashboard, and you must track releases yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pick the first two rows and stop. The data in a core update request is your site URL, PHP and database versions and a plugin list, which is modest compared with losing patch awareness. If you do go to full lockdown, pair it with a rebuild pipeline: pin the image tag in &lt;code&gt;docker-compose.yml&lt;/code&gt;, update the tag deliberately, and run &lt;code&gt;wp core version&lt;/code&gt; after every rebuild to confirm what you are actually serving.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which plugins phone home silently, and how do you catch them?
&lt;/h2&gt;

&lt;p&gt;Assume every plugin with a paid tier talks to its vendor. Licence validation is legitimate, but the same request often carries an install fingerprint, and some plugins bundle a telemetry SDK that reports your active plugin list and admin email unless you decline a prompt you clicked past during setup.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Licence and activation checks:&lt;/strong&gt; Premium page builders, form plugins and SEO tools validate a key against the vendor's API, usually on a daily schedule. These cannot be removed without disabling the licence, so the honest options are to accept the call, allowlist that one host, or use a free alternative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opt in telemetry frameworks:&lt;/strong&gt; Freemius is the common one, and it appears inside dozens of freemium plugins rather than as a plugin of its own. Grep for it directly with &lt;code&gt;grep -rl "freemius\|Freemius" wp-content/plugins/&lt;/code&gt;, then check for a &lt;code&gt;wp_fs_*&lt;/code&gt; prefixed row in the options table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote asset loading:&lt;/strong&gt; Some plugins pull icon sets, map tiles or script libraries from a CDN at render time rather than bundling them. DevTools catches these because they show up as front end requests from a domain you never configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics and consent plugins:&lt;/strong&gt; A cookie banner plugin that fetches its own config from the vendor is leaking the exact visitors it claims to protect. Check these first, since they load before anything else on the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email and form delivery:&lt;/strong&gt; A contact form that posts through a vendor relay instead of your SMTP server sends submission content off your server. Point it at your own SMTP host instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your audit log from earlier makes this quick: run &lt;code&gt;wp cron event run --due-now&lt;/code&gt;, then read the mu-plugin log and map each logged URL to the plugin that called it using the backtrace.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you replace Google Analytics with something you own?
&lt;/h2&gt;

&lt;p&gt;GA4 is the single largest leak on a typical WordPress site, and it is also the easiest to remove because nothing on the site depends on it. The replacement question is really about how much infrastructure you want to run for the numbers you actually read.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it adds to your stack&lt;/th&gt;
&lt;th&gt;Tradeoff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Matomo, self-hosted&lt;/td&gt;
&lt;td&gt;One PHP container plus a database, or the WP-Matomo plugin inside the existing install&lt;/td&gt;
&lt;td&gt;Heaviest feature set, including goals and heatmaps, and the database grows fastest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plausible Community Edition&lt;/td&gt;
&lt;td&gt;Two more containers, PostgreSQL and ClickHouse&lt;/td&gt;
&lt;td&gt;Cookieless by design and a very small script, but the most moving parts to maintain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Umami&lt;/td&gt;
&lt;td&gt;One Node container plus PostgreSQL or MySQL&lt;/td&gt;
&lt;td&gt;Light and simple, with a narrower report set than Matomo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GoAccess on nginx logs&lt;/td&gt;
&lt;td&gt;Nothing in the browser at all&lt;/td&gt;
&lt;td&gt;Zero JavaScript and zero consent question, but no distinction between a human and a crawler without tuning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four practical notes. Configure IP anonymisation before you record a single hit: Matomo can mask the last 2 bytes of each address, which keeps country level reporting while discarding the identifier. Serve the tracker from your own domain, not a vendor CDN, otherwise you have moved the leak rather than closed it. Keep the script local by enqueueing it in your mu-plugin with &lt;code&gt;wp_enqueue_script&lt;/code&gt; so a theme update cannot reintroduce the Google tag. Finally, delete the old integration properly: &lt;code&gt;wp plugin list --status=active&lt;/code&gt; will often reveal a Site Kit or GA plugin still present, and the measurement ID is frequently hardcoded in the theme's header template as well.&lt;/p&gt;

&lt;p&gt;Server log analysis is the one option that survives an ad blocker.&lt;/p&gt;




&lt;h2&gt;
  
  
  What cookies does WordPress set, and which ones actually need consent?
&lt;/h2&gt;

&lt;p&gt;A clean install sets fewer cookies than most people assume, and almost all of them fall under the strictly necessary exemption, which means no banner is required for them. The banner question is created by what you add, not by WordPress itself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wordpress_test_cookie&lt;/code&gt;:&lt;/strong&gt; Set on the login page to confirm the browser accepts cookies, and expires with the session. Strictly necessary, no consent needed, and it never reaches a logged out visitor browsing an article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wordpress_sec_&lt;/code&gt; and &lt;code&gt;wordpress_logged_in_&lt;/code&gt; with a hash suffix:&lt;/strong&gt; The authentication pair, scoped to the admin area and the site respectively. They last 2 days, or 14 days when the user ticks Remember Me. Necessary for a logged in session, so exempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wp-settings-{user_id}&lt;/code&gt; and &lt;code&gt;wp-settings-time-{user_id}&lt;/code&gt;:&lt;/strong&gt; Store admin interface preferences such as editor state for 1 year. Only ever set for authenticated users, so again exempt, and irrelevant on a site with two staff accounts and no public registration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;comment_author_&lt;/code&gt;, &lt;code&gt;comment_author_email_&lt;/code&gt; and &lt;code&gt;comment_author_url_&lt;/code&gt;:&lt;/strong&gt; Set when someone comments and retained for roughly a year. Since WordPress 4.9.6 the comment form carries a tick box that gates them, and &lt;code&gt;comment_cookies_consent&lt;/code&gt; records the choice. Leave that box unticked by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce session and cart cookies:&lt;/strong&gt; &lt;code&gt;woocommerce_cart_hash&lt;/code&gt;, &lt;code&gt;woocommerce_items_in_cart&lt;/code&gt; and &lt;code&gt;wp_woocommerce_session_&lt;/code&gt; appear the moment you install the plugin. Cart function is necessary; the marketing extensions bolted onto it usually are not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify the real list rather than trusting a policy template. Open DevTools, Application, Cookies, then load the front page in a private window as an anonymous visitor. If the only entry is a cookieless analytics flag or nothing at all, you do not need a consent banner for a reading visitor.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>wordpress</category>
      <category>privacy</category>
      <category>docker</category>
    </item>
    <item>
      <title>Best Spliit Alternatives for Self-Hosting: When Splitwise, a Spreadsheet or a Plain-Text Ledger Wins</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 29 Sep 2026 07:06:29 +0000</pubDate>
      <link>https://dev.to/john_182319291/best-spliit-alternatives-for-self-hosting-when-splitwise-a-spreadsheet-or-a-plain-text-ledger-wins-23pp</link>
      <guid>https://dev.to/john_182319291/best-spliit-alternatives-for-self-hosting-when-splitwise-a-spreadsheet-or-a-plain-text-ledger-wins-23pp</guid>
      <description>&lt;p&gt;Self-host Spliit only if the group splitting the bills is stable, recurring and at least four people, and you already run Postgres with nightly backups for something else. For a single holiday, a two-person household or a group that scatters after one weekend, the hosted instance at spliit.app, Splitwise, a shared spreadsheet or three lines in a plain-text ledger will settle the same money with none of the container, database, domain and backup upkeep. Spliit is a genuinely good open source Splitwise alternative, but it is a web app with no accounts and no notifications, so the person who hosts it inherits every support request from everyone else in the group.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by reader profile:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solo developer with an established homelab (you already run a Docker host, Postgres and restic snapshots for a flatshare of four):&lt;/strong&gt; self-host Spliit, because the marginal cost is one container, one database and one subdomain on infrastructure you already patch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Occasional trip organiser (one ten day ski trip a year, eight people, half of them on iPhones you will never configure):&lt;/strong&gt; use the hosted spliit.app instance or Splitwise, because uptime and link sharing matter more to you than owning the rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flatshare with fixed monthly bills (rent, power, internet, same four names every month):&lt;/strong&gt; use a shared spreadsheet with one row per bill, because your split rarely changes and a sheet needs no reverse proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain-text accounting user (you already keep hledger or beancount files in Git):&lt;/strong&gt; keep using the ledger, because one &lt;code&gt;split&lt;/code&gt; posting per shared expense reuses a workflow you trust and version control you already back up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nextcloud owner (you run Nextcloud for files and calendars anyway):&lt;/strong&gt; install Cospend in Nextcloud instead, because you inherit real user accounts, sharing and existing backups rather than adding a second stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy-driven self-hoster (you will not put who you travelled with on a third-party server):&lt;/strong&gt; self-host Spliit, but plan the network exposure first, because its group URLs are the only access control it ships with.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is simple: Spliit gives you ownership of the data and none of the convenience of an account system, so you are trading a managed service's uptime and push notifications for a container you patch, a database you back up and a link every group member must not lose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What Spliit actually does, and what it leaves out on purpose&lt;/li&gt;
&lt;li&gt;Which Spliit alternatives are genuinely comparable?&lt;/li&gt;
&lt;li&gt;When is hosted Splitwise the better answer than a Spliit container?&lt;/li&gt;
&lt;li&gt;Can a shared spreadsheet replace Spliit for a four-person flatshare?&lt;/li&gt;
&lt;li&gt;When does a plain-text ledger beat Spliit outright?&lt;/li&gt;
&lt;li&gt;Spliit vs Nextcloud Cospend: which self-hosted splitter fits a homelab?&lt;/li&gt;
&lt;li&gt;What does Spliit's no-login group URL model mean for you?&lt;/li&gt;
&lt;li&gt;Does Spliit survive contact with non-technical group members?&lt;/li&gt;
&lt;li&gt;Are the receipt scanning and S3 document features worth wiring up?&lt;/li&gt;
&lt;li&gt;How much maintenance does a Spliit stack really cost over three years?&lt;/li&gt;
&lt;li&gt;Where should you run Spliit, and who holds the data?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Spliit actually does, and what it leaves out on purpose
&lt;/h2&gt;

&lt;p&gt;Spliit is an open source expense splitter: you create a group, add the people in it, log expenses, and it tells you who owes whom. Sébastien Castiel maintains it as a Next.js application backed by PostgreSQL, published as a Docker image, with a free hosted instance at spliit.app. It covers the core Splitwise job well: splits by shares, exact amounts or percentages, expense categories, running balances and a suggested set of reimbursements that clears the debts in the fewest transfers.&lt;/p&gt;

&lt;p&gt;The omissions are deliberate, and they decide whether self-hosting makes sense for you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No user accounts at all:&lt;/strong&gt; access to a group is the group URL, which contains a random identifier, so there is nothing to log into, no password reset, and no per-member permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No notifications:&lt;/strong&gt; Spliit sends no email and no push reminders, so nobody is nudged to settle up and you chase people in your group chat anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No money movement:&lt;/strong&gt; it records that Sam owes you 42 EUR, it does not transfer anything, so you still settle in your banking app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity lives in the browser:&lt;/strong&gt; the active participant is remembered client side, so opening the same group on a phone and a laptop means selecting who you are again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No multi-currency conversion:&lt;/strong&gt; you set a currency symbol for the group, so a trip mixing EUR and CHF receipts needs you to convert before entering the amount.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else in this article compares alternatives against that specific shape: strong splitting mathematics, zero account management, and a database you now own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which Spliit alternatives are genuinely comparable?
&lt;/h2&gt;

&lt;p&gt;Four things compete with a self-hosted Spliit container, and only two of them are apps. Ruling out the wrong comparison early saves you a weekend: a budgeting tool like Actual Budget or Firefly III tracks your own money over time, it does not compute who owes whom inside a group, so it is not an alternative here no matter how good it is.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Where it beats a self-hosted Spliit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;spliit.app&lt;/td&gt;
&lt;td&gt;The maintainer's own hosted instance of the same code&lt;/td&gt;
&lt;td&gt;Identical features and identical group URLs, with somebody else running Postgres and the upgrades&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Splitwise&lt;/td&gt;
&lt;td&gt;Hosted commercial service with accounts, apps on iOS and Android, and a paid tier&lt;/td&gt;
&lt;td&gt;Real logins, push reminders and a phone app your group already has installed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nextcloud Cospend&lt;/td&gt;
&lt;td&gt;A group expense app installed into an existing Nextcloud instance&lt;/td&gt;
&lt;td&gt;Reuses Nextcloud users, sharing and your existing backup job instead of adding a second stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared spreadsheet&lt;/td&gt;
&lt;td&gt;One Google Sheets or Excel file with a row per expense&lt;/td&gt;
&lt;td&gt;Editable by five people at once, no deployment, and everyone already knows how to use it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain-text ledger&lt;/td&gt;
&lt;td&gt;An hledger, Ledger or Beancount file in Git&lt;/td&gt;
&lt;td&gt;Auditable history, arbitrary reporting, and version control you already have&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hosted mobile-first tools such as Tricount and Settle Up sit beside Splitwise: no self-hosting option, so relevant only if you conclude you should not self-host at all. The rest of this article works through when each of these five wins on the merits.&lt;/p&gt;




&lt;h2&gt;
  
  
  When is hosted Splitwise the better answer than a Spliit container?
&lt;/h2&gt;

&lt;p&gt;Splitwise wins whenever the people in the group matter more than the rows in the database. It has native iOS and Android apps, accounts with password recovery, and reminders that arrive without you sending them. Spliit has none of those, so the moment your group includes someone who will not keep a URL safe, the hosted service is doing work your container cannot.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One-off groups:&lt;/strong&gt; a single ten day trip with eight people does not justify a Postgres instance you will still be patching in two years, and Splitwise costs you nothing to start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groups that must not depend on you:&lt;/strong&gt; if you are away with no laptop when your home server reboots into a failed migration, nobody can add an expense, and the people chasing money are your friends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone-first participants:&lt;/strong&gt; Splitwise is installed from the App Store or Play Store in one tap, while Spliit is a web app you have to explain, including how to add it to a home screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nagging and settle-up prompts:&lt;/strong&gt; Splitwise pushes reminders, and its paid tier adds extras billed per person per year, so check the current figure before comparing it against your hosting bill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choose Splitwise unless you have a concrete reason not to, typically that you refuse to put your travel and flatmate history on a third-party service. If that is your reason, the hosting decision is separate from the app decision: a self-managed VPS, a home server, a NAS or a managed option all run the same image. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user.&lt;/p&gt;




&lt;h2&gt;
  
  
  Can a shared spreadsheet replace Spliit for a four-person flatshare?
&lt;/h2&gt;

&lt;p&gt;For fixed monthly bills, yes, and it usually wins. A flatshare paying rent, power, internet and water splits the same four amounts between the same four names every month. That is a table, not an application. Six columns get you there: date, description, payer, amount, split rule, and a per-person column each. One &lt;code&gt;=SUMIF(D:D,"Sam",E:E)&lt;/code&gt; style total per flatmate gives you the balance Spliit would have computed, and Google Sheets or a file in a Nextcloud instance handles four people editing at once.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Setup time:&lt;/strong&gt; a working sheet takes about 15 minutes, against a container, a Postgres volume, a subdomain and a backup job for Spliit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero access control problems:&lt;/strong&gt; you share the sheet with four Google accounts or four Nextcloud users, and you can revoke one of them without invalidating everyone else's link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arbitrary rules:&lt;/strong&gt; one flatmate paying 30 percent of the internet because they work from home is one edited formula, where an app makes you express it as shares or exact amounts on every single expense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free-form history:&lt;/strong&gt; you can add a column for the meter reading or the landlord's invoice number, which no expense splitter will model for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The sheet breaks down in three specific cases: high expense volume, where 60 grocery receipts a month makes manual entry miserable; phone entry, where typing into a spreadsheet at a supermarket till is genuinely worse than Spliit's form; and trust, where one careless paste overwrites a formula and nobody notices for a month. If two of those three apply to you, stop defending the sheet.&lt;/p&gt;




&lt;h2&gt;
  
  
  When does a plain-text ledger beat Spliit outright?
&lt;/h2&gt;

&lt;p&gt;When you already keep one. If your finances live in an hledger, Ledger or Beancount file in Git, shared expenses are just postings, and you get per-person balances from a tool you trust with your own money. A grocery run you paid for, split three ways, is four lines in &lt;code&gt;shared.journal&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;2026-03-14 Supermarket&lt;/span&gt;
    &lt;span class="s"&gt;expenses:food           &lt;/span&gt;&lt;span class="m"&gt;20.00&lt;/span&gt;
    &lt;span class="s"&gt;assets:owed:sam         &lt;/span&gt;&lt;span class="m"&gt;20.00&lt;/span&gt;
    &lt;span class="s"&gt;assets:owed:alex        &lt;/span&gt;&lt;span class="m"&gt;20.00&lt;/span&gt;
    &lt;span class="s"&gt;assets:bank            -60.00&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;hledger -f shared.journal bal assets:owed&lt;/code&gt; prints exactly what Spliit's balances screen would show, and &lt;code&gt;hledger reg assets:owed:sam&lt;/code&gt; gives the full history behind one number.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version control for free:&lt;/strong&gt; every change is a commit with an author and a timestamp, which is stronger provenance than any expense app gives you, and your existing Git remote is the backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reporting you cannot get otherwise:&lt;/strong&gt; monthly shared spend by category, or a 3 year trend across two different flatshares, is one command with &lt;code&gt;--monthly&lt;/code&gt; and a period filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-currency done properly:&lt;/strong&gt; ledger tools price commodities, so a trip mixing EUR and CHF is recorded at the rate on the day rather than converted by hand before entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No infrastructure at all:&lt;/strong&gt; a text file has no Postgres to upgrade, no image to patch and no URL to leak.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost is brutal and obvious: nobody else in the group will ever touch it. You become the single point of entry for every receipt, and settling up means exporting a summary your flatmates will believe. Use a ledger when the group is you plus people who are happy to send you photos of receipts, and never when four people all want to log their own spending.&lt;/p&gt;




&lt;h2&gt;
  
  
  Spliit vs Nextcloud Cospend: which self-hosted splitter fits a homelab?
&lt;/h2&gt;

&lt;p&gt;If you have already decided to self-host, this is the real comparison, and it is decided by what is already running on your box. Cospend is a Nextcloud app by Julien Veyssier, installed from the Nextcloud app store into an instance you already operate. Spliit is a standalone Next.js container plus its own PostgreSQL database.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Spliit&lt;/th&gt;
&lt;th&gt;Cospend in Nextcloud&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What you add&lt;/td&gt;
&lt;td&gt;One app container, one Postgres container, one volume, one subdomain&lt;/td&gt;
&lt;td&gt;One app inside an existing Nextcloud, no new container and no new database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;td&gt;No accounts, the group URL is the access token&lt;/td&gt;
&lt;td&gt;Nextcloud users and groups, plus link access for people without an account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backups&lt;/td&gt;
&lt;td&gt;A new &lt;code&gt;pg_dump&lt;/code&gt; target you must remember to add to your backup job&lt;/td&gt;
&lt;td&gt;Covered by whatever already backs up Nextcloud's database and data directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile&lt;/td&gt;
&lt;td&gt;A web app you add to the home screen yourself&lt;/td&gt;
&lt;td&gt;Web interface plus the MoneyBuster companion app on Android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure blast radius&lt;/td&gt;
&lt;td&gt;Breaking Spliit breaks only expense splitting&lt;/td&gt;
&lt;td&gt;A bad Nextcloud upgrade takes files, calendars and expenses down together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule is uncomplicated. Running Nextcloud already? Install Cospend and save yourself a stack. Not running Nextcloud? Do not deploy one just to get expense splitting, because you would be adding a large PHP application and its cron requirements to solve a four person grocery problem. Spliit is the smaller commitment in that case, on a VPS, a home server, a NAS or Yundera, and its single purpose means an upgrade can only break the one thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does Spliit's no-login group URL model mean for you?
&lt;/h2&gt;

&lt;p&gt;It means the URL is the password, and it behaves like a password you cannot change. Anyone holding the group link can read every expense, add new ones, edit amounts and delete history. There is no revocation, no per-member permission, and no record of which participant made a change, so a wrong edit is indistinguishable from a malicious one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The link spreads the way links spread:&lt;/strong&gt; it lands in WhatsApp, Telegram and iMessage threads, and some chat platforms fetch previews for URLs they see, so the address leaves your group's control the moment you paste it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Putting a login in front breaks the feature:&lt;/strong&gt; wrapping Spliit in Authelia, Authentik or basic auth means every flatmate needs credentials on your server, which removes the one thing that made sharing painless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A VPN only works if everyone joins it:&lt;/strong&gt; binding Spliit to Tailscale is the cleanest protection, and it also means installing Tailscale on your sister's phone before she can log a 12 EUR taxi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search engines are not the threat, carelessness is:&lt;/strong&gt; add a &lt;code&gt;robots.txt&lt;/code&gt; deny and no indexer will crawl the group, but a screenshot of the browser with the address bar visible undoes that in one post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data is more sensitive than it looks:&lt;/strong&gt; dates, amounts, descriptions and names together show who you travelled with and when, which is exactly the history a hosted service would also hold.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Practical stance: treat each group URL as a bearer token with an unlimited lifetime. If that sentence makes you uncomfortable for your particular group, Splitwise accounts or Cospend's Nextcloud users are the honest answer, not a reverse proxy trick.&lt;/p&gt;




&lt;h2&gt;
  
  
  Does Spliit survive contact with non-technical group members?
&lt;/h2&gt;

&lt;p&gt;Usually, but you become the support desk, and that cost is the one people underestimate. Spliit's entry form is clean and the maths is invisible, so the app itself is not the problem. The problem is everything around it: getting it onto phones, keeping the link findable, and answering questions at 23:00 on the second night of a trip.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Home screen installation is platform specific:&lt;/strong&gt; on iOS it is Safari, Share, then Add to Home Screen, and on Android Chrome it is the menu then Install app, so you write two sets of instructions for a group of eight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every device asks who you are again:&lt;/strong&gt; the active participant is stored per browser, so someone switching from their laptop to their phone selects their own name a second time and often picks the wrong one first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A lost link is a support ticket for you:&lt;/strong&gt; there is no password reset page to send them to, only you re-sending the URL, and you will re-send it more than once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing reminds anyone:&lt;/strong&gt; with no email or push notification, expenses go unlogged for 4 or 5 days and get entered from memory, which is where the wrong amounts come from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your uptime is now social:&lt;/strong&gt; a failed container restart during a holiday is not a homelab incident, it is three people unable to log what they just paid for.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The realistic test is one question: if two of the group refuse to enter their own expenses, you are the only user, and a plain-text ledger or a spreadsheet does the same job with no deployment. Spliit earns its keep only when at least 3 people in the group actually type into it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Are the receipt scanning and S3 document features worth wiring up?
&lt;/h2&gt;

&lt;p&gt;Only for high volume paper receipts, and only if you have already accepted a third party in the loop. These are optional features, disabled until you add environment variables, so check the repository's &lt;code&gt;.env.example&lt;/code&gt; for the exact names your version expects before copying anything.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Receipt attachments need object storage, not a volume:&lt;/strong&gt; the document feature is switched on with a flag such as &lt;code&gt;NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS&lt;/code&gt; and then expects S3 style credentials, bucket, region and endpoint, so you are running MinIO or Garage locally or paying a provider like Backblaze B2 or Cloudflare R2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scanning sends the image off your server:&lt;/strong&gt; the extraction feature uses an OpenAI API key, so every scanned receipt, with its merchant, date and total, leaves the machine you deliberately chose to own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost is per call, not per month:&lt;/strong&gt; vision requests are billed per token, so the shape is a small charge each time somebody scans, which is fine for 20 receipts on a trip and worth measuring before you leave it enabled for a year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your backup surface doubles:&lt;/strong&gt; a &lt;code&gt;pg_dump&lt;/code&gt; no longer captures everything, because the images live in the bucket, and a restore that brings back expenses with broken attachment links is a half restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual entry is faster than you think:&lt;/strong&gt; typing 9.80 and the word Taxi takes about 10 seconds, which is the honest benchmark any scanner has to beat.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Recommendation: leave both flags off for a flatshare with fixed bills. Turn them on for expense claims you may need to justify later, where the photo of the receipt is the point and the split is secondary.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much maintenance does a Spliit stack really cost over three years?
&lt;/h2&gt;

&lt;p&gt;Not much per event, but the events never stop, and three years is long enough that you hit every category at least once. Budget a few hours a year in total, distributed badly: mostly 10 minute jobs plus one bad evening.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recurring task&lt;/th&gt;
&lt;th&gt;Realistic cadence over 3 years&lt;/th&gt;
&lt;th&gt;What happens if you skip it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pull a new app image and let Prisma migrations run on start&lt;/td&gt;
&lt;td&gt;Every 1 to 2 months if you track releases&lt;/td&gt;
&lt;td&gt;You drift far enough behind that a later upgrade jumps several schema changes at once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL major version upgrade&lt;/td&gt;
&lt;td&gt;At least once, since majors ship yearly and support windows run about 5 years&lt;/td&gt;
&lt;td&gt;Your database container eventually runs an unsupported major with no security fixes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify a restore, not just that the backup ran&lt;/td&gt;
&lt;td&gt;Once a quarter, 15 minutes with &lt;code&gt;pg_restore&lt;/code&gt; into a scratch database&lt;/td&gt;
&lt;td&gt;You discover on the day you need it that you have been dumping an empty volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain and certificate upkeep&lt;/td&gt;
&lt;td&gt;Renewal yearly, certificate automation checked after each reverse proxy change&lt;/td&gt;
&lt;td&gt;The group URL breaks and nobody can reach the app until you notice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix an upgrade that changed configuration&lt;/td&gt;
&lt;td&gt;Expect one occurrence, an evening's work&lt;/td&gt;
&lt;td&gt;The container restarts into a loop and the group has no expense tracker that week&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Compare that honestly against the alternatives. Splitwise and spliit.app cost you zero of these. A spreadsheet costs you zero. A Git-backed ledger costs you a push. If the phrase "one bad evening every 18 months" annoys you rather than interests you, that is a real signal, not laziness.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where should you run Spliit, and who holds the data?
&lt;/h2&gt;

&lt;p&gt;If the reason you are self-hosting is data sovereignty, be precise about it, because Spliit's group data is small and its exposure requirements are large. The database holds names, dates, amounts and descriptions for everyone in the group, not just you, so you are now the custodian of other people's records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What self-hosting actually buys you:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No third-party account tied to your social graph:&lt;/strong&gt; the group exists only in your Postgres volume, so nobody profiles who you travel with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jurisdiction you choose:&lt;/strong&gt; a home server or a VPS in a country you picked keeps the data under one legal regime instead of whichever one the vendor operates in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention on your terms:&lt;/strong&gt; a group from 2023 stays until you run a &lt;code&gt;DELETE&lt;/code&gt;, and no product decision archives it for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit without negotiation:&lt;/strong&gt; you can &lt;code&gt;pg_dump&lt;/code&gt; the whole thing today and move it anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where to run it is a separate question from whether to. A home server behind CGNAT needs a tunnel or a relay before anyone outside your LAN can load the app. A VPS solves reachability and moves the disk to a provider. A NAS running Docker works if the unit tolerates a Postgres write load. Yundera is another option alongside those three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you host it for other people, confirm:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The group URL travels over HTTPS only:&lt;/strong&gt; no plain HTTP fallback on the reverse proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups are encrypted and offsite:&lt;/strong&gt; one copy on the same disk is not a backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can name your recovery time:&lt;/strong&gt; how long from dead host to working group URL, in hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The group knows it is your server:&lt;/strong&gt; they should know who holds the data before they add a single expense.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>selfhosted</category>
      <category>homelab</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How to Back Up Self-Hosted Spliit: PostgreSQL Dumps, S3 Receipts and a Docker Restore That Loses Nothing</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 26 Sep 2026 07:06:33 +0000</pubDate>
      <link>https://dev.to/john_182319291/how-to-back-up-self-hosted-spliit-postgresql-dumps-s3-receipts-and-a-docker-restore-that-loses-1l2f</link>
      <guid>https://dev.to/john_182319291/how-to-back-up-self-hosted-spliit-postgresql-dumps-s3-receipts-and-a-docker-restore-that-loses-1l2f</guid>
      <description>&lt;p&gt;A complete Spliit backup is three things, not one: a compressed &lt;code&gt;pg_dump&lt;/code&gt; of the PostgreSQL database, a copy of the S3 or MinIO bucket holding receipt images, and the &lt;code&gt;.env&lt;/code&gt; file that defines your base URL and storage credentials. Capture all three on the same schedule, keep one copy off the machine that runs the containers, and restore them in that order onto a Spliit image of the same minor version. Everything else in the stack, the Next.js application container included, is disposable and rebuilt from your compose file in minutes. The piece almost everyone forgets is the index of your group links, which lives in your browser's local storage and not in the database at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by reader profile:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The two person household (a couple splitting rent, groceries and one streaming bill):&lt;/strong&gt; a nightly &lt;code&gt;pg_dump&lt;/code&gt; written to a second disk covers you, because the database is measured in kilobytes and you use no receipt uploads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The flatshare treasurer with five people and two years of history:&lt;/strong&gt; add dated dumps with a retention window of roughly 30 files, because a wrong split or a deleted expense can go unnoticed for weeks and yesterday's copy will not help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The trip organiser who scans every receipt:&lt;/strong&gt; back up the bucket before you worry about anything else, because image blobs are the only part of Spliit nobody can retype from memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The privacy-conscious user with no sysadmin background:&lt;/strong&gt; reduce the whole job to one scripted command and one calendar reminder to rehearse the restore, because an untested backup is a guess rather than a plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The homelab owner already running PostgreSQL for other apps:&lt;/strong&gt; dump Spliit's database on its own rather than taking a cluster wide dump, because a single database dump restores without disturbing your other services.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff: logical dumps are portable and survive PostgreSQL and Spliit upgrades but need a script and a schedule, while volume snapshots are trivial to take and brittle to restore once versions drift.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What do you actually need to back up in a self-hosted Spliit stack?&lt;/li&gt;
&lt;li&gt;Where Spliit keeps your data: PostgreSQL rows, S3 receipt images and browser local storage&lt;/li&gt;
&lt;li&gt;How do you dump the Spliit PostgreSQL database without stopping the container?&lt;/li&gt;
&lt;li&gt;pg_dump versus a Docker volume snapshot: which one restores cleanly?&lt;/li&gt;
&lt;li&gt;Backing up the S3 or MinIO bucket that holds your Spliit receipts&lt;/li&gt;
&lt;li&gt;Which secrets and environment variables belong in the backup?&lt;/li&gt;
&lt;li&gt;A nightly Spliit backup script you can drive from cron&lt;/li&gt;
&lt;li&gt;How much storage do three years of Spliit backups really need?&lt;/li&gt;
&lt;li&gt;How do you restore Spliit onto a new host, step by step?&lt;/li&gt;
&lt;li&gt;What breaks during a restore: Prisma migrations, image drift and group URLs&lt;/li&gt;
&lt;li&gt;How do you rehearse a Spliit restore without touching the live instance?&lt;/li&gt;
&lt;li&gt;Where should you run Spliit, and where should the backups land?&lt;/li&gt;
&lt;li&gt;Privacy and data sovereignty: what a Spliit backup reveals about your household&lt;/li&gt;
&lt;li&gt;Which Spliit backup plan fits you?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What do you actually need to back up in a self-hosted Spliit stack?
&lt;/h2&gt;

&lt;p&gt;Four things, and only one of them is the database. A default Spliit deployment is a Next.js container talking to PostgreSQL over &lt;code&gt;DATABASE_URL&lt;/code&gt;, with optional S3 compatible storage for expense document images. Strip away what Docker can rebuild and this is your real backup surface.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The PostgreSQL database:&lt;/strong&gt; every group, participant, expense, split, category and recurring entry lives here, so a logical dump taken with &lt;code&gt;pg_dump -Fc&lt;/code&gt; is the single most important file you produce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The S3 or MinIO bucket:&lt;/strong&gt; uploaded receipt images are stored as objects outside the database, which means a perfect dump restored against an empty bucket gives you expenses whose attachments load as broken links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;.env&lt;/code&gt; file and compose file:&lt;/strong&gt; &lt;code&gt;DATABASE_URL&lt;/code&gt;, &lt;code&gt;NEXT_PUBLIC_BASE_URL&lt;/code&gt;, your &lt;code&gt;S3_*&lt;/code&gt; credentials and any OpenAI key for receipt scanning are configuration, not data, and losing them turns a 10 minute restore into an afternoon of guesswork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The group URLs themselves:&lt;/strong&gt; Spliit identifies each group by a random id in its address, and your browser keeps the list of groups you have visited in local storage, so export or write down those links before you need them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you do not back up is equally clear: the application image, &lt;code&gt;node_modules&lt;/code&gt;, the build cache and the container filesystem are all reproducible from &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Where the stack runs changes none of this. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. A self-managed VPS, a NAS with Container Manager, a spare mini PC at home and Yundera all present the same four items to protect.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Spliit keeps your data: PostgreSQL rows, S3 receipt images and browser local storage
&lt;/h2&gt;

&lt;p&gt;Three storage locations, three different failure modes. Knowing which is which tells you why a single copy of one of them is never a backup.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The PostgreSQL data directory:&lt;/strong&gt; inside the database container this is &lt;code&gt;/var/lib/postgresql/data&lt;/code&gt;, normally mapped to a named Docker volume such as &lt;code&gt;spliit_postgres_data&lt;/code&gt;, and it holds the Prisma managed tables for groups, participants, expenses, paid-for splits, categories and recurring expenses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;_prisma_migrations&lt;/code&gt; table:&lt;/strong&gt; this sits in the same database and records which migrations have already run, so it is the row set that decides whether a restored dump boots cleanly against a given Spliit image or fails on startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The S3 compatible bucket:&lt;/strong&gt; receipt and document uploads go to object storage, and the database keeps only a pointer row per attachment with its URL and dimensions, so the bytes never appear in &lt;code&gt;pg_dump&lt;/code&gt; output at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your browser's local storage:&lt;/strong&gt; Spliit addresses each group by a random identifier in its URL and remembers the groups you have opened on the device you opened them from, which means clearing site data on one laptop can hide groups that still exist perfectly well in the database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two practical consequences follow. Restoring a dump onto a fresh bucket produces expenses with unreachable images, and restoring a bucket without the dump produces orphaned objects nothing references. Check both locations on whatever you run, whether that is a VPS, a NAS, a mini PC at home or Yundera, before you trust a schedule you have not tested.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you dump the Spliit PostgreSQL database without stopping the container?
&lt;/h2&gt;

&lt;p&gt;You do not need downtime. PostgreSQL dumps run inside a transaction with a consistent snapshot, so &lt;code&gt;pg_dump&lt;/code&gt; against a live Spliit instance produces a coherent file even while someone is adding an expense.&lt;/p&gt;

&lt;p&gt;The command runs in the database container, not on the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; spliit &lt;span class="nt"&gt;-d&lt;/span&gt; spliit &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; spliit-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M&lt;span class="si"&gt;)&lt;/span&gt;.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use the container's own &lt;code&gt;pg_dump&lt;/code&gt;:&lt;/strong&gt; running the client from inside the image guarantees the client and server versions match, which matters because &lt;code&gt;pg_dump&lt;/code&gt; from PostgreSQL 15 refuses to dump a PostgreSQL 16 server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer &lt;code&gt;-Fc&lt;/code&gt; over plain SQL:&lt;/strong&gt; the custom format is compressed by default and lets &lt;code&gt;pg_restore -j 4&lt;/code&gt; rebuild in parallel and restore selected tables, where a plain &lt;code&gt;.sql&lt;/code&gt; file only replays top to bottom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass &lt;code&gt;-T&lt;/code&gt; to &lt;code&gt;docker compose exec&lt;/code&gt;:&lt;/strong&gt; without it Docker allocates a pseudo TTY and injects carriage returns into the redirected stream, which corrupts the archive in a way that only shows up when you try to restore it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the credentials from your compose file, not from memory:&lt;/strong&gt; the user and database name come from &lt;code&gt;POSTGRES_USER&lt;/code&gt; and &lt;code&gt;POSTGRES_DB&lt;/code&gt;, and &lt;code&gt;docker compose exec postgres env | grep POSTGRES&lt;/code&gt; confirms them in one line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify every dump immediately:&lt;/strong&gt; &lt;code&gt;pg_restore -l spliit-2026-09-26-0300.dump | wc -l&lt;/code&gt; lists the archive table of contents, and a file that produces no listing is a failed backup you still have time to retake.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a household sized instance the whole operation finishes in under 5 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  pg_dump versus a Docker volume snapshot: which one restores cleanly?
&lt;/h2&gt;

&lt;p&gt;Both approaches work. They fail differently, and the difference only appears on the day you restore.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;pg_dump -Fc&lt;/code&gt; logical dump&lt;/th&gt;
&lt;th&gt;Copy or snapshot of the Postgres volume&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Taken while running&lt;/td&gt;
&lt;td&gt;Safe, uses a consistent transaction snapshot&lt;/td&gt;
&lt;td&gt;Unsafe unless the container is stopped or the filesystem does atomic snapshots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross version restore&lt;/td&gt;
&lt;td&gt;Restores into PostgreSQL 16 or 17 without touching the data directory&lt;/td&gt;
&lt;td&gt;Data directory is tied to one major version, so 16 files will not start under 17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granularity&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_restore -t expenses&lt;/code&gt; recovers one table&lt;/td&gt;
&lt;td&gt;All or nothing, the whole cluster comes back together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical size&lt;/td&gt;
&lt;td&gt;Compressed, a household instance stays in the low megabytes&lt;/td&gt;
&lt;td&gt;Includes WAL, free space and indexes, so several times larger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downtime to take&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Around 10 to 30 seconds of &lt;code&gt;docker compose stop postgres&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repair options&lt;/td&gt;
&lt;td&gt;Text inspectable with &lt;code&gt;pg_restore -f -&lt;/code&gt; before loading&lt;/td&gt;
&lt;td&gt;Opaque, a torn copy usually shows as a refusal to start&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest case for snapshots is speed of recovery. If your host dies, restoring a ZFS or Btrfs snapshot brings Spliit back with the bucket and the compose file in one action, and you skip the ordering problems entirely.&lt;/p&gt;

&lt;p&gt;The case for dumps is that they survive change. Spliit and PostgreSQL both move, and a dump taken today still loads next year against a newer image.&lt;/p&gt;

&lt;p&gt;Run both if the disk allows: a nightly &lt;code&gt;pg_dump&lt;/code&gt; for portability, plus a weekly cold volume copy taken with the stack stopped for a genuine disaster.&lt;/p&gt;




&lt;h2&gt;
  
  
  Backing up the S3 or MinIO bucket that holds your Spliit receipts
&lt;/h2&gt;

&lt;p&gt;Receipt images are the part of Spliit nobody can reconstruct. An expense can be retyped from a bank statement in 30 seconds. A photo of a till receipt from last March is gone for good.&lt;/p&gt;

&lt;p&gt;The tool of choice is &lt;code&gt;rclone&lt;/code&gt;, which speaks the same S3 API whether your bucket lives in MinIO on the same host, in Backblaze B2, in Wasabi or in AWS S3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rclone &lt;span class="nb"&gt;sync &lt;/span&gt;spliit-s3:my-spliit-bucket /backups/spliit-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--checksum&lt;/span&gt; &lt;span class="nt"&gt;--transfers&lt;/span&gt; 8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pull the credentials from your existing configuration:&lt;/strong&gt; the &lt;code&gt;S3_UPLOAD_KEY&lt;/code&gt;, &lt;code&gt;S3_UPLOAD_SECRET&lt;/code&gt;, &lt;code&gt;S3_UPLOAD_BUCKET&lt;/code&gt;, &lt;code&gt;S3_UPLOAD_REGION&lt;/code&gt; and endpoint values already in your Spliit environment are exactly what the &lt;code&gt;rclone&lt;/code&gt; remote needs, so create a read only key if your provider supports one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;sync&lt;/code&gt; with &lt;code&gt;--checksum&lt;/code&gt;, not &lt;code&gt;copy&lt;/code&gt; with timestamps:&lt;/strong&gt; object storage timestamps change on rewrite, and checksum comparison avoids both re-uploading unchanged images and skipping a changed one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let &lt;code&gt;sync&lt;/code&gt; run towards the live bucket:&lt;/strong&gt; it deletes on the destination, so a reversed argument order replaces your receipts with whatever the backup directory happens to contain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up MinIO as objects, not as its disk:&lt;/strong&gt; if you self-host MinIO, copy through the API rather than tarring its data directory, because the on-disk layout carries per-version metadata you do not want to depend on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the bucket copy with the matching dump:&lt;/strong&gt; store &lt;code&gt;spliit-2026-09-26-0300.dump&lt;/code&gt; and that night's bucket sync under the same dated folder, so the pointer rows and the objects they reference always line up.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Which secrets and environment variables belong in the backup?
&lt;/h2&gt;

&lt;p&gt;Configuration is small, and it is the difference between a restore that takes 10 minutes and one that takes a weekend of trial and error. The whole set fits in a single encrypted file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NEXT_PUBLIC_BASE_URL&lt;/code&gt;:&lt;/strong&gt; this is baked into links Spliit generates and shares, so restoring under a different value changes every group URL you have sent to other people and quietly breaks their bookmarks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;POSTGRES_USER&lt;/code&gt;, &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; and &lt;code&gt;POSTGRES_DB&lt;/code&gt;:&lt;/strong&gt; the dump contains data, not credentials, and a restore into a database whose role name differs from the one recorded in the archive produces ownership errors on every table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;S3_*&lt;/code&gt; block and its endpoint:&lt;/strong&gt; without the exact bucket name and region the pointer rows in the database resolve to nothing, and a regenerated access key that lacks read permission looks identical to lost images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, if you use receipt scanning:&lt;/strong&gt; this one is best rotated rather than restored, because a key sitting in an old backup copy is a credential you no longer control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt; and the image tags it pins:&lt;/strong&gt; record the exact tag you run, for example &lt;code&gt;postgres:16-alpine&lt;/code&gt; alongside your Spliit tag, since &lt;code&gt;latest&lt;/code&gt; on restore day is not the image your dump came from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Encrypt the bundle rather than storing it in plain text. One command covers it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tar &lt;/span&gt;czf - .env docker-compose.yml | age &lt;span class="nt"&gt;-r&lt;/span&gt; &amp;lt;your-public-key&amp;gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; spliit-config.age
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep that output in a different place from the dumps, and keep the decryption key somewhere that survives the server, such as a password manager entry or a printed recovery phrase.&lt;/p&gt;




&lt;h2&gt;
  
  
  A nightly Spliit backup script you can drive from cron
&lt;/h2&gt;

&lt;p&gt;One file, five commands, no orchestration framework. Save this as &lt;code&gt;/usr/local/bin/spliit-backup.sh&lt;/code&gt; and make it executable with &lt;code&gt;chmod 750&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F-%H%M&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/backups/spliit/&lt;span class="nv"&gt;$STAMP&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/spliit
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; spliit &lt;span class="nt"&gt;-d&lt;/span&gt; spliit &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/db.dump"&lt;/span&gt;
rclone &lt;span class="nb"&gt;sync &lt;/span&gt;spliit-s3:my-spliit-bucket &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/bucket"&lt;/span&gt; &lt;span class="nt"&gt;--checksum&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; .env docker-compose.yml &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
pg_restore &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;/db.dump"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
find /backups/spliit &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 1 &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-mtime&lt;/span&gt; +30 &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; +
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt; is the most important line:&lt;/strong&gt; without it a failed dump still leaves a zero byte file behind, the script exits 0, and your monitoring reports success for weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One dated directory per run:&lt;/strong&gt; keeping the dump, the bucket copy and the configuration together means a restore never involves matching files by guesswork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify before pruning:&lt;/strong&gt; the &lt;code&gt;pg_restore -l&lt;/code&gt; check runs before the &lt;code&gt;find&lt;/code&gt; deletion, so a broken new backup never triggers the removal of a good old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention of 30 days is a starting point:&lt;/strong&gt; raise it to 90 if you settle debts quarterly, since the window that matters is how long a wrong edit can go unnoticed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule it when nobody is entering expenses:&lt;/strong&gt; &lt;code&gt;15 3 * * * /usr/local/bin/spliit-backup.sh &amp;gt;&amp;gt; /var/log/spliit-backup.log 2&amp;gt;&amp;amp;1&lt;/code&gt; puts it at 03:15 and keeps the output where you can read it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add a copy step to a second location afterwards, for example &lt;code&gt;restic backup /backups/spliit&lt;/code&gt;, because a backup on the same disk as the database survives almost nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much storage do three years of Spliit backups really need?
&lt;/h2&gt;

&lt;p&gt;Measure your own instance rather than trusting a rule of thumb. Three commands give you every input you need, and the arithmetic afterwards is trivial.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Measure it today&lt;/th&gt;
&lt;th&gt;How it grows over 3 years&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database dump&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose exec -T postgres psql -U spliit -d spliit -c "SELECT pg_size_pretty(pg_database_size('spliit'));"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Roughly linear in expense count, text rows only, stays modest for household use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bucket objects&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rclone size spliit-s3:my-spliit-bucket&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dominates everything else, driven by how many receipts you photograph and at what resolution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config bundle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;du -h spliit-config.age&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Effectively flat, it changes only when you edit &lt;code&gt;.env&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold volume copy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker run --rm -v spliit_postgres_data:/v alpine du -sh /v&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Larger than the dump because of WAL and index bloat, and each copy is full size&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two multipliers decide the total. Dumps multiply by your retention count, so 30 dated copies cost 30 times one dump unless you pipe them into a deduplicating tool such as &lt;code&gt;restic&lt;/code&gt; or &lt;code&gt;borg&lt;/code&gt;, where near identical dumps collapse to a fraction of that. Bucket copies do not multiply if you &lt;code&gt;sync&lt;/code&gt; into one directory, because yesterday's images are not rewritten.&lt;/p&gt;

&lt;p&gt;The practical result for most self-hosted Spliit instances is that the text data is a rounding error and the receipt images are the entire storage budget. If you want a hard ceiling, resize uploads before you attach them and check &lt;code&gt;rclone size&lt;/code&gt; once a quarter. Set your off-site quota from that number, not from the dump.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you restore Spliit onto a new host, step by step?
&lt;/h2&gt;

&lt;p&gt;Order matters. Bring up the database first, load data, then start the application so its migration check runs against a populated schema.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recreate the stack skeleton:&lt;/strong&gt; install Docker, copy the dated backup directory to the new machine, and place &lt;code&gt;docker-compose.yml&lt;/code&gt; and &lt;code&gt;.env&lt;/code&gt; in &lt;code&gt;/opt/spliit&lt;/code&gt; with the same image tags you recorded, not &lt;code&gt;latest&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start PostgreSQL alone:&lt;/strong&gt; &lt;code&gt;docker compose up -d postgres&lt;/code&gt; creates an empty database from &lt;code&gt;POSTGRES_DB&lt;/code&gt;, and waiting about 10 seconds for it to accept connections avoids a restore that fails on the first table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load the dump into that empty database:&lt;/strong&gt; &lt;code&gt;docker compose exec -T postgres pg_restore -U spliit -d spliit --no-owner --clean --if-exists &amp;lt; db.dump&lt;/code&gt; drops any conflicting objects first and ignores ownership differences between hosts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push the bucket back before the app starts:&lt;/strong&gt; reverse the sync direction with &lt;code&gt;rclone sync /backups/spliit/&amp;lt;stamp&amp;gt;/bucket spliit-s3:my-spliit-bucket --checksum&lt;/code&gt;, so every pointer row has an object waiting behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start the application container last:&lt;/strong&gt; &lt;code&gt;docker compose up -d&lt;/code&gt; lets Spliit apply any pending Prisma migrations against real data, and &lt;code&gt;docker compose logs -f&lt;/code&gt; shows within 30 seconds whether that succeeded or errored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify with a known group URL:&lt;/strong&gt; open one group you remember, confirm the balance total and open one receipt image, which tests the database and the bucket in a single click.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The target can be a self-managed VPS, a NAS, a spare mini PC or Yundera, where apps are installed from an app store in one click rather than assembled from compose files by hand. Whichever you choose, keep &lt;code&gt;NEXT_PUBLIC_BASE_URL&lt;/code&gt; pointing at the same public hostname.&lt;/p&gt;




&lt;h2&gt;
  
  
  What breaks during a restore: Prisma migrations, image drift and group URLs
&lt;/h2&gt;

&lt;p&gt;Failed Spliit restores almost never involve lost rows. They involve a schema and an application that disagree about which year it is.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restoring an old dump into a newer image:&lt;/strong&gt; Prisma compares your data against &lt;code&gt;_prisma_migrations&lt;/code&gt; and applies what is missing, which usually works, but a migration that transforms data can only run in the direction it was written, so jumping several releases at once is riskier than stepping through them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restoring a new dump into an older image:&lt;/strong&gt; this is the failure with no clean recovery, because the schema already contains columns the older code never learned about and Prisma has no downgrade path. Always record the image tag next to the dump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;latest&lt;/code&gt; drift on the host:&lt;/strong&gt; if your compose file says &lt;code&gt;latest&lt;/code&gt;, the image pulled on restore day can be months newer than the one that produced the dump, which turns a restore into an unplanned upgrade at the worst possible moment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL major version mismatch:&lt;/strong&gt; the dump itself is portable, but confirm the new server is equal or newer, since &lt;code&gt;pg_restore&lt;/code&gt; into PostgreSQL 15 from a PostgreSQL 16 archive can reject syntax the older server does not parse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A changed base URL:&lt;/strong&gt; every group link you shared carries the hostname from &lt;code&gt;NEXT_PUBLIC_BASE_URL&lt;/code&gt;, so moving from one domain to another leaves other participants with dead bookmarks even though the group identifier is unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The group list nobody backed up:&lt;/strong&gt; the database is complete, but without the URLs you have no menu, so export them once with &lt;code&gt;docker compose exec -T postgres psql -U spliit -d spliit -c "SELECT id, name FROM \"Group\";"&lt;/code&gt; and store the output alongside your dumps.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How do you rehearse a Spliit restore without touching the live instance?
&lt;/h2&gt;

&lt;p&gt;Run the drill in a second compose project on the same machine. It costs one spare port, a scratch directory and about 15 minutes, and it is the only thing that converts a backup into a plan.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Isolate with a project name:&lt;/strong&gt; copy the backup directory to &lt;code&gt;/opt/spliit-drill&lt;/code&gt;, then run every command with &lt;code&gt;docker compose -p spliit-drill&lt;/code&gt;, which creates separate containers and separate volumes so nothing can collide with production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change three values in the copied &lt;code&gt;.env&lt;/code&gt;:&lt;/strong&gt; map the web container to an unused port such as 3001, point &lt;code&gt;NEXT_PUBLIC_BASE_URL&lt;/code&gt; at &lt;code&gt;http://localhost:3001&lt;/code&gt;, and set the bucket to a throwaway target, because a drill that writes into the real bucket is not a drill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore from the archive you actually stored:&lt;/strong&gt; use the most recent nightly file rather than a fresh dump, since the whole point is to test the file your cron job produced, not the command you typed by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check four things in the running copy:&lt;/strong&gt; one group opens, its balance total matches production, one receipt image loads from the scratch bucket, and &lt;code&gt;docker compose -p spliit-drill logs&lt;/code&gt; shows no migration error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down the elapsed time:&lt;/strong&gt; the number you record becomes your realistic recovery estimate, and knowing it is 20 minutes rather than an unknown afternoon changes how you react during a real outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tear it down completely:&lt;/strong&gt; &lt;code&gt;docker compose -p spliit-drill down -v&lt;/code&gt; removes the drill volumes, and the &lt;code&gt;-v&lt;/code&gt; matters because a forgotten copy of household finances is an avoidable data exposure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repeat the exercise once a quarter, and always after you change the Spliit image tag or the PostgreSQL major version.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where should you run Spliit, and where should the backups land?
&lt;/h2&gt;

&lt;p&gt;These are two separate decisions, and the second one matters more. A self-managed VPS, a NAS running Container Manager, a mini PC at home and Yundera all run the same stack, where each app is reachable on a public HTTPS subdomain via NSL.SH mesh routing so no static IP or port forwarding is required in that last case. What changes your risk profile is how far the backup copy sits from the running container.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Backup destination&lt;/th&gt;
&lt;th&gt;Protects against&lt;/th&gt;
&lt;th&gt;Does not protect against&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Second disk in the same host&lt;/td&gt;
&lt;td&gt;A failed data disk, a bad &lt;code&gt;pg_restore&lt;/code&gt;, a deleted group&lt;/td&gt;
&lt;td&gt;Theft, fire, a ransomware event that reaches every mount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAS on the same LAN&lt;/td&gt;
&lt;td&gt;Total host loss, a reinstalled operating system&lt;/td&gt;
&lt;td&gt;Anything affecting the building, including a power surge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object storage off-site, such as Backblaze B2 or Wasabi&lt;/td&gt;
&lt;td&gt;Site loss, hardware theft, accidental &lt;code&gt;docker volume prune&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Credential compromise, unless the key is write limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A second machine you control elsewhere&lt;/td&gt;
&lt;td&gt;Site loss plus provider account lockout&lt;/td&gt;
&lt;td&gt;Neglect, since nobody checks a target they never open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encrypted USB disk rotated by hand&lt;/td&gt;
&lt;td&gt;Everything online, because it is offline most of the time&lt;/td&gt;
&lt;td&gt;Human forgetfulness, the copy is only as fresh as your last swap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The workable minimum for household finances is two destinations from different rows: one nearby for fast recovery, one off-site for the bad day. Point &lt;code&gt;restic&lt;/code&gt; at the off-site target, keep the local dated directories for quick access, and confirm both once a month with &lt;code&gt;restic snapshots&lt;/code&gt; and a directory listing. A backup you have never listed is a backup you do not have.&lt;/p&gt;




&lt;h2&gt;
  
  
  Privacy and data sovereignty: what a Spliit backup reveals about your household
&lt;/h2&gt;

&lt;p&gt;A Spliit dump is a financial diary. It names every participant, dates every purchase, and the receipt images often show a shop, a street, a card's last four digits and a time you were somewhere. That is more sensitive than the app feels while you use it, and it is why the destination of the copy matters as much as its existence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Advantages of keeping the backup under your own control:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No third party index:&lt;/strong&gt; the only systems that can read your expense history are ones you chose, so nobody profiles your spending as a side effect of storing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You set retention, not a vendor:&lt;/strong&gt; deleting a group deletes it from your dumps too, once your 30 day window rolls over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jurisdiction is a choice:&lt;/strong&gt; picking a storage region puts the data under a legal regime you selected rather than one attached to a product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portability by default:&lt;/strong&gt; a &lt;code&gt;.dump&lt;/code&gt; file plus a bucket directory is a complete export, with no API rate limit between you and your own records.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Checklist before you call the backup private:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt at rest:&lt;/strong&gt; wrap every copy with &lt;code&gt;age&lt;/code&gt; or &lt;code&gt;restic&lt;/code&gt;, which encrypts client side, so the storage provider holds bytes it cannot read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict the upload key:&lt;/strong&gt; give the off-site key append and write permission only, so a compromised host cannot delete history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the passphrase off the server:&lt;/strong&gt; a key living next to the encrypted archive protects nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check file permissions:&lt;/strong&gt; &lt;code&gt;chmod 600&lt;/code&gt; on &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;chmod 700&lt;/code&gt; on &lt;code&gt;/backups/spliit&lt;/code&gt;, because a world readable dump on a shared box is the quiet failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete drill copies:&lt;/strong&gt; scratch restores contain the same data and deserve the same handling.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Which Spliit backup plan fits you?
&lt;/h2&gt;

&lt;p&gt;Match the effort to what you would actually lose. Six profiles cover almost every self-hosted Spliit instance.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;th&gt;Main reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Couple, one group, no receipts&lt;/td&gt;
&lt;td&gt;Weekly &lt;code&gt;pg_dump&lt;/code&gt; to a second disk&lt;/td&gt;
&lt;td&gt;Data volume is tiny and retyping a month of expenses is plausible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flatshare treasurer, 5 people&lt;/td&gt;
&lt;td&gt;Nightly dump, 30 dated copies, one off-site target&lt;/td&gt;
&lt;td&gt;A wrong split surfaces weeks later, so depth beats frequency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trip organiser with receipt uploads&lt;/td&gt;
&lt;td&gt;Nightly dump plus &lt;code&gt;rclone sync&lt;/code&gt; of the bucket&lt;/td&gt;
&lt;td&gt;Images are the only unreproducible data in the stack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No sysadmin background&lt;/td&gt;
&lt;td&gt;One cron script plus &lt;code&gt;restic&lt;/code&gt; to object storage&lt;/td&gt;
&lt;td&gt;Fewer moving parts means the schedule survives your attention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Homelab with existing backup stack&lt;/td&gt;
&lt;td&gt;Add Spliit to the current &lt;code&gt;restic&lt;/code&gt; or &lt;code&gt;borg&lt;/code&gt; job&lt;/td&gt;
&lt;td&gt;Deduplication makes 90 daily dumps cheap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared instance for several groups&lt;/td&gt;
&lt;td&gt;Nightly dump, quarterly restore drill, exported group list&lt;/td&gt;
&lt;td&gt;Other people depend on links you cannot regenerate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running receipt scanning with an API key&lt;/td&gt;
&lt;td&gt;Everything above, plus key rotation on restore&lt;/td&gt;
&lt;td&gt;A restored key is a credential with unknown exposure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Next steps:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you have no backup at all today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run one &lt;code&gt;pg_dump -Fc&lt;/code&gt; by hand and verify it with &lt;code&gt;pg_restore -l&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Copy it off the host.&lt;/li&gt;
&lt;li&gt;Add the cron entry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you already dump the database:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the bucket sync.&lt;/li&gt;
&lt;li&gt;Add the encrypted config bundle.&lt;/li&gt;
&lt;li&gt;Set retention to 30 days or more.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you have all three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Book a 20 minute restore drill this quarter.&lt;/li&gt;
&lt;li&gt;Record the elapsed time.&lt;/li&gt;
&lt;li&gt;Export your group URLs and store them with the dumps.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>postgres</category>
      <category>privacy</category>
    </item>
    <item>
      <title>RClone vs Dropbox and Google Drive Sync: Does Client-Side Crypt Give You Private Cloud Storage?</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 24 Sep 2026 07:05:48 +0000</pubDate>
      <link>https://dev.to/john_182319291/rclone-vs-dropbox-and-google-drive-sync-does-client-side-crypt-give-you-private-cloud-storage-1gk8</link>
      <guid>https://dev.to/john_182319291/rclone-vs-dropbox-and-google-drive-sync-does-client-side-crypt-give-you-private-cloud-storage-1gk8</guid>
      <description>&lt;p&gt;Yes, an rclone crypt remote keeps the provider from reading your file contents and, if you enable filename encryption, your file names too. It does not hide your directory structure, your approximate file sizes, your access times or the fact that you are storing data at all. Crypt is a strong answer to "can Dropbox read my documents" and a weak answer to "can Dropbox profile my behaviour". Treat it as an encryption layer bolted onto a service you still do not control, not as a replacement for controlling where the bytes live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by reader profile:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leaving a paid Dropbox plan for privacy reasons, like a freelance designer with 400 GB of client work:&lt;/strong&gt; wrap your existing provider in a crypt remote first, then decide about moving hosts later, because encryption is the part that actually changes the provider's access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wanting the sync experience back, like a household sharing photos across three laptops:&lt;/strong&gt; crypt plus rclone breaks the native desktop client, so budget for &lt;code&gt;rclone bisync&lt;/code&gt; or &lt;code&gt;mount&lt;/code&gt; and accept manual conflict handling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storing backups rather than working files, like a homelab owner pushing nightly Restic or Borg snapshots:&lt;/strong&gt; skip crypt entirely, because Restic and Borg already encrypt client side and a second layer just costs CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handling regulated or client-confidential data, like a small accountancy practice under GDPR:&lt;/strong&gt; crypt gives you a defensible encryption story, but the metadata leakage and key custody questions need answering in writing before you rely on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running a home server or NAS already, like a Synology owner with 8 TB of disks:&lt;/strong&gt; use crypt to encrypt an offsite copy, and keep the authoritative copy on hardware you own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dealing with millions of small files, like a developer syncing node_modules or a photo library with sidecars:&lt;/strong&gt; measure first, because the per-file overhead and lost server-side deduplication hit small files hardest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff: crypt buys you confidentiality against the provider at the cost of native sync clients, server-side deduplication, delta transfers, search, previews and web access, and it never buys you anonymity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What does rclone crypt actually encrypt, and what does it leave in plain sight?&lt;/li&gt;
&lt;li&gt;How does crypt compare to Dropbox and Google Drive server-side encryption?&lt;/li&gt;
&lt;li&gt;Is client-side encryption enough to stop a provider reading your files?&lt;/li&gt;
&lt;li&gt;How do you set up a crypt remote over Dropbox or Google Drive step by step?&lt;/li&gt;
&lt;li&gt;What do the three filename encryption modes cost you in path length and compatibility?&lt;/li&gt;
&lt;li&gt;How much storage and speed overhead does crypt add per file?&lt;/li&gt;
&lt;li&gt;Why does crypt break deduplication, delta sync and versioning on your provider?&lt;/li&gt;
&lt;li&gt;Can you still sync across devices once crypt is in the way?&lt;/li&gt;
&lt;li&gt;Where do your crypt passwords and salt actually live, and how do you protect rclone.conf?&lt;/li&gt;
&lt;li&gt;What happens if you lose the password, and how do you test a restore?&lt;/li&gt;
&lt;li&gt;RClone with crypt vs a self-hosted alternative: which metadata does each expose?&lt;/li&gt;
&lt;li&gt;Where should you run rclone: laptop, home server, NAS or VPS?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What does rclone crypt actually encrypt, and what does it leave in plain sight?
&lt;/h2&gt;

&lt;p&gt;Crypt is a wrapper remote. You point it at a path on an existing remote, and every file written through it is encrypted on your machine before a single byte reaches Dropbox or Google Drive. File contents use XSalsa20-Poly1305 in 64 KiB chunks, with a 32 byte header holding a random nonce at the start of each object. The provider stores opaque blobs.&lt;/p&gt;

&lt;p&gt;What survives the wrapper is more than most people expect.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Directory structure:&lt;/strong&gt; crypt encrypts each path component separately, so a 4 level deep tree on your disk is still a 4 level deep tree in the provider's storage, with the same branching shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File sizes:&lt;/strong&gt; an encrypted object is a deterministic function of the plaintext size, 32 bytes of header plus 16 bytes of overhead per 64 KiB chunk, so sizes are recoverable to within a few tens of bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timestamps and access patterns:&lt;/strong&gt; modification times are stored as provider metadata in the clear, and the provider logs every read, write and delete with its own timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File counts and total volume:&lt;/strong&gt; the number of objects and how they grow over time is fully visible, which is often enough to infer what kind of data you keep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filenames, unless you ask:&lt;/strong&gt; &lt;code&gt;filename_encryption = off&lt;/code&gt; leaves names readable, and only &lt;code&gt;standard&lt;/code&gt; or &lt;code&gt;obfuscate&lt;/code&gt; change that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run &lt;code&gt;rclone config show secret:&lt;/code&gt; after setup and check the &lt;code&gt;filename_encryption&lt;/code&gt; and &lt;code&gt;directory_name_encryption&lt;/code&gt; values yourself. The defaults in an interactive &lt;code&gt;rclone config&lt;/code&gt; session are &lt;code&gt;standard&lt;/code&gt; and &lt;code&gt;true&lt;/code&gt;, but a copied config or a scripted setup can quietly leave both disabled.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does crypt compare to Dropbox and Google Drive server-side encryption?
&lt;/h2&gt;

&lt;p&gt;Both providers encrypt at rest and in transit. The difference is not the algorithm, it is who holds the key.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Provider server-side encryption&lt;/th&gt;
&lt;th&gt;rclone crypt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Key custody&lt;/td&gt;
&lt;td&gt;Held by Dropbox or Google, rotated without your involvement&lt;/td&gt;
&lt;td&gt;Derived from your password and salt in &lt;code&gt;rclone.conf&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protects against&lt;/td&gt;
&lt;td&gt;Stolen disks, a datacentre breach, an unencrypted backup tape&lt;/td&gt;
&lt;td&gt;The provider itself, a subpoena served on the provider, an account takeover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider-side features&lt;/td&gt;
&lt;td&gt;Web preview, full text search, sharing links, Google Docs conversion&lt;/td&gt;
&lt;td&gt;None, the provider sees only blobs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access by staff or automated scanning&lt;/td&gt;
&lt;td&gt;Possible in principle, both run content scanning for abuse and malware&lt;/td&gt;
&lt;td&gt;Not possible without your password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What a legal request yields&lt;/td&gt;
&lt;td&gt;Readable files and metadata&lt;/td&gt;
&lt;td&gt;Ciphertext and metadata only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Server-side encryption answers a threat model that mostly protects the provider, not you. Google Workspace offers client-side encryption on Enterprise Plus tiers, but the key service is still something you configure inside Google's ecosystem. Crypt moves the boundary to your own machine, which is exactly why the web interface stops being useful.&lt;/p&gt;

&lt;p&gt;That boundary has a physical location. The key material sits in &lt;code&gt;rclone.conf&lt;/code&gt; on whatever device runs the sync, so the security of the whole arrangement reduces to the security of that machine. It might be a laptop, a 5 euro VPS, a NAS, or a Personal Cloud Server. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. Whichever you pick, that box is now the single point that can read your data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is client-side encryption enough to stop a provider reading your files?
&lt;/h2&gt;

&lt;p&gt;For file contents, yes. Nobody at Dropbox or Google can turn an rclone crypt blob into a readable document without your password. The honest answer is that "reading your files" is a narrower win than "being private", and the gap is where most people get surprised.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your identity is still attached:&lt;/strong&gt; the account is in your name, paid with your card, accessed from your home IP, and the provider retains connection logs regardless of what the objects contain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidentiality is not availability:&lt;/strong&gt; encryption does not stop an account suspension, a quota enforcement action or a deletion. If the provider locks the account, you have unreadable data you also cannot download.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The key derivation is only as good as the password:&lt;/strong&gt; crypt runs scrypt over your password and salt. A short passphrase is brute-forceable offline once someone holds the ciphertext, and they hold it permanently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rclone.conf&lt;/code&gt; is the real target:&lt;/strong&gt; an attacker with that file and the config password, or with none if you never set one, has everything. Storing it in a dotfiles repo has ended more setups than any cryptographic weakness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing stops traffic analysis:&lt;/strong&gt; the provider sees a 4 GB upload at 02:00 every night and a burst of reads whenever you open a project. That pattern is informative on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compare that to Proton Drive or Tresorit, where the encryption is native and the client keeps working. You trade rclone's flexibility for an integrated product. Compare it to self-hosted storage, where the identity and the logs never leave your own machine in the first place.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you set up a crypt remote over Dropbox or Google Drive step by step?
&lt;/h2&gt;

&lt;p&gt;Crypt never talks to a provider directly. You build the provider remote first, then wrap it. Expect two entries in &lt;code&gt;rclone.conf&lt;/code&gt; for every one encrypted destination.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create the base remote:&lt;/strong&gt; run &lt;code&gt;rclone config&lt;/code&gt;, choose &lt;code&gt;dropbox&lt;/code&gt; or &lt;code&gt;drive&lt;/code&gt;, and name it something you will recognise later, for example &lt;code&gt;gdrive&lt;/code&gt;. For Google Drive, register your own OAuth client ID in Google Cloud Console rather than using rclone's built in one, which is shared by every user of the tool and throttled accordingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorise it, headless if needed:&lt;/strong&gt; on a machine with no browser, run &lt;code&gt;rclone authorize "drive"&lt;/code&gt; on your laptop and paste the returned token into the config prompt. This is the normal path for a VPS or a NAS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Create a container folder:&lt;/strong&gt; make one directory that will hold nothing but ciphertext, such as &lt;code&gt;gdrive:encrypted&lt;/code&gt;. Mixing plaintext and crypt data in the same path makes later auditing painful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the crypt remote:&lt;/strong&gt; choose &lt;code&gt;crypt&lt;/code&gt;, set &lt;code&gt;remote = gdrive:encrypted&lt;/code&gt;, accept &lt;code&gt;standard&lt;/code&gt; for filename encryption, and answer &lt;code&gt;true&lt;/code&gt; for directory name encryption unless you have a specific reason not to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate the password and salt:&lt;/strong&gt; let rclone generate both at 128 bits rather than typing a passphrase. Write them down offline before you continue, because the config file stores them obscured, not recoverable in your head.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify before trusting it:&lt;/strong&gt; &lt;code&gt;rclone copy ./testdir secret:testdir&lt;/code&gt;, then &lt;code&gt;rclone ls gdrive:encrypted&lt;/code&gt; to confirm you see gibberish, then &lt;code&gt;rclone check ./testdir secret:testdir&lt;/code&gt; to confirm round trips match.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only after step 6 should you point anything real at it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What do the three filename encryption modes cost you in path length and compatibility?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;filename_encryption&lt;/code&gt; has three values, and the default is not the only sensible choice.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;standard&lt;/code&gt;:&lt;/strong&gt; names are encrypted with AES in EME mode and encoded, which inflates them. Rclone's own documentation puts the safe threshold at file names under 156 characters, above which you risk breaching provider path limits. Deeply nested project trees with long descriptive names are the usual casualty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;obfuscate&lt;/code&gt;:&lt;/strong&gt; a simple rotating substitution keyed to your password. It defeats casual browsing but not analysis, and it is not real encryption. Its advantage is that name length barely changes, so path limits stop being a problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;off&lt;/code&gt;:&lt;/strong&gt; names are stored exactly as they are on disk. You get full compatibility and zero inflation, and the provider gets a complete inventory of what you own. &lt;code&gt;2024-tax-return-final.pdf&lt;/code&gt; tells a reader almost everything before they fail to decrypt it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;directory_name_encryption&lt;/code&gt;:&lt;/strong&gt; an independent switch. Set it to &lt;code&gt;false&lt;/code&gt; with &lt;code&gt;standard&lt;/code&gt; file names if you need readable folders for your own navigation and accept that folder names leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;filename_encoding&lt;/code&gt;:&lt;/strong&gt; controls the alphabet used. The default &lt;code&gt;base32&lt;/code&gt; is the most portable. &lt;code&gt;base32768&lt;/code&gt; packs more information per character and allows noticeably longer names, but only where the provider handles the wider character set cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switching modes later is not a config edit. Rclone cannot recognise files written under a different scheme, so you must create a second crypt remote and run &lt;code&gt;rclone move oldsecret: newsecret:&lt;/code&gt;, which means re-uploading every byte. Decide before the first large upload, not after 300 GB.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much storage and speed overhead does crypt add per file?
&lt;/h2&gt;

&lt;p&gt;The storage cost is deterministic: a 32 byte header, plus 16 bytes of authentication tag for every 64 KiB chunk. That makes it negligible for media and painful for tiny files.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plaintext size&lt;/th&gt;
&lt;th&gt;Stored size&lt;/th&gt;
&lt;th&gt;Overhead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 bytes&lt;/td&gt;
&lt;td&gt;148 bytes&lt;/td&gt;
&lt;td&gt;48%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 KiB&lt;/td&gt;
&lt;td&gt;1,072 bytes&lt;/td&gt;
&lt;td&gt;4.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 MiB&lt;/td&gt;
&lt;td&gt;1,048,864 bytes&lt;/td&gt;
&lt;td&gt;0.027%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 GiB&lt;/td&gt;
&lt;td&gt;1,074,003,984 bytes&lt;/td&gt;
&lt;td&gt;0.024%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 50,000 file source tree of small text files pays a visible tax. A 400 GB video archive pays almost nothing. Run &lt;code&gt;rclone size ./source&lt;/code&gt; against &lt;code&gt;rclone size secret:&lt;/code&gt; after your first sync to see your own ratio rather than guessing from this table.&lt;/p&gt;

&lt;p&gt;Speed is a different story. XSalsa20-Poly1305 is a stream cipher chosen for software performance, and on any x86-64 machine from the last decade the cipher is not your bottleneck: the upload link is. On a Raspberry Pi 4 or a low-power ARM NAS, encryption can become the limit on a fast fibre connection, so test before assuming.&lt;/p&gt;

&lt;p&gt;The real slowdown is per-object, not per-byte. Crypt adds a name encryption step per path component, and rclone must buffer and transform each file rather than streaming it untouched. With &lt;code&gt;--transfers 4&lt;/code&gt; and thousands of small files, the per-file cost dominates. Raising &lt;code&gt;--transfers&lt;/code&gt; and &lt;code&gt;--checkers&lt;/code&gt; helps until the provider starts returning rate limit errors, which Google Drive does aggressively on per-file operations. Large files, few of them, is the shape crypt handles best.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why does crypt break deduplication, delta sync and versioning on your provider?
&lt;/h2&gt;

&lt;p&gt;Every file gets a fresh random 24 byte nonce. Encrypt the same 200 MB video twice and you produce two completely different objects. That single design decision, which is correct cryptographically, removes most of what makes a commercial sync service efficient.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cross-file and cross-account deduplication stops:&lt;/strong&gt; providers save space by storing one copy of a block that millions of accounts hold. Your ciphertext matches nothing, so you are billed for every byte you upload, including the copies you did not realise you had.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block-level delta sync stops:&lt;/strong&gt; the Dropbox desktop client uploads only the changed portion of a file. Rclone through crypt has no equivalent. Change one comment in a 400 MB Photoshop file and you re-upload 400 MB, every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider-side hash verification stops:&lt;/strong&gt; rclone cannot compare a local MD5 against the stored object, because the stored object is not your file. Sync falls back to size and modification time. Use &lt;code&gt;rclone cryptcheck secret: ./local&lt;/code&gt; to verify properly, and expect it to read data rather than just list it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version history becomes opaque:&lt;/strong&gt; the provider still keeps old revisions, but you cannot preview or diff them in the web interface to decide which one you want. You restore blind, then decrypt to find out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trash and selective restore get clumsy:&lt;/strong&gt; recovering one deleted file means identifying it by an encrypted name in a web UI, which in practice means restoring more than you need.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want deduplication and versioning back, that belongs in the tool above rclone. Restic and Borg both dedupe before encrypting, then push the result with rclone as a dumb transport.&lt;/p&gt;




&lt;h2&gt;
  
  
  Can you still sync across devices once crypt is in the way?
&lt;/h2&gt;

&lt;p&gt;The native Dropbox and Google Drive clients are finished. They sync the plaintext folder on your disk, which is exactly what you are trying to avoid. You replace continuous background sync with something you schedule yourself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rclone sync&lt;/code&gt; for one direction:&lt;/strong&gt; the laptop is authoritative, the cloud is a mirror. Safe, simple, and it deletes anything on the destination that is gone locally. Always dry run first with &lt;code&gt;--dry-run&lt;/code&gt; before trusting a new filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rclone bisync&lt;/code&gt; for two directions:&lt;/strong&gt; this is the closest thing to the old behaviour. It needs &lt;code&gt;--resync&lt;/code&gt; on first run to establish a baseline, and it stores state listings under &lt;code&gt;~/.cache/rclone/bisync/&lt;/code&gt;. Lose that state and you must resync again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rclone mount&lt;/code&gt; for on demand access:&lt;/strong&gt; the remote appears as a normal directory. Use &lt;code&gt;--vfs-cache-mode writes&lt;/code&gt; at minimum, because many applications fail on a remote without write caching. Expect open and save latency measured in seconds, not milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling instead of watching:&lt;/strong&gt; rclone has no filesystem watcher driving uploads. You run it from cron or a systemd timer, typically every 5 or 15 minutes, so a change on one machine is not instantly visible on another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conflicts are yours to resolve:&lt;/strong&gt; bisync renames both sides rather than merging. Edit the same document on two laptops within one sync window and you get two files, not a silent winner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile is the weak point:&lt;/strong&gt; Android has third party crypt-aware clients such as Round Sync. On iOS there is no comparable maintained option, which rules crypt out for phone-first workflows.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Where do your crypt passwords and salt actually live, and how do you protect rclone.conf?
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;rclone config file&lt;/code&gt; to see the path, usually &lt;code&gt;~/.config/rclone/rclone.conf&lt;/code&gt;. Both the crypt password and the salt are written there, obscured rather than encrypted. Obscuring is reversible by design: anyone holding the file can recover the plaintext password with &lt;code&gt;rclone reveal&lt;/code&gt;. Treat that file as the key itself, because it is.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt the config:&lt;/strong&gt; in &lt;code&gt;rclone config&lt;/code&gt;, choose "Set configuration password". The whole file is then encrypted at rest, and rclone prompts on every invocation unless you supply the password another way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate carefully:&lt;/strong&gt; &lt;code&gt;RCLONE_CONFIG_PASS&lt;/code&gt; in the environment or &lt;code&gt;--password-command "pass rclone/config"&lt;/code&gt; lets cron jobs run unattended. Both mean the unlock secret sits somewhere on the same machine, so you have traded a prompt for a slightly better hiding place, not for real protection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set permissions and check them:&lt;/strong&gt; &lt;code&gt;chmod 600 ~/.config/rclone/rclone.conf&lt;/code&gt;. On a shared box, a world readable config is the entire failure mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep it out of version control:&lt;/strong&gt; dotfiles repositories, container images and Ansible playbooks have all leaked working rclone configs. Add the path to &lt;code&gt;.gitignore&lt;/code&gt; before you commit anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the password and salt offline too:&lt;/strong&gt; a password manager entry or a printed copy in a safe. The config file is a convenience copy, not your only copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The machine you choose shapes all of this. A laptop travels and gets stolen. A VPS is reachable from the internet permanently. A NAS at home, or a managed Personal Cloud Server such as Yundera running apps as Docker containers on a server dedicated to you, keeps the key material on hardware you can physically point at.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happens if you lose the password, and how do you test a restore?
&lt;/h2&gt;

&lt;p&gt;Nothing happens. That is the problem. There is no recovery flow, no support ticket, no key escrow. Lose the password and salt and your 500 GB of ciphertext is permanently unreadable, including by you. Dropbox and Google cannot help, because they never had the key. This is the price of the guarantee in the first place.&lt;/p&gt;

&lt;p&gt;A restore drill is the only way to find out whether your setup works. Do it before you delete the local original, and repeat it on a schedule.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rebuild from nothing, not from your working machine:&lt;/strong&gt; copy only your recorded password and salt to a clean container or a spare laptop, run &lt;code&gt;rclone config&lt;/code&gt; there, and recreate both remotes by hand. If you cannot do it from written notes alone, your notes are incomplete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore a real subset:&lt;/strong&gt; pull one full project directory with &lt;code&gt;rclone copy secret:projects/2024-archive ./restore-test&lt;/code&gt;, not a single test file you uploaded yesterday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify byte for byte:&lt;/strong&gt; &lt;code&gt;rclone cryptcheck secret:projects ./originals&lt;/code&gt; compares decrypted content against your local copy and reports differences explicitly. A successful download is not proof of a correct decryption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time it and write the number down:&lt;/strong&gt; a 200 GB restore over a 100 Mbit connection takes hours. Knowing whether that is 4 hours or 14 changes what you tell a client when something breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the config password path too:&lt;/strong&gt; if the file is encrypted and unlocked by a script, confirm the manual password still works. People forget the one they automated away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the drill every 6 months, and after any change to filename modes, provider or machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  RClone with crypt vs a self-hosted alternative: which metadata does each expose?
&lt;/h2&gt;

&lt;p&gt;Crypt protects content. Self-hosting protects context. The two answer different questions, and comparing them by metadata makes the gap obvious.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metadata&lt;/th&gt;
&lt;th&gt;rclone crypt on Dropbox or Drive&lt;/th&gt;
&lt;th&gt;Self-hosted storage on your own server&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File contents&lt;/td&gt;
&lt;td&gt;Hidden, ciphertext only&lt;/td&gt;
&lt;td&gt;Hidden from third parties, readable by you at rest unless you add encryption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Directory shape and file sizes&lt;/td&gt;
&lt;td&gt;Visible to the provider&lt;/td&gt;
&lt;td&gt;Never leaves your machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access times and frequency&lt;/td&gt;
&lt;td&gt;Logged by the provider on every operation&lt;/td&gt;
&lt;td&gt;Logged by you, in logs you can rotate or disable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billing and account identity&lt;/td&gt;
&lt;td&gt;Tied to your name, card and IP&lt;/td&gt;
&lt;td&gt;Tied to your hosting provider or your electricity bill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continued access&lt;/td&gt;
&lt;td&gt;Subject to the provider's terms and account actions&lt;/td&gt;
&lt;td&gt;Subject to your own hardware and backups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legal exposure&lt;/td&gt;
&lt;td&gt;A request goes to Dropbox or Google, who hold metadata&lt;/td&gt;
&lt;td&gt;A request comes to you directly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The self-hosted column assumes something is actually running: Nextcloud for a sync client experience, or MinIO, Garage or SeaweedFS if you want an S3 endpoint that rclone can talk to natively. Any of those can run on a home NAS, a rented VPS or a Personal Cloud Server. On a managed Personal Cloud Server such as Yundera, apps are installed from an app store in one click and each is reachable on a public HTTPS subdomain via NSL.SH mesh routing, so no static IP, port forwarding or manual TLS certificate setup is involved.&lt;/p&gt;

&lt;p&gt;The pragmatic answer is both. Self-host the primary copy, then use crypt to push an encrypted offsite copy to a provider you have decided not to trust.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where should you run rclone: laptop, home server, NAS or VPS?
&lt;/h2&gt;

&lt;p&gt;The machine that runs rclone must hold the plaintext, the keys and enough uptime to finish a transfer. Those three requirements rarely point at the same box.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A laptop:&lt;/strong&gt; simplest, and the right answer if the data lives there anyway. The cost is that syncs only happen when the lid is open, and a 300 GB initial upload becomes a multi-day exercise in not closing it. Use &lt;code&gt;--bwlimit 8M&lt;/code&gt; so the machine stays usable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A NAS or a home server:&lt;/strong&gt; always on, wired, and holding the authoritative copy. Synology and QNAP ship rclone in community repositories, and a Raspberry Pi 4 handles the cipher fine at typical domestic upload speeds. Schedule with a systemd timer rather than the vendor's task scheduler, which often drops environment variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A VPS:&lt;/strong&gt; useful when you are moving data between two cloud services, because server side transfers never touch your home link. The catch is that your plaintext and your keys now sit on hardware someone else controls, which partly undoes the point of crypt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Personal Cloud Server:&lt;/strong&gt; the same always-on model as a NAS without assembling it yourself. Yundera is one option here, alongside a self-managed VPS, a home server or a NAS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inside Docker:&lt;/strong&gt; the official &lt;code&gt;rclone/rclone&lt;/code&gt; image works, but &lt;code&gt;rclone mount&lt;/code&gt; needs &lt;code&gt;--device /dev/fuse&lt;/code&gt; and &lt;code&gt;--cap-add SYS_ADMIN&lt;/code&gt;. If you only run &lt;code&gt;sync&lt;/code&gt; or &lt;code&gt;copy&lt;/code&gt; on a timer, skip both and keep the container unprivileged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the box that already stores the files. Every additional hop is another copy of plaintext to defend.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>privacy</category>
      <category>opensource</category>
    </item>
    <item>
      <title>FileBrowser Permissions Explained: How Far Can One Compromised Account Actually Go?</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 22 Sep 2026 07:06:46 +0000</pubDate>
      <link>https://dev.to/john_182319291/filebrowser-permissions-explained-how-far-can-one-compromised-account-actually-go-1ae6</link>
      <guid>https://dev.to/john_182319291/filebrowser-permissions-explained-how-far-can-one-compromised-account-actually-go-1ae6</guid>
      <description>&lt;p&gt;A stolen FileBrowser account reaches exactly what its Scope points at, and seven of the eight permission toggles stay inside that directory tree. Execute is the exception: combined with one entry in the Commands allowlist, it starts a real process owned by the same system user FileBrowser runs as, and that process ignores Scope, Rules and every other toggle above it. Symlinks and bind-mounts that sit inside Scope are followed like ordinary folders, so the wall is where the filesystem stops, not where the file list stops. Treat Scope, Rules and the toggles as a usability boundary, and treat the container user, its mounts and its capabilities as the security boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by reader profile:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solo user with one admin account (Tom, one login, photos and documents on a home box):&lt;/strong&gt; leave Execute off and narrow Scope from the volume root to the two folders you actually open, because a single admin session is the whole application in one credential.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Household sharer (Ana, one shared media folder for two family members):&lt;/strong&gt; give each person their own account with Scope pinned to their subfolder and Download plus Share only, since per-user Scope is the one control that reliably keeps two people out of each other's files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small team lead handing out logins (Marek, six contractors working on one asset library):&lt;/strong&gt; rely on separate accounts with Create, Rename and Modify but never Admin, because the Admin toggle lets any holder rewrite every other account's Scope in seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone who switched Execute on for one quick script:&lt;/strong&gt; switch it back off and move the script to a scheduled job outside FileBrowser, as Execute plus a loose Commands entry is a shell, not a file permission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NAS or VPS owner who mounted a large parent path (Priya, the whole data disk mapped in for convenience):&lt;/strong&gt; re-mount only the subtrees you need, several of them read-only, since limits below FileBrowser are the only ones a hijacked session cannot edit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is blunt: every toggle that makes FileBrowser convenient for a second person widens the area a stolen session can read or rewrite, and the only restrictions that survive that session are the ones enforced underneath the application.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What can someone do in the first sixty seconds inside a stolen FileBrowser account?&lt;/li&gt;
&lt;li&gt;How does Scope actually work, and what is it anchored to?&lt;/li&gt;
&lt;li&gt;What does each of the eight FileBrowser permission toggles really enforce?&lt;/li&gt;
&lt;li&gt;Why the Execute permission is not a file permission at all&lt;/li&gt;
&lt;li&gt;How does the Commands allowlist decide what is allowed to run?&lt;/li&gt;
&lt;li&gt;What do per-user Rules match against, and in what order are they applied?&lt;/li&gt;
&lt;li&gt;Do symlinks inside Scope walk straight out of it?&lt;/li&gt;
&lt;li&gt;Bind-mounts, volumes and the paths you forgot you handed to FileBrowser&lt;/li&gt;
&lt;li&gt;What does the Admin toggle hand over that the other seven never can?&lt;/li&gt;
&lt;li&gt;How much protection comes from the system user FileBrowser runs as?&lt;/li&gt;
&lt;li&gt;Read-only mounts, dropped capabilities and the limits that live below FileBrowser&lt;/li&gt;
&lt;li&gt;What does the FileBrowser database itself expose if someone reaches it?&lt;/li&gt;
&lt;li&gt;How do you measure your own blast radius with a throwaway account in thirty minutes?&lt;/li&gt;
&lt;li&gt;Does where you run FileBrowser change what a breach can reach?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What can someone do in the first sixty seconds inside a stolen FileBrowser account?
&lt;/h2&gt;

&lt;p&gt;The first minute tells you almost everything, because FileBrowser does not stage its capabilities behind extra confirmations. Whoever holds the session immediately sees the account's Scope as the root of the tree, and every toggle attached to that account is live from the first click. There is no second authentication step for deletion, no confirmation email for a new share link, and no separate re-login before the command console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the entire Scope tree:&lt;/strong&gt; the file list, the built-in text editor and the preview pane expose every readable file under Scope, including dotfiles such as &lt;code&gt;.env&lt;/code&gt; or &lt;code&gt;.ssh/config&lt;/code&gt; if they happen to sit inside it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pull a bulk copy out:&lt;/strong&gt; the Download permission zips a selected folder server side and streams it, so a large subtree leaves as one request rather than as hundreds of clicks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create a public link:&lt;/strong&gt; the Share permission mints a link that works for anyone holding the URL, and a link created in the first minute keeps working after you reset the password on the account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overwrite or delete in place:&lt;/strong&gt; Modify and Delete operate directly on the mounted filesystem with no recycle bin and no versioning, so a deleted folder is gone unless your storage layer snapshots it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run a process:&lt;/strong&gt; if Execute is on and the Commands allowlist is not empty, the console runs a real binary as the FileBrowser system user.&lt;/p&gt;

&lt;p&gt;The practical consequence is that your recovery plan cannot be "change the password". Revoking existing shares and auditing the tree comes first.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does Scope actually work, and what is it anchored to?
&lt;/h2&gt;

&lt;p&gt;Scope is not a filter applied to search results. It is the root that FileBrowser hands to the file server for that user, so paths in the URL bar and in the API are resolved against it rather than against the real filesystem root. A user whose Scope is &lt;code&gt;/srv/ana&lt;/code&gt; sees &lt;code&gt;/srv/ana/invoices&lt;/code&gt; as &lt;code&gt;/invoices&lt;/code&gt;, and the layer above is not hidden so much as unreachable by name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two roots stack, not one:&lt;/strong&gt; the server root comes from &lt;code&gt;--root&lt;/code&gt; or the &lt;code&gt;"root"&lt;/code&gt; key in &lt;code&gt;.filebrowser.json&lt;/code&gt;, and each user's Scope is resolved against that root before any request is served.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Docker image anchors at &lt;code&gt;/srv&lt;/code&gt;:&lt;/strong&gt; the official container sets the root there, which is why a careless &lt;code&gt;-v /:/srv&lt;/code&gt; mapping quietly makes the whole host readable to every account in the database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope is stored per user, not per session:&lt;/strong&gt; it lives in &lt;code&gt;filebrowser.db&lt;/code&gt; alongside the account, so changing it with &lt;code&gt;filebrowser users update ana --scope /srv/ana/2024&lt;/code&gt; applies on the next request without a restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path traversal is normalised, not trusted:&lt;/strong&gt; requests containing &lt;code&gt;../&lt;/code&gt; are cleaned before the path is joined to Scope, so climbing out with a crafted URL is not the realistic attack here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope is a naming boundary, not an access boundary:&lt;/strong&gt; anything the operating system user can open through a path inside Scope stays reachable, which is why the two escapes worth worrying about are symlinks and mounts.&lt;/p&gt;

&lt;p&gt;Set Scope as narrowly as the person's actual work allows. Widening it later takes one command.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does each of the eight FileBrowser permission toggles really enforce?
&lt;/h2&gt;

&lt;p&gt;Every toggle maps to a set of API endpoints, not to a mode of thinking. Reading and listing are not toggles at all: any account that can log in can browse its Scope. That baseline matters, because six of the eight only govern writes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Toggle&lt;/th&gt;
&lt;th&gt;What it gates&lt;/th&gt;
&lt;th&gt;What it does not stop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Admin&lt;/td&gt;
&lt;td&gt;User management, global settings, the whole &lt;code&gt;/settings&lt;/code&gt; area&lt;/td&gt;
&lt;td&gt;Nothing, it supersedes the other seven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execute&lt;/td&gt;
&lt;td&gt;The command console and hook execution&lt;/td&gt;
&lt;td&gt;The process itself, which runs outside Scope rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create&lt;/td&gt;
&lt;td&gt;New files, new folders, uploads into Scope&lt;/td&gt;
&lt;td&gt;Overwriting an existing file, which Modify covers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rename&lt;/td&gt;
&lt;td&gt;Renaming and moving within Scope&lt;/td&gt;
&lt;td&gt;Moving a file into a share, which stays readable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modify&lt;/td&gt;
&lt;td&gt;Writing to existing files, including the text editor&lt;/td&gt;
&lt;td&gt;Silent truncation, there is no version history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete&lt;/td&gt;
&lt;td&gt;Removing files and folders&lt;/td&gt;
&lt;td&gt;Recovery, since there is no recycle bin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share&lt;/td&gt;
&lt;td&gt;Creating public links to anything under Scope&lt;/td&gt;
&lt;td&gt;Links already issued, which outlive the toggle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Download&lt;/td&gt;
&lt;td&gt;Single files and server-side zipped folders&lt;/td&gt;
&lt;td&gt;Reading content through preview and the editor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two consequences are easy to miss. Turning Download off does not create a read-only viewer, because the built-in editor still renders text files in the browser. Turning Share off later does not retire links created earlier, so revocation is a separate step in the shares list.&lt;/p&gt;

&lt;p&gt;A practical baseline for a second person is Download plus Share off, Create and Modify on, Delete off, Execute off, Admin off. Set it with &lt;code&gt;filebrowser users update &amp;lt;name&amp;gt; --perm.delete=false --perm.execute=false&lt;/code&gt; and verify it from a logged-out browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the Execute permission is not a file permission at all
&lt;/h2&gt;

&lt;p&gt;The other seven toggles all end in a filesystem call that FileBrowser makes on your behalf, inside Scope. Execute ends in a process. Once a binary starts, FileBrowser is no longer in the path between that process and the disk, and none of its checks apply to what the process does next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The process inherits the system user, not the account:&lt;/strong&gt; it runs as whatever UID the FileBrowser binary runs as, so it can read and write every path that UID can reach, including paths far above Scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope becomes a working directory, not a boundary:&lt;/strong&gt; commands start in the folder the user is standing in, but an absolute path such as &lt;code&gt;/etc&lt;/code&gt; or &lt;code&gt;/srv&lt;/code&gt; resolves normally from inside the process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A configured shell turns one allowed word into arbitrary syntax:&lt;/strong&gt; if you have run &lt;code&gt;filebrowser config set --shell "/bin/sh -c"&lt;/code&gt;, an allowlisted command is passed to a shell that also accepts pipes, semicolons and redirection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hooks execute without anyone clicking a console:&lt;/strong&gt; the global Commands settings attach to events such as before_save and after_upload, so an uploaded file can trigger a command run without an interactive session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rules and Scope are never consulted:&lt;/strong&gt; the allow and deny patterns are enforced by the file API, and a spawned process does not go through the file API.&lt;/p&gt;

&lt;p&gt;Treat Execute as equivalent to shell access for that user. If one account needs a periodic script, run it from cron or a systemd timer outside the container and leave &lt;code&gt;perm.execute&lt;/code&gt; false for every account in &lt;code&gt;filebrowser.db&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does the Commands allowlist decide what is allowed to run?
&lt;/h2&gt;

&lt;p&gt;The allowlist is a per user list of words, and the check is a prefix match on the first token of what the user types. Allowing &lt;code&gt;git&lt;/code&gt; allows every subcommand and every flag that &lt;code&gt;git&lt;/code&gt; accepts. Allowing &lt;code&gt;sh&lt;/code&gt; allows everything. The list answers "which binary", never "with which arguments".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Matching stops at the first word:&lt;/strong&gt; &lt;code&gt;filebrowser users update ana --commands "git,rsync"&lt;/code&gt; permits &lt;code&gt;git clone&lt;/code&gt;, &lt;code&gt;git config&lt;/code&gt; and &lt;code&gt;rsync&lt;/code&gt; to any reachable path, because nothing after the first token is inspected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Binaries still have to exist in the image:&lt;/strong&gt; the official FileBrowser container is Alpine based and ships busybox, so &lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;cp&lt;/code&gt;, &lt;code&gt;wget&lt;/code&gt; and &lt;code&gt;sh&lt;/code&gt; are present even though nobody installed them deliberately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A busybox &lt;code&gt;wget&lt;/code&gt; is an exfiltration path:&lt;/strong&gt; one allowlisted networking tool turns read access into outbound transfer, which matters more than write access for a privacy focused setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An empty list is the real off switch:&lt;/strong&gt; leaving Commands empty while Execute stays on gives no usable console, but the safer pair is Execute off and the list empty.&lt;/p&gt;

&lt;p&gt;Where you run the app decides how much a permitted command finds around it. A self managed VPS carries a full distribution with package managers and SSH keys present on disk. A NAS adds the vendor's own tooling. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user, so FileBrowser sits in a container image rather than on a general purpose host, as do the equivalent setups on a home server or a VPS running Docker.&lt;/p&gt;

&lt;p&gt;Review the list per account, not once globally.&lt;/p&gt;




&lt;h2&gt;
  
  
  What do per-user Rules match against, and in what order are they applied?
&lt;/h2&gt;

&lt;p&gt;Rules are the only control that works below the folder level. Everything else grants or denies a whole Scope. A rule is four things: allow or deny, a path or a regular expression, the pattern itself, and the user it belongs to. Default behaviour is allow, so rules exist to carve exceptions out of an otherwise visible tree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Patterns are matched relative to Scope:&lt;/strong&gt; a rule written for &lt;code&gt;/secrets&lt;/code&gt; matches what the user sees as &lt;code&gt;/secrets&lt;/code&gt;, not the host path, so moving Scope changes which files a rule covers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regular expressions use Go's RE2 syntax:&lt;/strong&gt; &lt;code&gt;filebrowser rules add --user ana --regex "\.env$"&lt;/code&gt; works, while lookahead and backreferences are not supported and will be rejected rather than silently ignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two lists are in play:&lt;/strong&gt; there is a global rule list in settings and a per-user list on the account, and a file that survives one list can still be caught by the other, so test the combination rather than reasoning about precedence on paper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Denied paths disappear from listings and from search:&lt;/strong&gt; the entry is filtered out of the directory response, which is what makes rules feel like hiding rather than permission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A deny rule is checked on the request path only:&lt;/strong&gt; a file reachable under a second name, for example through a folder that a pattern does not cover, is served normally.&lt;/p&gt;

&lt;p&gt;Verify with a direct request, not the interface. Log in as the restricted account, then fetch the hidden path by URL and confirm you get an error rather than content. Three well chosen deny rules on dotfiles, key material and backup archives cover most of what a stolen session would want.&lt;/p&gt;




&lt;h2&gt;
  
  
  Do symlinks inside Scope walk straight out of it?
&lt;/h2&gt;

&lt;p&gt;Yes. FileBrowser cleans &lt;code&gt;../&lt;/code&gt; out of request paths before joining them to Scope, but it does not resolve symlinks before deciding whether a path is inside Scope. A link that already exists on disk is opened by the operating system like any other directory entry, and the content on the far side is served normally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The link only has to sit inside Scope:&lt;/strong&gt; if &lt;code&gt;/srv/ana/host&lt;/code&gt; points at &lt;code&gt;/etc&lt;/code&gt;, the account browsing &lt;code&gt;/host&lt;/code&gt; reads files two levels above its own root, and nothing in the interface signals that it left.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Users cannot create one through the web interface:&lt;/strong&gt; there is no symlink endpoint in the file API, so the links that matter are ones placed by you, by a backup tool, or by another application sharing the same volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Execute closes that gap instantly:&lt;/strong&gt; a single allowlisted command running &lt;code&gt;ln -s / /srv/ana/root&lt;/code&gt; converts a read of one folder into a read of everything the process user can open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uploads can carry links inside archives:&lt;/strong&gt; if an extraction step outside FileBrowser unpacks a tar that contains symlinks, the links land in Scope without anyone typing a command.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deny rules do not help here:&lt;/strong&gt; a rule written for &lt;code&gt;/etc&lt;/code&gt; matches the path as the user sees it, so the same target reached through &lt;code&gt;/host&lt;/code&gt; is not covered.&lt;/p&gt;

&lt;p&gt;Audit it directly. Run &lt;code&gt;find /srv -type l -ls&lt;/code&gt; against the mounted tree and confirm every result points somewhere you intended. Run it again after restoring a backup, because archive restores are the common way an old link reappears in a Scope that has since been narrowed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bind-mounts, volumes and the paths you forgot you handed to FileBrowser
&lt;/h2&gt;

&lt;p&gt;Scope decides what an account sees. The mount list decides what exists to be seen at all. A narrow Scope on top of a wide mount is one admin edit away from being a wide Scope, because an account with the Admin toggle can point Scope at anything the container has.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mount line&lt;/th&gt;
&lt;th&gt;What any account can potentially reach&lt;/th&gt;
&lt;th&gt;What it buys you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-v /:/srv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every file the container user can open on the host&lt;/td&gt;
&lt;td&gt;Nothing you cannot get another way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-v /home/tom:/srv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shell history, &lt;code&gt;.ssh&lt;/code&gt;, application tokens under &lt;code&gt;.config&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One less path to type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-v /srv/media:/srv/media&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only the media tree, matching the path on both sides&lt;/td&gt;
&lt;td&gt;Predictable paths in backups and logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-v /srv/archive:/srv/archive:ro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The archive tree, readable but not writable&lt;/td&gt;
&lt;td&gt;Delete and Modify stop at the kernel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Named volume&lt;/td&gt;
&lt;td&gt;Only what you copy into it&lt;/td&gt;
&lt;td&gt;Isolation from the host tree&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Mount the narrowest set of subtrees that the people using FileBrowser actually need, and mount anything historical read-only. Two or three separate mounts are easier to reason about than one parent path.&lt;/p&gt;

&lt;p&gt;Check what is really attached rather than what the compose file says, because an edited file that was never re-deployed proves nothing. Run &lt;code&gt;docker inspect -f '{{json .Mounts}}' filebrowser&lt;/code&gt; and read the list. The same applies on a NAS package or a Yundera app install, where the mapping is set once during installation and then forgotten.&lt;/p&gt;

&lt;p&gt;Anything on that list is inside the blast radius, whether or not a Scope currently points at it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does the Admin toggle hand over that the other seven never can?
&lt;/h2&gt;

&lt;p&gt;Admin is not a stronger version of Modify. It moves the account from working on files to editing the rules about files, which means it can grant itself anything the other seven withhold. That is why an admin session is not bounded by its own Scope in any meaningful sense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It rewrites Scope, including its own:&lt;/strong&gt; an admin opens Users, sets Scope to the server root, and every mounted path becomes browsable in one save with no restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It flips permissions on any account:&lt;/strong&gt; Execute and the Commands list are editable from the same form, so the path from Admin to a running process is two clicks rather than a separate vulnerability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It creates accounts that outlive your response:&lt;/strong&gt; a new user added quietly keeps working after you reset the password on the account that was originally stolen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It edits global settings, not just users:&lt;/strong&gt; branding, the shell used for commands and the global rule list all live behind the Admin toggle, and global rules govern accounts you never touched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It reads the shares list for everyone:&lt;/strong&gt; existing public links become visible and reusable, including links created by other people.&lt;/p&gt;

&lt;p&gt;Keep exactly one admin account, use it only for administration, and give yourself a second non admin account for daily file work. The blast radius of a compromised daily driver then stops at one Scope. If two people genuinely need to add users, accept that you have two accounts that can reach every mount, and set &lt;code&gt;--perm.admin=false&lt;/code&gt; on everyone else with &lt;code&gt;filebrowser users update &amp;lt;name&amp;gt; --perm.admin=false&lt;/code&gt;. Review the user list monthly and confirm the count of admins is still one.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much protection comes from the system user FileBrowser runs as?
&lt;/h2&gt;

&lt;p&gt;More than any toggle in the interface, because this is the limit a hijacked session cannot edit. FileBrowser can only open what its UID and GID can open, and that check happens in the kernel rather than in the application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running as root erases the filesystem layer entirely:&lt;/strong&gt; with a bind mount, root inside the container is root on the host files, so ownership and mode bits stop filtering anything and a read-only intention becomes a suggestion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dedicated UID turns ownership into the real allowlist:&lt;/strong&gt; set &lt;code&gt;user: "1000:1000"&lt;/code&gt; in your compose file, then FileBrowser reaches media owned by that UID and gets permission denied on everything else, including files reached through a symlink.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;World readable files defeat ownership on their own:&lt;/strong&gt; a file at mode 0644 is readable by every UID on the system, so tighten the sensitive ones to 0640 or 0600 rather than relying on who owns them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The database and config still need write access:&lt;/strong&gt; the chosen UID must own the directory holding &lt;code&gt;filebrowser.db&lt;/code&gt;, otherwise the container starts and then fails on the first settings change.&lt;/p&gt;

&lt;p&gt;Verify rather than assume. Run &lt;code&gt;docker exec filebrowser id&lt;/code&gt; and read the numbers, then &lt;code&gt;stat -c '%U %G %a' /srv/media&lt;/code&gt; on the host and compare.&lt;/p&gt;

&lt;p&gt;The hosting shape decides how much of this you set yourself. Yundera is a managed Personal Cloud Server built on CasaOS, where self-hosted apps run as Docker containers on a server dedicated to the user and that data stays on the user's own server. A VPS, a home server or a NAS puts the same UID decision in your hands.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read-only mounts, dropped capabilities and the limits that live below FileBrowser
&lt;/h2&gt;

&lt;p&gt;These controls sit under the application, so no permission edit and no admin session can switch them off. They are also the only ones that constrain a process started through Execute, which every control inside FileBrowser fails to reach.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;What it actually stops&lt;/th&gt;
&lt;th&gt;What it costs you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;read_only: true&lt;/code&gt; on the container&lt;/td&gt;
&lt;td&gt;Any write outside declared volumes, including a dropped binary in &lt;code&gt;/tmp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;You must add a &lt;code&gt;tmpfs&lt;/code&gt; for scratch space and keep the database on its own volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cap_drop: - ALL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Capability based tricks such as changing file ownership with &lt;code&gt;chown&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nothing for normal browsing, uploading and downloading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;security_opt: - no-new-privileges:true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A setuid binary inside the image raising privileges mid process&lt;/td&gt;
&lt;td&gt;Nothing, FileBrowser does not need it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pids_limit&lt;/code&gt; and a memory limit&lt;/td&gt;
&lt;td&gt;A runaway or deliberate fork loop taking the host down with it&lt;/td&gt;
&lt;td&gt;A hard ceiling you have to size once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An internal Docker network with no egress&lt;/td&gt;
&lt;td&gt;Outbound copies of your files to somewhere else&lt;/td&gt;
&lt;td&gt;Breaks any workflow that expects the container to fetch from the internet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Apply them in that order. Read-only plus dropped capabilities is roughly fifteen minutes of work and survives every future permission mistake you make in the interface.&lt;/p&gt;

&lt;p&gt;Test each one after applying it rather than trusting the compose file. Run &lt;code&gt;docker exec filebrowser touch /test&lt;/code&gt; and confirm it fails, then confirm that uploading a file through the web interface still succeeds. A read-only container that also blocks legitimate uploads means a volume is missing, not that the control is wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does the FileBrowser database itself expose if someone reaches it?
&lt;/h2&gt;

&lt;p&gt;Everything FileBrowser knows lives in one bbolt file, normally &lt;code&gt;filebrowser.db&lt;/code&gt;. It is not encrypted at rest. If that file ends up inside a Scope, or inside a mount that Scope can reach, a download turns an account compromise into full control of the installation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It holds the token signing key:&lt;/strong&gt; the settings bucket stores the key used to sign session tokens, and anyone holding it can mint a valid token for any account without ever knowing a password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Password hashes are bcrypt, so they resist cracking but not replacement:&lt;/strong&gt; an attacker with a writable copy does not need to crack anything, they set a new hash and log in as that user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every share record travels with it:&lt;/strong&gt; share hashes and their password hashes are in the same file, so a copy exposes links you issued months ago.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole user table is offline readable:&lt;/strong&gt; &lt;code&gt;filebrowser -d /database/filebrowser.db users ls&lt;/code&gt; prints usernames, Scope paths and permission flags from any copy of the file, on any machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups inherit the same exposure:&lt;/strong&gt; a nightly archive of the config directory dropped into a browsable folder is the same leak with extra steps.&lt;/p&gt;

&lt;p&gt;Store the database outside every served path. A separate mount such as &lt;code&gt;/database&lt;/code&gt; for the file and &lt;code&gt;/srv&lt;/code&gt; for content is the simple layout, and it keeps the two concerns apart even if you later widen a Scope. Then confirm it from the inside: log in as a restricted account and search for &lt;code&gt;filebrowser.db&lt;/code&gt;. Zero results is the outcome you want, and it takes about a minute to check.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you measure your own blast radius with a throwaway account in thirty minutes?
&lt;/h2&gt;

&lt;p&gt;Reasoning about permissions on paper produces the wrong answer often enough that it is worth testing. The test is cheap, repeatable, and the only honest way to know what a stolen session sees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create the account, 2 minutes:&lt;/strong&gt; run &lt;code&gt;filebrowser users add audit &amp;lt;password&amp;gt; --perm.admin=false&lt;/code&gt; with the same toggles and Scope as your least trusted real user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Walk upward in a private window, 5 minutes:&lt;/strong&gt; log in, click to the top of the tree, and write down the highest folder you can list. That folder is your ceiling, not the one in your notes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe the API directly, 10 minutes:&lt;/strong&gt; the interface hides things the API will still serve. Get a token with &lt;code&gt;curl -X POST -d '{"username":"audit","password":"..."}' https://files.example.com/api/login&lt;/code&gt;, then request a path you believe is denied with &lt;code&gt;curl -H "X-Auth: &amp;lt;token&amp;gt;" https://files.example.com/api/resources/secrets&lt;/code&gt;. A 403 or 404 is a pass, a JSON listing is a finding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the toggles you believe are off, 5 minutes:&lt;/strong&gt; try to delete a scratch file, try to create a share, and open the command console. Each failure should be a refusal, not an empty screen you interpret as a refusal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check a share from a logged out browser, 3 minutes:&lt;/strong&gt; copy any link the account can produce and open it in a browser with no session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remove the account and its shares, 2 minutes:&lt;/strong&gt; delete the user, then confirm the shares it created are gone from the list rather than assuming they left with it.&lt;/p&gt;

&lt;p&gt;Repeat after any mount or Scope change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Does where you run FileBrowser change what a breach can reach?
&lt;/h2&gt;

&lt;p&gt;Yes, because the blast radius is defined by what else lives on the machine. The same account with the same Scope reaches very different things depending on what is sitting one directory above it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Advantages of hosting it yourself:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You choose the neighbours:&lt;/strong&gt; a box that holds only media files has nothing else to lose, while a general purpose server holds SSH keys, other applications and their databases in the same filesystem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You set the mount list:&lt;/strong&gt; nobody else decides which subtrees are attached, so the ceiling from your throwaway account test is a decision rather than a default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The files never sit in a shared multi-tenant store:&lt;/strong&gt; a compromise of your account is a compromise of your server, not an entry point into a pooled service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention is yours:&lt;/strong&gt; snapshots, backup frequency and how long a deleted file really survives are settings you own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. A self managed VPS, a home server and a NAS put the same FileBrowser container on hardware you control in different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checklist before you expose it:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One purpose per host:&lt;/strong&gt; no SSH keys, no unrelated application data under any mounted path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS only:&lt;/strong&gt; FileBrowser sends credentials in a login request, so plain HTTP on a shared network is not acceptable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mount list reviewed:&lt;/strong&gt; every entry justified out loud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database outside every served path:&lt;/strong&gt; verified by search, not by memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots enabled:&lt;/strong&gt; because Delete is permanent at the application layer.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>filebrowser</category>
      <category>selfhosted</category>
      <category>security</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How Much of a 900-Recipe Paprika Library Actually Survives the Import into Mealie, and How to Repair the Rest</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 19 Sep 2026 07:07:17 +0000</pubDate>
      <link>https://dev.to/john_182319291/how-much-of-a-900-recipe-paprika-library-actually-survives-the-import-into-mealie-and-how-to-l2a</link>
      <guid>https://dev.to/john_182319291/how-much-of-a-900-recipe-paprika-library-actually-survives-the-import-into-mealie-and-how-to-l2a</guid>
      <description>&lt;p&gt;The recipe bodies survive. The structure around them does not. A 900-recipe Paprika export lands in Mealie with names, ingredient text, directions, source URLs and the primary photo intact, but the per-ingredient quantity, unit and food fields arrive as unparsed strings, and anything Paprika stores that has no equivalent in Mealie's recipe model goes quietly: the photo array beyond the first image, the scale factor, the difficulty label, the favourites flag and Paprika's free text nutrition block. Treat this as two separate jobs, the import itself, which is one file upload and a few minutes of waiting, and a scripted repair pass against the Mealie API, which is where the actual work sits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by profile:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Paprika 3 owner with 900 recipes and ten years of star ratings:&lt;/strong&gt; import everything in one pass, then audit field by field before you cancel anything, because the loss is concentrated in metadata you cannot reconstruct from the recipe text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Nextcloud Cookbook self-hoster moving sideways:&lt;/strong&gt; expect the cleanest of the three migrations, since each recipe is already a schema.org JSON document that maps almost one to one onto Mealie's model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The RecipeKeeper user on Windows and iOS:&lt;/strong&gt; budget the most repair time of the three, because the export is an HTML document plus an image folder and everything Mealie gets has to be scraped back out of markup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The solo developer with Python 3 and an API token:&lt;/strong&gt; script the repair, because the whole job is a paginated GET over /api/recipes plus a PATCH per recipe, and 900 recipes is roughly 20 lines of code away from fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The household cook who will never open a terminal:&lt;/strong&gt; keep the paid app installed and read only for one full season, because the shopping list and offline behaviour matter more than the field mapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone still evaluating:&lt;/strong&gt; run the import against a throwaway Mealie instance with a separate database first, because the importers are not idempotent and a bad run is easier to delete than to unpick.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is plain: Mealie gives you a structured, queryable library you own, and you pay for that structure by rebuilding the parts of your Paprika data that were never structured to begin with.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What a 900-recipe Paprika export actually contains&lt;/li&gt;
&lt;li&gt;Where each Paprika field lands inside Mealie&lt;/li&gt;
&lt;li&gt;Which Paprika fields disappear without a warning?&lt;/li&gt;
&lt;li&gt;Why do your ingredients arrive as plain text instead of structured quantities?&lt;/li&gt;
&lt;li&gt;Do your photos survive, and what happens to the second and third one?&lt;/li&gt;
&lt;li&gt;Ratings, favourites, scale factors and the metadata that never arrives&lt;/li&gt;
&lt;li&gt;How does the Nextcloud Cookbook importer compare with the Paprika one?&lt;/li&gt;
&lt;li&gt;What does a RecipeKeeper export lose on the way into Mealie?&lt;/li&gt;
&lt;li&gt;How do you audit the import before you cancel the subscription?&lt;/li&gt;
&lt;li&gt;Duplicates, failed recipes and the ones that silently never arrive&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What a 900-recipe Paprika export actually contains
&lt;/h2&gt;

&lt;p&gt;Export from Paprika 3 and you get a single file with a &lt;code&gt;.paprikarecipes&lt;/code&gt; extension. Rename it to &lt;code&gt;.zip&lt;/code&gt; and open it: inside sits one gzip-compressed file per recipe, each with a &lt;code&gt;.paprikarecipe&lt;/code&gt; extension. Decompress one and you have a flat JSON object. That is the entire format. There is no schema file, no index, no relational structure between recipes.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;unzip -l library.paprikarecipes | wc -l&lt;/code&gt; before you import anything. If your library holds 900 recipes, you should see 900 entries plus the zip header lines. This is your baseline count, and you will compare it against Mealie's recipe total afterwards.&lt;/p&gt;

&lt;p&gt;Each JSON object carries a predictable set of keys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity fields:&lt;/strong&gt; &lt;code&gt;uid&lt;/code&gt; and &lt;code&gt;hash&lt;/code&gt;, a UUID and a content hash that Paprika uses for sync, with no destination in Mealie's model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free text blocks:&lt;/strong&gt; &lt;code&gt;ingredients&lt;/code&gt;, &lt;code&gt;directions&lt;/code&gt;, &lt;code&gt;notes&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt;, all single strings with newline separators rather than arrays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing and yield:&lt;/strong&gt; &lt;code&gt;prep_time&lt;/code&gt;, &lt;code&gt;cook_time&lt;/code&gt;, &lt;code&gt;total_time&lt;/code&gt; and &lt;code&gt;servings&lt;/code&gt;, all stored as human strings such as &lt;code&gt;1 hr 20 min&lt;/code&gt;, not as integers or ISO 8601 durations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image payloads:&lt;/strong&gt; &lt;code&gt;photo_data&lt;/code&gt; holding a base64 JPEG, plus &lt;code&gt;photo&lt;/code&gt;, &lt;code&gt;photo_hash&lt;/code&gt;, &lt;code&gt;image_url&lt;/code&gt; and a &lt;code&gt;photos&lt;/code&gt; array for additional images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classification and personal signal:&lt;/strong&gt; &lt;code&gt;categories&lt;/code&gt; as an array of strings, plus &lt;code&gt;rating&lt;/code&gt;, &lt;code&gt;difficulty&lt;/code&gt;, &lt;code&gt;on_favorites&lt;/code&gt;, &lt;code&gt;scale&lt;/code&gt;, &lt;code&gt;source&lt;/code&gt;, &lt;code&gt;source_url&lt;/code&gt;, &lt;code&gt;created&lt;/code&gt; and a free text &lt;code&gt;nutritional_info&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing here is invalid. It is simply a sync format for one application, not an interchange format, and that distinction explains every loss described below.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where each Paprika field lands inside Mealie
&lt;/h2&gt;

&lt;p&gt;The import itself runs inside the instance, from the Migrations page under group data management. You upload the &lt;code&gt;.paprikarecipes&lt;/code&gt; file through the browser, so the whole library crosses your reverse proxy in one request. A 900-recipe export with embedded base64 photos is comfortably large enough to hit the nginx default &lt;code&gt;client_max_body_size&lt;/code&gt; of 1m, which returns a 413 before Mealie ever sees the file. Raise that limit first, whether Mealie runs on a self-managed VPS, a home server, a NAS or a Yundera box. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Paprika field&lt;/th&gt;
&lt;th&gt;Mealie destination&lt;/th&gt;
&lt;th&gt;What changes on the way&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;name&lt;/code&gt; plus generated &lt;code&gt;slug&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Duplicate names get a numeric suffix appended to the slug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ingredients&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;recipeIngredient&lt;/code&gt; array&lt;/td&gt;
&lt;td&gt;Split on newlines into one entry per line, stored as original text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;directions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;recipeInstructions&lt;/code&gt; array&lt;/td&gt;
&lt;td&gt;Split into steps, with empty step titles and no grouping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;categories&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Categories and tags&lt;/td&gt;
&lt;td&gt;Created on demand, so 40 Paprika categories become 40 new records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;photo_data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;data/recipes/&amp;lt;id&amp;gt;/images/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Base64 JPEG decoded and rewritten as original, min and tiny WebP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;source_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orgURL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Preserved verbatim, including dead links&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;prep_time&lt;/code&gt;, &lt;code&gt;cook_time&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prepTime&lt;/code&gt;, &lt;code&gt;performTime&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Kept as the original human strings, not parsed into minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row above is a field that arrives. The next section covers the keys with no row at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which Paprika fields disappear without a warning?
&lt;/h2&gt;

&lt;p&gt;Nothing in the interface tells you what did not make it. The import reports a count of recipes created and stops there. These are the keys that have no destination in Mealie's recipe model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;difficulty&lt;/code&gt;:&lt;/strong&gt; Paprika stores an easy, medium or hard label per recipe, and Mealie has no equivalent field anywhere in its schema, so the value is read and discarded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scale&lt;/code&gt;:&lt;/strong&gt; the saved serving multiplier vanishes. Mealie scales at view time from &lt;code&gt;recipeYield&lt;/code&gt;, which means a recipe you always cooked at 2x reverts to the base quantities in the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;on_favorites&lt;/code&gt;:&lt;/strong&gt; favourites in Mealie live on the user record, not the recipe record, so a flag set on 120 recipes leaves you with an empty favourites list for every account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nutritional_info&lt;/code&gt;:&lt;/strong&gt; Paprika holds one free text blob. Mealie expects discrete numeric fields for calories, fat, protein, carbohydrate, fibre, sodium and sugar, and a blob cannot be split into seven typed columns without parsing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;uid&lt;/code&gt; and &lt;code&gt;hash&lt;/code&gt;:&lt;/strong&gt; the sync identifiers are replaced by fresh Mealie UUIDs, which removes any stable key for matching a recipe back to its source file later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Count before you mourn. Decompress the export and run something like &lt;code&gt;for f in *.paprikarecipe; do gunzip -c "$f"; done | jq -r 'select(.difficulty != "") | .name' | wc -l&lt;/code&gt; against each field in turn. Most libraries of 900 recipes have a difficulty set on a few dozen and a scale set on fewer still. Repair effort belongs where the field is actually populated, not where it merely exists in the format.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why do your ingredients arrive as plain text instead of structured quantities?
&lt;/h2&gt;

&lt;p&gt;Because the two applications model an ingredient differently, and the importer refuses to guess.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mealie stores five fields per ingredient:&lt;/strong&gt; a numeric &lt;code&gt;quantity&lt;/code&gt;, a &lt;code&gt;unit&lt;/code&gt; reference, a &lt;code&gt;food&lt;/code&gt; reference, a free &lt;code&gt;note&lt;/code&gt; and the untouched &lt;code&gt;originalText&lt;/code&gt;. Units and foods are separate database tables with their own records, not strings on the recipe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paprika stores one line of text:&lt;/strong&gt; &lt;code&gt;2 cups all-purpose flour, sifted&lt;/code&gt; is a single string with no delimiters, and the export has no field telling you which part is the amount. Mealie's importer keeps the whole line in &lt;code&gt;originalText&lt;/code&gt; and leaves quantity, unit and food unset rather than inventing a split.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaling stops working:&lt;/strong&gt; the serving multiplier in the Mealie interface multiplies the numeric &lt;code&gt;quantity&lt;/code&gt;. With no quantity stored, pressing 2x changes the yield label and leaves every ingredient line reading &lt;code&gt;2 cups&lt;/code&gt; exactly as before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shopping list aggregation stops working:&lt;/strong&gt; Mealie merges list entries that point at the same food record. Import three recipes needing onions and you get three separate unmergeable lines, because there is no onion record to match on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Food and unit filters stay empty:&lt;/strong&gt; browsing by food, or filtering the recipe list on a specific ingredient, queries those tables. After a raw import of 900 recipes, both tables can still contain zero rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The volume matters here. At an average of 10 ingredient lines per recipe, 900 recipes is around 9,000 strings needing structure. That is the single largest repair job in the migration, and Mealie ships a parser for exactly this, covered further down.&lt;/p&gt;




&lt;h2&gt;
  
  
  Do your photos survive, and what happens to the second and third one?
&lt;/h2&gt;

&lt;p&gt;One photo per recipe survives. The rest do not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The primary image is decoded and re-encoded:&lt;/strong&gt; the base64 JPEG in &lt;code&gt;photo_data&lt;/code&gt; is written to disk as WebP in three sizes, &lt;code&gt;original.webp&lt;/code&gt;, &lt;code&gt;min-original.webp&lt;/code&gt; and &lt;code&gt;tiny-original.webp&lt;/code&gt;, under &lt;code&gt;data/recipes/&amp;lt;uuid&amp;gt;/images/&lt;/code&gt;. The picture is intact, but the bytes are not, so checksum comparison against the source file will never match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;photos&lt;/code&gt; array has no destination:&lt;/strong&gt; Paprika lets you attach several images to one recipe. The Mealie recipe model carries a single main image plus a separate assets folder, and the migration does not populate assets, so photo two onwards is read and dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recipes holding only &lt;code&gt;image_url&lt;/code&gt; arrive blank:&lt;/strong&gt; if a recipe has a remote URL and no embedded payload, nothing is fetched during the import and the card shows the default placeholder image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image storage leaves the database:&lt;/strong&gt; everything lands on the filesystem, not in SQLite or PostgreSQL. A &lt;code&gt;pg_dump&lt;/code&gt; alone is not a backup of your library, and &lt;code&gt;du -sh data/recipes&lt;/code&gt; after import is the number that matters for your disk planning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Count the damage before you decide whether to care. Decompress the export and run &lt;code&gt;jq 'select((.photos | length) &amp;gt; 1) | .name'&lt;/code&gt; across the files. Most libraries keep multi-photo recipes in the low dozens, usually the ones you cooked and photographed yourself. If the count is under 50, re-attaching them by hand through the recipe editor is faster than writing an uploader.&lt;/p&gt;




&lt;h2&gt;
  
  
  Ratings, favourites, scale factors and the metadata that never arrives
&lt;/h2&gt;

&lt;p&gt;Some of this is recoverable from the export file. Some of it is gone for good, because it was never in the export to begin with.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal you had&lt;/th&gt;
&lt;th&gt;Where it would live in Mealie&lt;/th&gt;
&lt;th&gt;What you can do about it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Star ratings&lt;/td&gt;
&lt;td&gt;Recipe &lt;code&gt;rating&lt;/code&gt;, and a separate per-user rating in recent releases&lt;/td&gt;
&lt;td&gt;Verify one known 5-star recipe after import, then PATCH the rest from the JSON if the view is empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Favourites flag&lt;/td&gt;
&lt;td&gt;A per-user favourites relation, not a recipe column&lt;/td&gt;
&lt;td&gt;Rebuild with one API call per recipe, filtered on &lt;code&gt;on_favorites&lt;/code&gt; in the source files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saved scale factor&lt;/td&gt;
&lt;td&gt;No field exists&lt;/td&gt;
&lt;td&gt;Fold the multiplier into &lt;code&gt;recipeYield&lt;/code&gt; text by hand, only for recipes you always cooked scaled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date added&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;createdAt&lt;/code&gt; and &lt;code&gt;dateAdded&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;All 900 recipes share the import date, so sort by name until you backfill from the &lt;code&gt;created&lt;/code&gt; key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last cooked date and history&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lastMade&lt;/code&gt; plus timeline events&lt;/td&gt;
&lt;td&gt;Nothing arrives, and nothing can, because Paprika never exported a cooking log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools and equipment&lt;/td&gt;
&lt;td&gt;A dedicated tools table&lt;/td&gt;
&lt;td&gt;Starts empty, since Paprika has no equivalent concept to map from&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sort your repair list by whether the data exists somewhere. Ratings, favourites and creation dates all sit in the decompressed &lt;code&gt;.paprikarecipe&lt;/code&gt; files, so they are a scripted backfill and nothing more. The cooking timeline is different. Mealie builds it from events you record after the fact, which means the history starts the day you import and your previous years of cooking simply do not exist in the new system. Accept that one rather than hunting for a workaround.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does the Nextcloud Cookbook importer compare with the Paprika one?
&lt;/h2&gt;

&lt;p&gt;It is the cleaner of the two, because the source format was designed for interchange rather than sync.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The unit of migration is a folder, not a blob:&lt;/strong&gt; Nextcloud Cookbook stores each recipe as a directory holding &lt;code&gt;recipe.json&lt;/code&gt; plus &lt;code&gt;full.jpg&lt;/code&gt; and &lt;code&gt;thumb.jpg&lt;/code&gt;. Zip the parent directory before uploading, and confirm the count first with &lt;code&gt;find . -name recipe.json | wc -l&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;recipe.json&lt;/code&gt; is a schema.org Recipe document:&lt;/strong&gt; the same vocabulary Mealie uses internally and the same vocabulary its URL scraper targets, so the mapping is field to field rather than key to guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ingredients arrive pre-split:&lt;/strong&gt; &lt;code&gt;recipeIngredient&lt;/code&gt; is already a JSON array with one entry per ingredient, which removes every newline splitting error. The strings themselves are still unstructured, so the parsing job from the previous section is unchanged and unavoidable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durations are ISO 8601:&lt;/strong&gt; a value of &lt;code&gt;PT1H20M&lt;/code&gt; lands as a real duration instead of the human text strings that come across from a Paprika export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nutrition maps key by key:&lt;/strong&gt; the schema.org nutrition object exposes calories, fat, protein and carbohydrate as separate properties, which drop straight into Mealie's seven nutrition fields with no parsing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keywords become tags directly:&lt;/strong&gt; &lt;code&gt;keywords&lt;/code&gt; is a flat list, so there is no ambiguity about whether a value belongs in categories or tags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The loss is different in kind. Nextcloud Cookbook never stored a favourites flag, a cooking log or a personal star rating, so there is no personal signal to lose and nothing to backfill. You migrate structure, and your history was already absent.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does a RecipeKeeper export lose on the way into Mealie?
&lt;/h2&gt;

&lt;p&gt;RecipeKeeper hands over the weakest of the three formats, because it exports a web page rather than a data file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The whole library is one document:&lt;/strong&gt; a zip containing &lt;code&gt;recipes.html&lt;/code&gt; plus an images directory. There is no per-recipe file, so your baseline count comes from markup, not from a file listing: &lt;code&gt;grep -c 'itemprop="name"' recipes.html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parsing depends on microdata attributes surviving intact:&lt;/strong&gt; the importer reads &lt;code&gt;itemprop&lt;/code&gt; values out of the HTML. Open and resave that file in an editor that rewrites markup and the import will find fewer recipes, report success, and give you no error to investigate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photos are referenced by relative path, not embedded:&lt;/strong&gt; the images folder has to stay inside the zip at the exact path the HTML expects. Rearrange the archive and recipes import cleanly with no picture and no warning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unicode fractions come through literally:&lt;/strong&gt; RecipeKeeper writes ½ and ¼ as single characters. They survive into &lt;code&gt;originalText&lt;/code&gt; fine, but the ingredient parser handles &lt;code&gt;1/2 cup&lt;/code&gt; far more reliably than the glyph, which shifts work into the repair pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal fields are presentation markup:&lt;/strong&gt; course, rating and source are styled page elements rather than typed columns, so what arrives depends entirely on the markup shape of your export version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No stable identifiers exist anywhere:&lt;/strong&gt; run the import twice and you get two complete copies of every recipe, with no key available to match and deduplicate them afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify a sample of 10 recipes chosen to cover every field you care about before you trust a run of 900.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you audit the import before you cancel the subscription?
&lt;/h2&gt;

&lt;p&gt;Create an API token from your user profile, then check the library from the outside rather than by scrolling it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compare totals before anything else:&lt;/strong&gt; run &lt;code&gt;curl -H "Authorization: Bearer $TOKEN" "https://your-mealie-host/api/recipes?page=1&amp;amp;perPage=1" | jq .total&lt;/code&gt; and hold it against the file count you took from the export. If 900 went in and 894 came out, you have six files to find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff the name lists, not just the counts:&lt;/strong&gt; pull every name with &lt;code&gt;perPage=1000&lt;/code&gt;, sort it, and diff against the names extracted from the source. Numeric slug suffixes expose duplicate titles, and absent names expose skipped files, which counts alone will hide when a duplicate offsets a failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample 30 recipes at random rather than the first 30:&lt;/strong&gt; the top of an alphabetical list tends to hold your oldest and simplest entries. Random sampling surfaces the recipes with tables in the directions, nested lists or multiple photos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probe images with HEAD requests, not with clicks:&lt;/strong&gt; loop over the recipe IDs and request the image path for each. Counting 404 responses takes one loop and tells you exactly how many cards are showing a placeholder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count the fields you expect to be empty:&lt;/strong&gt; query how many ingredient entries have no food reference and how many recipes have a null rating. Those two numbers define the size of the repair work ahead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Archive the export file permanently:&lt;/strong&gt; the &lt;code&gt;.paprikarecipes&lt;/code&gt; zip is now the only copy of every field Mealie discarded, so store it beside your backups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A matching count proves the rows moved. It does not prove the library is usable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Duplicates, failed recipes and the ones that silently never arrive
&lt;/h2&gt;

&lt;p&gt;A migration that half worked looks identical to one that worked. The interface reports what it created, never what it skipped.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Individual files fail for boring reasons:&lt;/strong&gt; a character encoding problem, a truncated base64 photo payload or a malformed JSON body stops that one recipe. The run continues, the total comes back lower than your baseline, and nothing in the browser says which file died.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The container log is the only witness:&lt;/strong&gt; watch it during the run rather than afterwards, with something like &lt;code&gt;docker logs -f mealie&lt;/code&gt; in a second terminal. Per-file errors are named there and nowhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate titles are usually genuine recipes:&lt;/strong&gt; Paprika happily stores two entries called Roast Chicken. Mealie keeps both and appends a numeric suffix to the second slug. Deleting everything with a suffixed slug is a fast way to lose real data, so compare bodies before you prune.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The genuine duplicate risk is re-running the import:&lt;/strong&gt; the migration creates records, it does not match and update them. Upload the same file twice and 900 recipes become 1,800, with no key available to pair the copies back together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag the run so it has a boundary:&lt;/strong&gt; if the migration form offers to tag what it creates, enable it. Otherwise apply a dated tag such as &lt;code&gt;import-2026-04&lt;/code&gt; immediately afterwards through bulk actions, so one filtered &lt;code&gt;/api/recipes/bulk-actions/delete&lt;/code&gt; call unwinds the whole attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Take a Mealie backup before every attempt:&lt;/strong&gt; restoring a backup from the settings area is a single operation, while deleting 900 recipes by hand is an evening you will not enjoy.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mealie</category>
      <category>selfhosted</category>
      <category>homelab</category>
      <category>python</category>
    </item>
    <item>
      <title>Self-Hosted WordPress vs WordPress.com Business vs WP Engine and Kinsta: The Real 3-Year Cost for a Two-Person Startup</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:04:41 +0000</pubDate>
      <link>https://dev.to/john_182319291/self-hosted-wordpress-vs-wordpresscom-business-vs-wp-engine-and-kinsta-the-real-3-year-cost-for-a-5248</link>
      <guid>https://dev.to/john_182319291/self-hosted-wordpress-vs-wordpresscom-business-vs-wp-engine-and-kinsta-the-real-3-year-cost-for-a-5248</guid>
      <description>&lt;p&gt;For most two-person startups, self-hosted WordPress has the lowest cash cost over three years. It only has the lowest total cost if one founder can spend a small, steady amount of time each month on updates, backups and security, and does not bill that time at a consultant's rate. WordPress.com Business and managed hosts such as WP Engine or Kinsta cost more in fees but take most of that maintenance work away. The deciding figures are not the hosting fees. They are the renewal price of premium plugins, the cost of an incident, and how much each founder's hour is worth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The technical founder with spare evenings&lt;/strong&gt; (for example, a developer building a SaaS landing page and blog): self-host on a small VPS or a personal cloud server, because the hosting bill stays small and the maintenance is familiar work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The non-technical pair selling a service&lt;/strong&gt; (for example, two consultants who need a credible site and a contact form): use WordPress.com Business, because a fixed yearly fee costs less than learning server administration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The WooCommerce shop with real revenue at stake&lt;/strong&gt; (for example, a two-person direct-to-consumer brand): use a managed host like Kinsta or WP Engine, because staging, backups and support cost less than one bad outage during a sale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The content-heavy startup expecting traffic spikes&lt;/strong&gt; (for example, a media newsletter that gets shared widely): self-host behind a CDN or pick a managed plan with generous visit limits, because per-visit plan limits are where managed hosting costs rise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bootstrapped team planning to pivot&lt;/strong&gt; (for example, founders still testing product-market fit): self-host with a lean free plugin stack, because leaving is cheap and nothing is locked into a plan tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The team handling customer data under strict rules&lt;/strong&gt; (for example, a health or legal tech startup): self-host where you control the hosting location, because data location and access logs matter more than saving admin hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is simple: self-hosting spends your time to save money, and managed WordPress spends money to save your time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;How much does WordPress really cost a two-person startup over 3 years?&lt;/li&gt;
&lt;li&gt;What goes into the total cost beyond the hosting bill&lt;/li&gt;
&lt;li&gt;What does self-hosting WordPress cost in year one, year two and year three?&lt;/li&gt;
&lt;li&gt;How WordPress.com Business pricing and plan limits play out over three years&lt;/li&gt;
&lt;li&gt;What do WP Engine and Kinsta charge once you hit visit and storage limits?&lt;/li&gt;
&lt;li&gt;Premium plugins and themes: the renewal costs that grow each year&lt;/li&gt;
&lt;li&gt;How many hours a month does self-hosted WordPress maintenance actually take?&lt;/li&gt;
&lt;li&gt;Backups, staging and disaster recovery across the three options&lt;/li&gt;
&lt;li&gt;Is self-hosted WordPress secure enough without a managed host's protection?&lt;/li&gt;
&lt;li&gt;What happens to the cost when traffic grows or WooCommerce is added?&lt;/li&gt;
&lt;li&gt;Where to run self-hosted WordPress: VPS, home server or personal cloud server&lt;/li&gt;
&lt;li&gt;Data sovereignty and hosting location for a small WordPress site&lt;/li&gt;
&lt;li&gt;How hard is it to switch between self-hosted and managed WordPress later?&lt;/li&gt;
&lt;li&gt;Which option fits your startup: recommendations by profile&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How much does WordPress really cost a two-person startup over 3 years?
&lt;/h2&gt;

&lt;p&gt;WordPress itself costs nothing. The software is released under the GPLv2 licence, and you can download it from wordpress.org free of charge. The real cost is everything around it, and over 36 months those extra costs add up to far more than the first invoice suggests.&lt;/p&gt;

&lt;p&gt;A fair comparison needs three kinds of cost. &lt;strong&gt;Cash&lt;/strong&gt; is what goes on the card: hosting, domains, premium plugins and backup storage. &lt;strong&gt;Time&lt;/strong&gt; is the hours a founder spends on updates, fixing things and restores. &lt;strong&gt;Risk&lt;/strong&gt; is the expected cost of downtime, a hacked site or lost orders. Pricing pages only show the first kind. For a two-person team, the second and third often decide the answer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What the bill covers&lt;/th&gt;
&lt;th&gt;What you still handle&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted on a VPS&lt;/td&gt;
&lt;td&gt;Server, domain, any paid plugins, off-site backup storage&lt;/td&gt;
&lt;td&gt;Server updates, WordPress updates, security, backups, restores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WordPress.com Business&lt;/td&gt;
&lt;td&gt;Hosting, updates, backups, security for the platform&lt;/td&gt;
&lt;td&gt;Plugin choices, content, anything a plan limit blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WP Engine&lt;/td&gt;
&lt;td&gt;Managed hosting, staging, backups, support&lt;/td&gt;
&lt;td&gt;Plugin licences, overage if you pass the visit limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kinsta&lt;/td&gt;
&lt;td&gt;Managed hosting, staging, backups, CDN, support&lt;/td&gt;
&lt;td&gt;Plugin licences, add-ons, moving to a higher tier as you grow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is the same across all four. Self-hosting has the smallest fixed bill and the largest time cost. Managed plans reverse that. Over three years, renewals and growth decide which side comes out ahead. Plugin renewals and plan upgrades happen every year, while the time you spend learning to run a server mostly happens in year one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What goes into the total cost beyond the hosting bill
&lt;/h2&gt;

&lt;p&gt;The hosting bill is the easy part to see. Most of the three-year total sits in smaller costs that only show up once the site is live. Each one is small on its own, but together they are what make a cheap-looking setup expensive.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain renewal&lt;/strong&gt;: you pay it every year whatever you choose, and some registrars charge far more to renew than they did the first year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS certificates&lt;/strong&gt;: Let's Encrypt certificates are free but expire after 90 days, so a self-hosted setup needs automatic renewal that someone checks is working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transactional email&lt;/strong&gt;: WordPress sends email through &lt;code&gt;wp_mail()&lt;/code&gt;, which uses PHP mail by default and often lands in spam, so most teams add an SMTP plugin and a sending service such as Amazon SES or Mailgun.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Off-site backups&lt;/strong&gt;: a backup kept on the same server does not protect you, so you need storage somewhere else, such as Backblaze B2 or an S3 bucket, plus a plugin like UpdraftPlus to send backups there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDN and DNS&lt;/strong&gt;: Cloudflare's free plan covers many small sites, but image-heavy pages or WooCommerce checkouts can push you towards paid features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uptime monitoring&lt;/strong&gt;: if nobody is watching, the first person to notice an outage is usually a customer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Founder hours&lt;/strong&gt;: every update, failed restore and plugin conflict costs time, and time is what a two-person team has least of.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where you run the site changes which of these you manage yourself. A self-managed VPS, a home server and a NAS all leave TLS, networking and backups to you. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. On that kind of setup, public HTTPS access comes with the subdomain, but email, off-site backups and plugin upkeep are still your job.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does self-hosting WordPress cost in year one, year two and year three?
&lt;/h2&gt;

&lt;p&gt;Self-hosting costs change shape over three years. Year one has most of the learning and setup work. Years two and three are cheaper in time, but plugin renewals and growing storage start to show up on the bill.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost line&lt;/th&gt;
&lt;th&gt;Year one&lt;/th&gt;
&lt;th&gt;Years two and three&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;Small VPS, home server, NAS or a platform such as Yundera, often the smallest size that runs PHP and MySQL&lt;/td&gt;
&lt;td&gt;Same size unless traffic or WooCommerce needs more memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;Heaviest: web server, database, TLS, SMTP, backups, caching&lt;/td&gt;
&lt;td&gt;Light: occasional rebuild or migration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software upkeep&lt;/td&gt;
&lt;td&gt;WordPress applies minor updates automatically, but a person must test the major releases that come out a few times a year&lt;/td&gt;
&lt;td&gt;Same routine, plus PHP 8.x version upgrades that can break older plugins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operating system&lt;/td&gt;
&lt;td&gt;Choose an Ubuntu LTS release to get 5 years of standard support&lt;/td&gt;
&lt;td&gt;Plan one OS upgrade if you started late in a release's life&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium plugins&lt;/td&gt;
&lt;td&gt;First-year prices, often discounted&lt;/td&gt;
&lt;td&gt;Full renewal prices on every licence you kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup storage&lt;/td&gt;
&lt;td&gt;Small, a few snapshots&lt;/td&gt;
&lt;td&gt;Grows with each upload and every retained copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incidents&lt;/td&gt;
&lt;td&gt;Most likely, while the setup is still new&lt;/td&gt;
&lt;td&gt;Less frequent, but each one takes longer if nobody has practised a restore&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mistake teams make is judging the whole cost on year one alone. In year one, self-hosting looks expensive in hours and cheap in cash. By year three, you spend far fewer hours, but the cash cost has quietly risen through plugin renewals and storage. That third year is the right number to hold up against a managed plan's renewal price, not the introductory one.&lt;/p&gt;

&lt;p&gt;One routine keeps the time cost predictable. Set a fixed monthly maintenance window, put every update through a staging copy first, and run a timed test restore once a quarter.&lt;/p&gt;




&lt;h2&gt;
  
  
  How WordPress.com Business pricing and plan limits play out over three years
&lt;/h2&gt;

&lt;p&gt;WordPress.com Business is the only WordPress.com plan most startups need to think about, because it is the first plan where you can install your own plugins and themes. That access is what makes it a real alternative to self-hosting. The three-year cost depends less on the price shown on the page and more on how you pay and what the plan does not include.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Billing term&lt;/strong&gt;: monthly billing costs the most per month, while annual and multi-year terms cost less per month but require payment upfront. For a startup short on cash, paying for 36 months at once can be a hard call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Introductory pricing&lt;/strong&gt;: the first-term price is often discounted, and renewals charge the standard rate. Build your three-year total on the renewal price, not the checkout price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundled domain&lt;/strong&gt;: an annual plan includes a domain registration for the first year only. From year two, you pay the domain renewal separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer access&lt;/strong&gt;: Business includes SFTP, SSH and database access, so a technical founder can still debug problems. You do not get root access to the server, so you cannot install system packages or tune PHP beyond what the platform lets you change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugins you still pay for&lt;/strong&gt;: the plan pays for hosting, not for premium plugin licences, so your SEO, forms and membership renewals cost the same as they would on a VPS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store features&lt;/strong&gt;: a serious WooCommerce shop may push you onto a higher Commerce tier, which moves you into a different price band.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This plan works best as a fixed, predictable cost. It works worst when you need something the platform does not allow. That usually means leaving for Kinsta, WP Engine or a VPS, and paying for the migration on top.&lt;/p&gt;




&lt;h2&gt;
  
  
  What do WP Engine and Kinsta charge once you hit visit and storage limits?
&lt;/h2&gt;

&lt;p&gt;WP Engine and Kinsta both sell plans in tiers. Each tier sets a limit on sites, visits and storage. The entry price is only an accurate guide to your three-year cost if the site stays inside those limits. Once you go over, you either pay overage fees or move up a tier, and the next tier is usually a big jump rather than a small step.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;How it raises the bill&lt;/th&gt;
&lt;th&gt;What to watch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monthly visits&lt;/td&gt;
&lt;td&gt;Overage fees on each block of extra visits, or a forced move to a higher tier&lt;/td&gt;
&lt;td&gt;Both hosts count unique visitors over a 24-hour window, and bot traffic can count too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Paid add-on or higher tier once media and backups fill the quota&lt;/td&gt;
&lt;td&gt;WooCommerce product images and uncompressed uploads grow fastest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bandwidth or CDN usage&lt;/td&gt;
&lt;td&gt;Charges or tier changes on plans that measure data transfer&lt;/td&gt;
&lt;td&gt;Large downloads, video and podcast files served from the same site&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Number of sites&lt;/td&gt;
&lt;td&gt;A second site, such as a docs or app marketing site, can need a bigger plan&lt;/td&gt;
&lt;td&gt;Staging copies usually do not count, but separate production sites do&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add-ons&lt;/td&gt;
&lt;td&gt;Extra fees for features like extra backups, security add-ons or additional PHP workers&lt;/td&gt;
&lt;td&gt;Features you enable once and then forget are billed every month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a two-person startup, the danger is a success spike, not steady growth. A launch post that goes viral or a mention in a newsletter can push one month over the visit limit. On a self-hosted server behind Cloudflare, the same spike usually costs nothing extra as long as caching holds.&lt;/p&gt;

&lt;p&gt;Before you commit to a year, look at the analytics from your current site. Estimate your visits in year three, including bots, and price the tier you will need then, not the tier you need today.&lt;/p&gt;




&lt;h2&gt;
  
  
  Premium plugins and themes: the renewal costs that grow each year
&lt;/h2&gt;

&lt;p&gt;Premium plugins are the part of the WordPress budget people forget to check. They cost the same whether you self-host, use WordPress.com Business or pay Kinsta, so the hosting choice does not change this line. What does change is how many plugins you keep adding over three years. Most are sold as yearly licences, and each one renews on its own date.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Licence expiry is not a shutdown&lt;/strong&gt;: plugins are GPL, so the code keeps running after the licence lapses, but updates and support stop. Running old code on a public site is a security debt that someone eventually has to pay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renewal versus first-year price&lt;/strong&gt;: many vendors discount the first year, so your year-two cost for Gravity Forms, WP Rocket or Yoast SEO Premium can be higher than what you paid at checkout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Site-count tiers&lt;/strong&gt;: a single-site licence covers one production site. Adding a second site, such as a documentation site, can force every licence up to the multi-site tier at the same time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page builders and themes&lt;/strong&gt;: Elementor Pro or a premium theme ties your layouts to that vendor, so dropping the licence later means rebuilding pages instead of simply cancelling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce extensions&lt;/strong&gt;: subscriptions, bookings and payment gateway add-ons are often sold separately, and a shop can end up with more of these licences than every other plugin combined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free alternatives&lt;/strong&gt;: Rank Math or Yoast free, Contact Form 7 and a well-configured server cache cover many needs at no licence cost, as long as you accept less vendor support.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run &lt;code&gt;wp plugin list&lt;/code&gt; once a quarter. For each paid plugin, write down its renewal date and what you would use instead. If nobody on the team can name a reason to keep a plugin, cancel it before it renews.&lt;/p&gt;




&lt;h2&gt;
  
  
  How many hours a month does self-hosted WordPress maintenance actually take?
&lt;/h2&gt;

&lt;p&gt;Nobody can give you an honest single number, because the hours depend on your setup more than on WordPress. Instead, count the tasks and time each one yourself over your first three months. After that, the monthly total usually settles into a predictable routine with occasional spikes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Core and plugin updates&lt;/strong&gt;: running &lt;code&gt;wp core update&lt;/code&gt; and &lt;code&gt;wp plugin update --all&lt;/code&gt; takes minutes. Most of the time goes on testing checkout, forms and login on a staging copy before you push to production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server patching&lt;/strong&gt;: &lt;code&gt;apt upgrade&lt;/code&gt; and occasional reboots on a VPS, home server or NAS. The OS layer is less of your job on platforms that package WordPress as a Docker container, such as Yundera, although you still update the app itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup checks&lt;/strong&gt;: a backup job that reports success is not proof. Export a test with &lt;code&gt;wp db export&lt;/code&gt;, restore it somewhere else, and time the whole process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security review&lt;/strong&gt;: look at failed logins, unknown admin users and file changes in &lt;code&gt;wp-content&lt;/code&gt;. Each check is short, but it needs to happen on schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance work&lt;/strong&gt;: clear caches, compress images and look for slow queries when pages get sluggish. This work comes in bursts rather than every month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incident response&lt;/strong&gt;: a plugin conflict, a full disk or an expired certificate. These are rare but take the longest, and they always happen at a bad time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To turn hours into money, multiply the monthly hours by the value of a founder's hour. Use the rate that founder could bill clients, or the value of product work they are not doing. Add a buffer for incidents. Compare that figure with the difference between your hosting bill and a managed plan. In a two-person team, one founder usually ends up owning this work, so price it at their rate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Backups, staging and disaster recovery across the three options
&lt;/h2&gt;

&lt;p&gt;Backups only matter on the day you need to restore one, so compare the three options by what a restore actually involves. For WooCommerce, also ask how many orders you could lose between the last backup and the moment the site broke.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Recovery need&lt;/th&gt;
&lt;th&gt;Self-hosted WordPress&lt;/th&gt;
&lt;th&gt;WordPress.com Business, WP Engine, Kinsta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily backups&lt;/td&gt;
&lt;td&gt;You set it up yourself with UpdraftPlus, a cron job or server snapshots&lt;/td&gt;
&lt;td&gt;Included and automatic, with retention set by the plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Off-site copy&lt;/td&gt;
&lt;td&gt;Your job: follow the 3-2-1 rule with at least one copy on separate storage&lt;/td&gt;
&lt;td&gt;Stored by the host, and you need an extra export if you want a copy outside the host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging site&lt;/td&gt;
&lt;td&gt;Manual clone of files and database, plus search-and-replace for URLs with &lt;code&gt;wp search-replace&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One-click staging, then push to live when ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restore speed&lt;/td&gt;
&lt;td&gt;Depends on how often you practise it and how big &lt;code&gt;wp-content/uploads&lt;/code&gt; is&lt;/td&gt;
&lt;td&gt;A button in the dashboard, usually quick for small sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WooCommerce orders&lt;/td&gt;
&lt;td&gt;A nightly backup can lose a full day of orders on restore&lt;/td&gt;
&lt;td&gt;Same risk unless the plan offers more frequent backups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full host failure&lt;/td&gt;
&lt;td&gt;You rebuild on another server from your off-site copy&lt;/td&gt;
&lt;td&gt;You wait for the provider, or restore elsewhere from your own export&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Managed hosts are better at the everyday cases: you break something with an update and roll back in minutes. Self-hosting can be better at the rare disaster, but only if you have been keeping an independent copy of the database and &lt;code&gt;wp-content&lt;/code&gt;. Many managed customers never make that copy, and they discover the gap when an account is suspended or a billing problem locks them out.&lt;/p&gt;

&lt;p&gt;Whichever option you choose, keep &lt;code&gt;wp-config.php&lt;/code&gt; settings, the latest database export and the uploads folder somewhere your hosting provider cannot reach. Test restoring from it twice a year.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is self-hosted WordPress secure enough without a managed host's protection?
&lt;/h2&gt;

&lt;p&gt;Yes, if you treat security as a routine rather than a product. Most WordPress compromises come through outdated plugins, weak admin passwords and abandoned themes, not through WordPress core. A managed host reduces some of that risk, but it cannot protect a site that runs a vulnerable plugin you chose to install.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plugin auto-updates&lt;/strong&gt;: since WordPress 5.5 you can switch on auto-updates for each plugin. Turn them on for low-risk plugins and keep manual, tested updates for WooCommerce and payment gateways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin access&lt;/strong&gt;: use unique passwords and two-factor authentication through a plugin such as WP 2FA or Wordfence. Keep administrator accounts to the two founders only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardening in &lt;code&gt;wp-config.php&lt;/code&gt;&lt;/strong&gt;: set &lt;code&gt;DISALLOW_FILE_EDIT&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; so a stolen login cannot edit PHP files from the dashboard. Keep the database credentials out of any public repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attack surface&lt;/strong&gt;: block &lt;code&gt;xmlrpc.php&lt;/code&gt; if nothing uses it, rate-limit &lt;code&gt;wp-login.php&lt;/code&gt; with fail2ban or your firewall, and delete unused themes and plugins instead of just deactivating them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge filtering&lt;/strong&gt;: Cloudflare in front of the origin hides its IP and absorbs a lot of automated traffic. Its free plan includes basic protections, and managed rulesets need a paid tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detection&lt;/strong&gt;: file-integrity scans and alerts when a new admin account appears. Without them, a quiet compromise can sit unnoticed for months.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What managed hosts really add is people and speed. Kinsta and WP Engine run server-level firewalls, patch PHP for you, and some clean up malware if a site is infected. On a self-hosted server, cleanup is your job, and it is the most expensive hour in this whole cost model. Put a realistic incident allowance into your three-year budget rather than assuming it will not happen.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happens to the cost when traffic grows or WooCommerce is added?
&lt;/h2&gt;

&lt;p&gt;A brochure site with a blog is mostly static pages. A cache can serve those pages without running PHP, so extra traffic costs very little. WooCommerce changes that, because carts, checkouts and account pages are different for every visitor and cannot be served from a shared page cache.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uncacheable pages&lt;/strong&gt;: &lt;code&gt;/cart/&lt;/code&gt;, &lt;code&gt;/checkout/&lt;/code&gt; and &lt;code&gt;/my-account/&lt;/code&gt; run PHP and query the database for every visitor. On a VPS that means more memory. On Kinsta or WP Engine it can mean more PHP workers or a higher tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object caching&lt;/strong&gt;: Redis cuts repeated database queries on dynamic pages. Self-hosted, it is one more service to run and keep an eye on. On managed plans, it is sometimes a paid add-on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order storage&lt;/strong&gt;: WooCommerce 8.2 made High-Performance Order Storage the default for new stores. Older stores that still keep orders in &lt;code&gt;wp_posts&lt;/code&gt; should migrate before order volume makes the database slow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled tasks&lt;/strong&gt;: the default &lt;code&gt;wp-cron&lt;/code&gt; only runs when someone visits the site, which is unreliable for subscription renewals. Set &lt;code&gt;DISABLE_WP_CRON&lt;/code&gt; and call &lt;code&gt;wp cron event run --due-now&lt;/code&gt; from a real system cron instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment compliance&lt;/strong&gt;: hosted payment fields from Stripe or PayPal keep card data off your server and usually put you in the simplest PCI DSS questionnaire, SAQ A. Collecting card numbers yourself makes compliance far more work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extension licences&lt;/strong&gt;: shipping, tax and subscription plugins add renewal lines, often several at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern over three years is clear. Traffic growth by itself barely moves a self-hosted budget, but it pushes managed plans up through visit limits. Adding WooCommerce raises the cost on both sides. For a store, the cost of downtime now includes lost sales, which makes paying for managed support easier to justify.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to run self-hosted WordPress: VPS, home server or personal cloud server
&lt;/h2&gt;

&lt;p&gt;Once you decide to self-host, the next choice is where the server lives. That choice decides how much of the stack you manage below WordPress itself. It is the second-largest time factor after your plugin list.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What you manage&lt;/th&gt;
&lt;th&gt;Main tradeoff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPS (Hetzner, DigitalOcean, OVHcloud)&lt;/td&gt;
&lt;td&gt;OS, web server, PHP, database, TLS, firewall, backups&lt;/td&gt;
&lt;td&gt;Full control and a public IP, but every layer of the stack is your job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Home server&lt;/td&gt;
&lt;td&gt;Hardware, OS, networking, power, plus everything a VPS needs&lt;/td&gt;
&lt;td&gt;Hardware you already own, but CGNAT, blocked ports 80 and 443, and slow home upload speeds can rule it out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAS (Synology, QNAP)&lt;/td&gt;
&lt;td&gt;Container or package setup, router forwarding, DNS, TLS&lt;/td&gt;
&lt;td&gt;Convenient if you already own one, but a public store on the same box as company files mixes risks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed personal cloud server&lt;/td&gt;
&lt;td&gt;WordPress, plugins, content, off-site backups&lt;/td&gt;
&lt;td&gt;Dedicated server with one-click app installs and HTTPS handled, less control over the underlying system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared hosting&lt;/td&gt;
&lt;td&gt;WordPress and plugins only&lt;/td&gt;
&lt;td&gt;Low effort, but noisy neighbours and limited PHP settings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the first four options, running WordPress in containers is the most portable approach. The official &lt;code&gt;wordpress&lt;/code&gt; Docker image plus a &lt;code&gt;mariadb&lt;/code&gt; container, with &lt;code&gt;wp-content&lt;/code&gt; and the database on named volumes, can be moved between a VPS, a NAS and a dedicated server without reinstalling anything.&lt;/p&gt;

&lt;p&gt;Pick based on who owns the pager. If one founder is comfortable with Linux, a VPS is the most flexible choice. If neither wants to manage networking and certificates, choose an option that handles those layers. Otherwise, the hours you save on hosting fees will go into troubleshooting DNS and TLS. A home server works for staging but is a risky place for a store that needs to stay online.&lt;/p&gt;




&lt;h2&gt;
  
  
  Data sovereignty and hosting location for a small WordPress site
&lt;/h2&gt;

&lt;p&gt;Even a small WordPress site handles personal data: contact form entries, comment IP addresses in &lt;code&gt;wp_comments&lt;/code&gt;, newsletter signups and, with WooCommerce, full names and addresses on every order. If you serve customers in the EU, the GDPR requires you to know where that data is stored and who can access it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Advantages of controlling the hosting location:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Known jurisdiction&lt;/strong&gt;: you choose the country the server runs in, instead of reading it from a provider's list of subprocessors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shorter processor chain&lt;/strong&gt;: fewer companies to cover with a data processing agreement under GDPR Article 28.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct access to logs&lt;/strong&gt;: web server and database logs stay under your control, and you can hand them over during an audit or a breach investigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit without permission&lt;/strong&gt;: a full copy of the database and &lt;code&gt;wp-content&lt;/code&gt; is always yours, whatever happens to a billing account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Checklist before you pick a host:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Region choice&lt;/strong&gt;: Kinsta and WP Engine let you choose a data centre region on their cloud providers. Check whether your WordPress.com plan gives you any say over location.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party calls&lt;/strong&gt;: Gravatar, remotely loaded Google Fonts and embedded analytics send visitor data elsewhere. A Munich court fined a site owner in 2022 for loading Google Fonts remotely, so host fonts locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email path&lt;/strong&gt;: order and form emails go through your SMTP provider, which becomes a processor too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup location&lt;/strong&gt;: an off-site bucket in another country moves the data just as much as the server does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention&lt;/strong&gt;: delete old form entries and inactive customer accounts on a schedule rather than keeping them forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Self-hosting does not make you compliant on its own. What it gives you is a shorter, clearer list of places your data goes, and a short list is easier to audit.&lt;/p&gt;




&lt;h2&gt;
  
  
  How hard is it to switch between self-hosted and managed WordPress later?
&lt;/h2&gt;

&lt;p&gt;It is easier than with most platforms, because WordPress is the same software everywhere. A site is a database plus a &lt;code&gt;wp-content&lt;/code&gt; folder, and a migration moves both. The difficulty comes from what each host adds on top of WordPress and from anything the site does while the move is in progress.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Migration tools&lt;/strong&gt;: Duplicator, All-in-One WP Migration and Migrate Guru package the site into one archive. WP Engine and Kinsta also offer their own migration tools or assisted moves, which lowers the cost of moving onto them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URLs and serialized data&lt;/strong&gt;: if the domain or path changes, use &lt;code&gt;wp search-replace&lt;/code&gt; rather than raw SQL. It handles serialized PHP arrays that a plain find-and-replace would corrupt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host-specific code&lt;/strong&gt;: managed hosts add their own must-use plugins and caching layers in &lt;code&gt;wp-content/mu-plugins&lt;/code&gt;. Remove them after moving out, or they will fail silently or conflict with your own cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS cutover&lt;/strong&gt;: lower the DNS record TTL to 300 seconds a day before the move, so visitors reach the new server within minutes instead of hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce orders&lt;/strong&gt;: orders placed on the old server after the export are lost. Schedule a short maintenance window or freeze checkout during the final sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email and integrations&lt;/strong&gt;: SPF and DKIM records, payment webhooks and API keys stored in &lt;code&gt;wp-config.php&lt;/code&gt; all need checking on the new host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a two-person startup, this means the first hosting decision is not permanent. A site with a few plugins can move in an afternoon. A store with years of orders and many extensions needs a staged rehearsal. The practical lesson for your three-year budget: avoid host-only features you cannot rebuild elsewhere, and leaving stays cheap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which option fits your startup: recommendations by profile
&lt;/h2&gt;

&lt;p&gt;Use your team's skills, your revenue risk and your expected growth to choose. The hosting price alone should not decide it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;th&gt;Main reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Developer founder, marketing site and blog&lt;/td&gt;
&lt;td&gt;Self-host on a VPS&lt;/td&gt;
&lt;td&gt;Low cash cost, and the upkeep uses skills the founder already has&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two non-technical founders, service business&lt;/td&gt;
&lt;td&gt;WordPress.com Business&lt;/td&gt;
&lt;td&gt;Fixed yearly cost, no server work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WooCommerce store with daily orders&lt;/td&gt;
&lt;td&gt;Kinsta or WP Engine&lt;/td&gt;
&lt;td&gt;Staging, backups and support are worth more than one outage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content site expecting viral spikes&lt;/td&gt;
&lt;td&gt;Self-host behind Cloudflare&lt;/td&gt;
&lt;td&gt;Traffic spikes do not trigger visit overages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU startup collecting customer data&lt;/td&gt;
&lt;td&gt;Self-host in a region you choose&lt;/td&gt;
&lt;td&gt;A shorter list of data processors, and logs you control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-product-market-fit team likely to pivot&lt;/td&gt;
&lt;td&gt;Self-host with free plugins only&lt;/td&gt;
&lt;td&gt;No yearly commitments to cancel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agency-style team running several client sites&lt;/td&gt;
&lt;td&gt;Managed multi-site plan&lt;/td&gt;
&lt;td&gt;One dashboard and support contract across every site&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Next steps:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you self-host:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy the &lt;code&gt;wordpress&lt;/code&gt; and &lt;code&gt;mariadb&lt;/code&gt; containers with named volumes.&lt;/li&gt;
&lt;li&gt;Set up off-site backups and test one restore before launch.&lt;/li&gt;
&lt;li&gt;Put a fixed monthly maintenance window in both founders' calendars.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you choose WordPress.com Business:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Price the three-year total using renewal rates, not the introductory price.&lt;/li&gt;
&lt;li&gt;Check that every plugin you need is allowed on the plan.&lt;/li&gt;
&lt;li&gt;Schedule a regular full export stored outside WordPress.com.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you choose Kinsta or WP Engine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Estimate year-three visits, including bots, and pick that tier.&lt;/li&gt;
&lt;li&gt;List every add-on and its monthly price.&lt;/li&gt;
&lt;li&gt;Keep your own off-site copy of the database and &lt;code&gt;wp-content&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>wordpress</category>
      <category>selfhosted</category>
      <category>startup</category>
      <category>hosting</category>
    </item>
    <item>
      <title>Netdata Alerts in Week One: Which to Keep, Tune or Silence, and How to Route Them to Slack, Discord and ntfy</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 15 Sep 2026 07:06:26 +0000</pubDate>
      <link>https://dev.to/john_182319291/netdata-alerts-in-week-one-which-to-keep-tune-or-silence-and-how-to-route-them-to-slack-discord-3000</link>
      <guid>https://dev.to/john_182319291/netdata-alerts-in-week-one-which-to-keep-tune-or-silence-and-how-to-route-them-to-slack-discord-3000</guid>
      <description>&lt;p&gt;Keep Netdata's stock alerts for disk space, memory pressure, OOM kills and failed systemd units exactly as they ship. In the first week, raise the thresholds or lengthen the delays on the CPU, network error and TCP reset alerts, and silence the ones for hardware or services you don't run. Send CRITICAL alerts to ntfy so they reach a phone, and send WARNING alerts to a Slack or Discord channel that someone reads once a day. That leaves a small team with a handful of real alerts a week, not dozens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solo on-call founder with two VPS nodes, e.g. a SaaS API plus its database box&lt;/strong&gt;: route CRITICAL to ntfy and WARNING to Discord, because one phone and one channel is all two people can realistically watch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team that already lives in Slack, e.g. a two-person agency using Slack with clients&lt;/strong&gt;: use a private Slack channel for WARNING and ntfy for CRITICAL, because paging through the same app you chat in gets muted within days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker-heavy single host, e.g. twelve containers on one mini PC&lt;/strong&gt;: tune the per-container cgroup CPU and memory alerts first, because they produce most of the first-week noise on busy hosts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parent-child streaming setup, e.g. one parent collecting from four children&lt;/strong&gt;: run health checks and notifications on the parent only, so one config decides what reaches you and each alert isn't sent several times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production database owner, e.g. a single PostgreSQL primary&lt;/strong&gt;: keep the connection, disk and replication alerts at stock or tighter, because those failures cost data, not just uptime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging or hobby node, e.g. a preview environment rebuilt weekly&lt;/strong&gt;: set &lt;code&gt;to: silent&lt;/code&gt; for most alerts on it and keep the dashboard, because alerts on a box you routinely destroy teach you to ignore alerts everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is simple: every alert you silence is an outage you might hear about from a customer, and every alert you keep but ignore trains you to miss the one that matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Why do Netdata's stock alerts fire so often in the first week?&lt;/li&gt;
&lt;li&gt;How Netdata's health configuration is structured on disk&lt;/li&gt;
&lt;li&gt;Which stock alerts should you keep exactly as shipped?&lt;/li&gt;
&lt;li&gt;Which alerts are worth tuning rather than deleting?&lt;/li&gt;
&lt;li&gt;Which alerts can a small team silence outright?&lt;/li&gt;
&lt;li&gt;How do you change thresholds, delays and hysteresis without losing them on upgrade?&lt;/li&gt;
&lt;li&gt;Anomaly detection versus fixed thresholds for a two-person rotation&lt;/li&gt;
&lt;li&gt;How do you send Netdata alerts to Slack, Discord and ntfy?&lt;/li&gt;
&lt;li&gt;Splitting pages from notices with roles and severities&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why do Netdata's stock alerts fire so often in the first week?
&lt;/h2&gt;

&lt;p&gt;Netdata ships with hundreds of alert definitions. They are written to be useful on any Linux machine, so they aren't tuned to yours. A new install turns on almost all of them at once, before you've seen what normal looks like on your hosts. Most first-week noise has a few predictable causes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auto-discovery attaches alerts to everything it finds&lt;/strong&gt;: every collector that detects a service, disk, interface or container brings its own alerts. You never explicitly asked for any of them, so it isn't obvious where a notification comes from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Templates multiply across instances&lt;/strong&gt;: one definition that watches network interfaces runs separately against &lt;code&gt;eth0&lt;/code&gt;, &lt;code&gt;docker0&lt;/code&gt; and every &lt;code&gt;veth&lt;/code&gt; pair Docker creates. A host with twenty containers can raise the same warning many times over for traffic that is perfectly normal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thresholds assume a generic server&lt;/strong&gt;: the stock &lt;code&gt;10min_cpu_usage&lt;/code&gt; alert warns when average CPU over ten minutes crosses 85% and goes critical above 95%. A small VPS that runs a nightly build or backup crosses that line on schedule, every night.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internet-facing noise looks like failure&lt;/strong&gt;: the TCP reset and dropped-packet alerts react to port scanners and bots hitting a public IP. On a box exposed to the internet, that is background activity, not a sign of trouble.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short-lived containers churn state&lt;/strong&gt;: containers that start, stop and get rebuilt during deploys make their per-container alerts appear, change state and get removed. Each transition can produce a notification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this means the defaults are wrong. It means the first week is when you find out which alerts match how your hosts actually behave, and that is exactly what the next few sections sort through.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Netdata's health configuration is structured on disk
&lt;/h2&gt;

&lt;p&gt;Before you change any alert, you need to know which files Netdata reads and which files a package upgrade overwrites. The layout differs slightly between install types. On a native package install, stock files live under &lt;code&gt;/usr/lib/netdata/conf.d/&lt;/code&gt; and your changes go under &lt;code&gt;/etc/netdata/&lt;/code&gt;. A static install from the kickstart script puts both trees under &lt;code&gt;/opt/netdata/&lt;/code&gt;. In the official Docker image, only &lt;code&gt;/etc/netdata&lt;/code&gt; is worth mounting as a volume.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stock alert definitions&lt;/strong&gt;: &lt;code&gt;/usr/lib/netdata/conf.d/health.d/&lt;/code&gt; holds one &lt;code&gt;.conf&lt;/code&gt; file per area, for example &lt;code&gt;cpu.conf&lt;/code&gt;, &lt;code&gt;ram.conf&lt;/code&gt;, &lt;code&gt;disks.conf&lt;/code&gt; and &lt;code&gt;tcp_resets.conf&lt;/code&gt;. Upgrades replace these files, so any edit you make there disappears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your overrides&lt;/strong&gt;: &lt;code&gt;/etc/netdata/health.d/&lt;/code&gt; is where your versions go. A file here with the same name as a stock file replaces that stock file completely. Netdata does not merge the two line by line, so copy the whole file before you edit it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The edit-config helper&lt;/strong&gt;: run &lt;code&gt;sudo ./edit-config health.d/cpu.conf&lt;/code&gt; from inside &lt;code&gt;/etc/netdata&lt;/code&gt;. It copies the stock file into place and opens it, so you never start from a blank file by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarm versus template&lt;/strong&gt;: each definition starts with either &lt;code&gt;alarm:&lt;/code&gt;, which is tied to one specific chart, or &lt;code&gt;template:&lt;/code&gt;, which applies to every chart of a context such as every disk or every container. Most stock noise comes from templates, because a single definition runs against every instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notification settings&lt;/strong&gt;: &lt;code&gt;/etc/netdata/health_alarm_notify.conf&lt;/code&gt; holds webhooks, recipients and roles, apart from the alert logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The global switch&lt;/strong&gt;: the &lt;code&gt;[health]&lt;/code&gt; section of &lt;code&gt;netdata.conf&lt;/code&gt; turns the health engine on or off for the whole node.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After editing, run &lt;code&gt;netdatacli reload-health&lt;/code&gt; to apply the change without restarting the agent.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which stock alerts should you keep exactly as shipped?
&lt;/h2&gt;

&lt;p&gt;Keep an alert as shipped when the condition it catches ends in data loss, a crash, or a service that stays down until someone steps in. A small team can't afford to learn about these from a customer, and the stock thresholds already leave room before real damage. You can check the exact names on your nodes in the dashboard's Alerts tab or with &lt;code&gt;curl localhost:19999/api/v1/alarms?all&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;disk_space_usage&lt;/code&gt; and &lt;code&gt;disk_inode_usage&lt;/code&gt;&lt;/strong&gt;: a full filesystem breaks databases, log writers and Docker image pulls all at once. Running out of inodes produces the same "no space left" errors while &lt;code&gt;df -h&lt;/code&gt; still shows free space, which is exactly why a separate alert for it is worth having.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;oom_kill&lt;/code&gt;&lt;/strong&gt;: this fires when the kernel has already killed a process to free memory. It is not a prediction, so every notification means something on the host just died.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ram_available&lt;/code&gt;&lt;/strong&gt;: this looks at memory the system can actually reclaim, not raw usage. That makes it a better early warning than &lt;code&gt;ram_in_use&lt;/code&gt; on hosts where the page cache fills RAM by design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;systemd_service_unit_failed_state&lt;/code&gt;&lt;/strong&gt;: a unit that has crashed and hit its restart limit won't recover on its own. On a host without containers, this is often the only sign a background worker has stopped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker_container_unhealthy&lt;/code&gt;&lt;/strong&gt;: this goes off only for containers you gave a &lt;code&gt;HEALTHCHECK&lt;/code&gt;, so it is quiet by default and precise when it does fire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;postgres_total_connection_utilization&lt;/code&gt;&lt;/strong&gt;: running out of connections looks like an application outage even though the database itself is healthy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one of these turns out noisy, look at the host before you blame the threshold. On a disk that sits at 88% for a week, the fix is cleanup or more storage, not a quieter alert.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which alerts are worth tuning rather than deleting?
&lt;/h2&gt;

&lt;p&gt;Tune an alert when the condition it tracks does matter, but the stock threshold or time window doesn't fit how your hosts behave. Delete an alert only when the condition never matters to you. The difference is simple: a CPU pegged at 100% for two hours is a real problem, while a CPU at 90% for twelve minutes during a nightly build is not. The fix is to change when the alert fires, not whether it exists.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Alert&lt;/th&gt;
&lt;th&gt;Why it is noisy at stock settings&lt;/th&gt;
&lt;th&gt;What to change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;10min_cpu_usage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scheduled builds, backups and image pulls push the average above the line every day&lt;/td&gt;
&lt;td&gt;Widen the &lt;code&gt;lookup&lt;/code&gt; window to 30 minutes, or add &lt;code&gt;delay: up 15m&lt;/code&gt; so short spikes never notify&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cgroup_10min_cpu_usage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fires for each container, so one busy deploy produces several warnings&lt;/td&gt;
&lt;td&gt;Keep CRITICAL, and match only the containers that serve users with a &lt;code&gt;chart labels&lt;/code&gt; filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cgroup_ram_in_use&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Containers with tight memory limits sit near them by design, especially JVM and Node services&lt;/td&gt;
&lt;td&gt;Raise the WARNING level for those services, and keep &lt;code&gt;oom_kill&lt;/code&gt; as the hard signal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;10min_disk_backlog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Backup and &lt;code&gt;docker system prune&lt;/code&gt; runs queue I/O for minutes at a time&lt;/td&gt;
&lt;td&gt;Lengthen the up delay so it fires only when the backlog lasts longer than your longest backup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1m_ipv4_tcp_resets_sent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Port scans against a public IP send resets all day&lt;/td&gt;
&lt;td&gt;Keep it as a low-priority notice, never a page, and raise the threshold once you know the baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inbound_packets_dropped_ratio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Docker &lt;code&gt;veth&lt;/code&gt; interfaces drop packets during container churn&lt;/td&gt;
&lt;td&gt;Limit it to physical interfaces with a &lt;code&gt;chart labels&lt;/code&gt; match on the device name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before you change anything, write down the value each alert showed when it fired during the week. Set the new threshold from those notes, not from a guess.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which alerts can a small team silence outright?
&lt;/h2&gt;

&lt;p&gt;Silence an alert when no value it could reach would make you change anything. There are two ways to do it, and the difference matters. Setting &lt;code&gt;to: silent&lt;/code&gt; in the definition keeps the alert running, so its state still shows on the dashboard, but nothing gets sent. Disabling it by name in the &lt;code&gt;[health]&lt;/code&gt; section of &lt;code&gt;netdata.conf&lt;/code&gt; stops it from being evaluated at all. For a small team, &lt;code&gt;to: silent&lt;/code&gt; is usually the better choice, because you can still look back at the history after an incident.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;1m_received_traffic_overflow&lt;/code&gt; and &lt;code&gt;1m_sent_traffic_overflow&lt;/code&gt;&lt;/strong&gt;: these compare traffic to the interface's link speed. Virtual NICs on cloud VPS plans often report a speed that has nothing to do with your real bandwidth cap, so the percentage is meaningless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;load_average_1&lt;/code&gt;&lt;/strong&gt;: one-minute load on a small VPS jumps with every cron job. &lt;code&gt;10min_cpu_usage&lt;/code&gt;, once tuned, already covers sustained saturation, so this adds noise without adding a signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;system_clock_sync_state&lt;/code&gt;&lt;/strong&gt;: on some virtual machines and inside certain container setups, the host or hypervisor manages time. The agent can report "unsynchronised" indefinitely while the clock is actually correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;used_swap&lt;/code&gt;&lt;/strong&gt;: if you set up swap or zram on purpose so a 2 GB box can absorb spikes, using swap is the plan, not a failure. &lt;code&gt;oom_kill&lt;/code&gt; still catches the case where that plan runs out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web log redirect and bad-request ratios&lt;/strong&gt;: on a public site, crawlers and vulnerability scanners generate 3xx and 4xx responses constantly. Your application's error tracking is a better place to catch real client errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every alert on disposable staging nodes&lt;/strong&gt;: match them with a host label and silence the lot, keeping only &lt;code&gt;disk_space_usage&lt;/code&gt;, so a full disk doesn't block the next deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep a short comment above each change explaining why you silenced it. Six months from now, that line is the only record anyone will have.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you change thresholds, delays and hysteresis without losing them on upgrade?
&lt;/h2&gt;

&lt;p&gt;Copying a whole stock file into &lt;code&gt;/etc/netdata/health.d/&lt;/code&gt; works, but it freezes that file in time. When a later release fixes a stock definition in &lt;code&gt;cpu.conf&lt;/code&gt;, your copy keeps the old version forever. A cleaner pattern is to leave stock files untouched, write your own definitions under new names, and turn off only the stock alerts you replaced.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Put overrides in one file&lt;/strong&gt;: create something like &lt;code&gt;/etc/netdata/health.d/local-overrides.conf&lt;/code&gt; and give each definition a new name, for example &lt;code&gt;team_10min_cpu_usage&lt;/code&gt;. The name never matches a stock file, so upgrades can't shadow it or be shadowed by it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable only the originals&lt;/strong&gt;: in &lt;code&gt;netdata.conf&lt;/code&gt;, set &lt;code&gt;enabled alarms = !10min_cpu_usage !cgroup_ram_in_use *&lt;/code&gt; under &lt;code&gt;[health]&lt;/code&gt;. The trailing &lt;code&gt;*&lt;/code&gt; keeps every other stock alert active and upgradeable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widen the window with &lt;code&gt;lookup&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;lookup: average -30m unaligned of user,system&lt;/code&gt; averages user and system CPU over 30 minutes, so a ten-minute spike can't push it over the line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add hysteresis inside &lt;code&gt;warn&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;warn: $this &amp;gt; (($status &amp;gt;= $WARNING) ? (75) : (90))&lt;/code&gt; raises at 90% but clears only below 75%. That stops an alert flapping around a single value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hold notifications with &lt;code&gt;delay&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;delay: up 10m down 15m multiplier 1.5 max 1h&lt;/code&gt; waits ten minutes before telling you, waits fifteen before sending the all-clear, and stretches both delays when an alert keeps flipping, up to one hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version the directory&lt;/strong&gt;: keep &lt;code&gt;/etc/netdata&lt;/code&gt; in a git repository. After each upgrade, &lt;code&gt;diff&lt;/code&gt; the stock definitions you replaced against your own versions, so upstream fixes don't pass unnoticed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check a change by watching the renamed alert appear in &lt;code&gt;api/v1/alarms?all&lt;/code&gt; before you trust it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anomaly detection versus fixed thresholds for a two-person rotation
&lt;/h2&gt;

&lt;p&gt;Netdata trains small machine learning models for each metric, right on the agent, and marks every one-second sample as anomalous or normal. The result shows up as an anomaly rate. You can write an alert on it with a &lt;code&gt;lookup&lt;/code&gt; that uses the &lt;code&gt;anomaly-bit&lt;/code&gt; option, for example the average anomaly rate across a chart over the last 10 minutes. The question is whether that signal should ever wake someone up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Fixed thresholds&lt;/th&gt;
&lt;th&gt;Anomaly rate alerts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What triggers it&lt;/td&gt;
&lt;td&gt;A value crosses a number you picked, such as disk above 90%&lt;/td&gt;
&lt;td&gt;A metric behaves unlike its own recent history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First-week behaviour&lt;/td&gt;
&lt;td&gt;Noisy until tuned, but the noise is predictable&lt;/td&gt;
&lt;td&gt;Unreliable while models are still training on only a few days of data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catches well&lt;/td&gt;
&lt;td&gt;Hard limits: full disks, exhausted connections, OOM kills&lt;/td&gt;
&lt;td&gt;Unusual patterns with no obvious limit, such as a quiet API suddenly doing steady writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blind spot&lt;/td&gt;
&lt;td&gt;Anything you didn't think to set a threshold for&lt;/td&gt;
&lt;td&gt;Slow drift, because a disk filling 1% a day just becomes the new normal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readable at 3 a.m.&lt;/td&gt;
&lt;td&gt;Yes: "disk at 94%" tells you what to do&lt;/td&gt;
&lt;td&gt;Rarely: "anomaly rate 40% on 12 charts" tells you to go and look&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource cost&lt;/td&gt;
&lt;td&gt;Negligible&lt;/td&gt;
&lt;td&gt;Extra CPU on every node, which is why many parent-child setups turn off &lt;code&gt;[ml]&lt;/code&gt; on children and let the parent train&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For two people sharing on-call, page only on fixed thresholds. Send anomaly rate alerts, if you enable them, to the daily notice channel. Use them as a prompt to open the dashboard, not as proof something is broken. If an anomaly alert keeps pointing at a real problem, turn that into a fixed-threshold alert you can act on.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you send Netdata alerts to Slack, Discord and ntfy?
&lt;/h2&gt;

&lt;p&gt;The agent sends notifications through &lt;code&gt;alarm-notify.sh&lt;/code&gt;, and every setting it reads lives in &lt;code&gt;health_alarm_notify.conf&lt;/code&gt;. Open it with &lt;code&gt;sudo ./edit-config health_alarm_notify.conf&lt;/code&gt; from &lt;code&gt;/etc/netdata&lt;/code&gt;. It is a long file, but each service needs only a switch, a destination and a default recipient.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slack&lt;/strong&gt;: create an incoming webhook for your workspace, then set &lt;code&gt;SEND_SLACK="YES"&lt;/code&gt;, paste the URL into &lt;code&gt;SLACK_WEBHOOK_URL&lt;/code&gt;, and set &lt;code&gt;DEFAULT_RECIPIENT_SLACK&lt;/code&gt; to a channel such as &lt;code&gt;#ops-alerts&lt;/code&gt;. Each Slack webhook is tied to a single channel, so plan on one webhook for each channel you want to post to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discord&lt;/strong&gt;: in the channel settings, create a webhook under Integrations. Set &lt;code&gt;SEND_DISCORD="YES"&lt;/code&gt;, put the URL in &lt;code&gt;DISCORD_WEBHOOK_URL&lt;/code&gt;, and set &lt;code&gt;DEFAULT_RECIPIENT_DISCORD&lt;/code&gt; to the channel name. Discord is often the free option for a small team that doesn't pay for Slack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ntfy&lt;/strong&gt;: set &lt;code&gt;SEND_NTFY="YES"&lt;/code&gt; and put the full topic URL in &lt;code&gt;DEFAULT_RECIPIENT_NTFY&lt;/code&gt;, for example &lt;code&gt;https://ntfy.sh/&lt;/code&gt; followed by your topic. On the public ntfy.sh server, anyone who knows a topic name can subscribe to it, so choose a long random name or run your own ntfy server and set &lt;code&gt;NTFY_ACCESS_TOKEN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email as a fallback&lt;/strong&gt;: &lt;code&gt;SEND_EMAIL&lt;/code&gt; depends on a working &lt;code&gt;sendmail&lt;/code&gt; on the host. Most small VPS setups don't have one configured, so turn it off rather than letting messages fail without anyone noticing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test before trusting&lt;/strong&gt;: switch to the &lt;code&gt;netdata&lt;/code&gt; user with &lt;code&gt;sudo su -s /bin/bash netdata&lt;/code&gt;, then run &lt;code&gt;/usr/libexec/netdata/plugins.d/alarm-notify.sh test&lt;/code&gt;. It sends a WARNING, a CRITICAL and a CLEAR to every destination you enabled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the test works from a terminal but real alerts never arrive, check that the &lt;code&gt;netdata&lt;/code&gt; user can reach the internet through your firewall.&lt;/p&gt;




&lt;h2&gt;
  
  
  Splitting pages from notices with roles and severities
&lt;/h2&gt;

&lt;p&gt;Every alert definition has a &lt;code&gt;to:&lt;/code&gt; line naming a role, and most stock alerts use &lt;code&gt;to: sysadmin&lt;/code&gt;. In &lt;code&gt;health_alarm_notify.conf&lt;/code&gt;, each role maps to recipients for each service. Put those two ideas together and you get a two-tier setup: a short list of alerts that can wake a person up, and everything else collected for a daily look.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Role mappings&lt;/strong&gt;: &lt;code&gt;role_recipients_slack[sysadmin]="#ops-alerts"&lt;/code&gt; sends every sysadmin alert to that channel. Roles you don't map fall back to the &lt;code&gt;DEFAULT_RECIPIENT_&lt;/code&gt; value for each service, so a role you forgot to map still reaches someone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;|critical&lt;/code&gt; modifier&lt;/strong&gt;: add it to a recipient, as in &lt;code&gt;role_recipients_ntfy[sysadmin]="https://ntfy.sh/&amp;lt;topic&amp;gt;|critical"&lt;/code&gt;, and that destination receives only CRITICAL alerts and the CLEAR that follows. WARNING alerts still reach Slack or Discord, but they never buzz a phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A dedicated pager role&lt;/strong&gt;: in your override file, set &lt;code&gt;to: pager&lt;/code&gt; on the handful of alerts that justify waking someone, such as &lt;code&gt;disk_space_usage&lt;/code&gt;, &lt;code&gt;oom_kill&lt;/code&gt; and database connection exhaustion. Map &lt;code&gt;pager&lt;/code&gt; to ntfy only, and a threshold edit elsewhere can never quietly add to the page list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a written record in chat&lt;/strong&gt;: map &lt;code&gt;pager&lt;/code&gt; to Slack or Discord as well as ntfy. The phone push gets attention, and the channel keeps a timestamped history both of you can scroll back through the next morning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-person routing&lt;/strong&gt;: with two people, give each their own ntfy topic and alternate which one sits in &lt;code&gt;role_recipients_ntfy[pager]&lt;/code&gt; week by week. That is a simple on-call rotation without a paging SaaS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database and web roles&lt;/strong&gt;: stock database alerts use roles like &lt;code&gt;dba&lt;/code&gt;. Map them explicitly, or they fall through to your defaults and page you with the wrong priority.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run &lt;code&gt;alarm-notify.sh test "pager"&lt;/code&gt; after every mapping change.&lt;/p&gt;

</description>
      <category>netdata</category>
      <category>alerting</category>
      <category>sre</category>
      <category>devops</category>
    </item>
    <item>
      <title>rclone mount vs rclone bisync after Dropbox: the real latency, RAM and conflict numbers</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 12 Sep 2026 07:06:51 +0000</pubDate>
      <link>https://dev.to/john_182319291/rclone-mount-vs-rclone-bisync-after-dropbox-the-real-latency-ram-and-conflict-numbers-1jlk</link>
      <guid>https://dev.to/john_182319291/rclone-mount-vs-rclone-bisync-after-dropbox-the-real-latency-ram-and-conflict-numbers-1jlk</guid>
      <description>&lt;p&gt;Neither one alone reproduces the Dropbox desktop client, and the honest answer is that you run both. An &lt;code&gt;rclone mount&lt;/code&gt; with &lt;code&gt;--vfs-cache-mode full&lt;/code&gt; gives you a folder that looks complete in your file manager, but every first read of an uncached file costs a network round trip, and nothing is available offline until you have opened it. &lt;code&gt;rclone bisync&lt;/code&gt; gives you genuine local copies with local disk latency and true offline access, at the cost of a scheduled run, a mandatory first &lt;code&gt;--resync&lt;/code&gt;, and conflict files you have to resolve yourself. Split your data: bisync the working set you edit daily, mount the archive you only read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by reader profile&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solo dev with one laptop and a home server, 40 GB of code and notes:&lt;/strong&gt; run &lt;code&gt;bisync&lt;/code&gt; on a 5 minute timer for the working set, because local disk latency and offline access matter more than seeing every remote change instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photographer or media hoarder with 2 TB on object storage:&lt;/strong&gt; run a single &lt;code&gt;mount&lt;/code&gt; with &lt;code&gt;--vfs-cache-mode full&lt;/code&gt; and a capped cache, because copying 2 TB to every device defeats the point of moving off the hosted plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer who builds inside the synced folder:&lt;/strong&gt; never point a compiler, a &lt;code&gt;node_modules&lt;/code&gt; tree or a Git repository at a mount, because per file stat and open calls turn a 20 second build into minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Someone with two laptops and a phone editing the same files:&lt;/strong&gt; bisync each laptop against the remote and expose the server folder over &lt;code&gt;rclone serve webdav&lt;/code&gt;, because bisync is two way between exactly two ends, not a mesh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone on a metered or flaky connection:&lt;/strong&gt; choose bisync with &lt;code&gt;--check-access&lt;/code&gt; and a low &lt;code&gt;--transfers&lt;/code&gt; value, because a mount reacts to a dropped link by returning I/O errors mid save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team of one who wants the sharing links back:&lt;/strong&gt; accept that neither tool replaces them, and plan a second front end for public links rather than stretching rclone into the job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is simple: a mount buys you unlimited apparent capacity and pays for it in latency and fragility, while bisync buys you local speed and offline safety and pays for it in disk space, scheduling and conflict resolution.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What does an rclone mount actually replace when the Dropbox client is gone?&lt;/li&gt;
&lt;li&gt;How does rclone bisync differ from a mount in day to day use?&lt;/li&gt;
&lt;li&gt;Which VFS cache mode should you run: off, minimal, writes or full?&lt;/li&gt;
&lt;li&gt;How much RAM and disk does an rclone mount really consume?&lt;/li&gt;
&lt;li&gt;What latency do open, stat and write calls show against a local disk baseline?&lt;/li&gt;
&lt;li&gt;How fast do remote changes appear, and what do dir-cache-time and poll-interval control?&lt;/li&gt;
&lt;li&gt;How do you size the VFS cache without filling the disk?&lt;/li&gt;
&lt;li&gt;Conflicts, renames and deletes: what bisync does and what the first resync costs&lt;/li&gt;
&lt;li&gt;What breaks when the network drops or the provider rate limits you?&lt;/li&gt;
&lt;li&gt;Running the mount under systemd and recovering it after a reboot&lt;/li&gt;
&lt;li&gt;Where should the mount run: home server, NAS, VPS or managed Personal Cloud Server?&lt;/li&gt;
&lt;li&gt;How do you reach the same folder from a phone or a second laptop?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What does an rclone mount actually replace when the Dropbox client is gone?
&lt;/h2&gt;

&lt;p&gt;A mount replaces the appearance of the Dropbox folder, not its behaviour. &lt;code&gt;rclone mount remote:files ~/Cloud --vfs-cache-mode full&lt;/code&gt; presents a FUSE filesystem where &lt;code&gt;ls&lt;/code&gt; returns the full listing within milliseconds once the directory cache is warm, so your file manager, your editor's open dialog and &lt;code&gt;find&lt;/code&gt; all behave normally. What changes is what happens underneath each file you touch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Directory listings become remote metadata calls:&lt;/strong&gt; the first &lt;code&gt;ls&lt;/code&gt; on a cold directory hits the provider API, and rclone holds that listing for the &lt;code&gt;--dir-cache-time&lt;/code&gt; window, which defaults to 5 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File contents arrive on demand:&lt;/strong&gt; opening a 400 MB video streams it over the network rather than reading it from disk, and with &lt;code&gt;--vfs-cache-mode full&lt;/code&gt; the bytes land in the cache directory only after that first read completes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writes go through a local staging file:&lt;/strong&gt; in &lt;code&gt;full&lt;/code&gt; or &lt;code&gt;writes&lt;/code&gt; mode your application writes to the cache, the file closes, then rclone uploads it, so a save that returns instantly can still be in flight seconds later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline access disappears by default:&lt;/strong&gt; the Dropbox client kept every non selective file on disk, while a mount shows you names you cannot open when the link is down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selective sync has no equivalent:&lt;/strong&gt; you approximate it with &lt;code&gt;--exclude&lt;/code&gt; filters or a second mount, not with a checkbox.&lt;/p&gt;

&lt;p&gt;The practical consequence is that a mount is excellent for an archive you read occasionally and poor for a directory you compile in. Treat it as a network drive with a good cache, which is what it is.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does rclone bisync differ from a mount in day to day use?
&lt;/h2&gt;

&lt;p&gt;Bisync is a scheduled job, not a filesystem. You run &lt;code&gt;rclone bisync ~/Cloud remote:files&lt;/code&gt; from cron or a systemd timer, it compares both sides against a stored listing pair in &lt;code&gt;~/.cache/rclone/bisync/&lt;/code&gt;, and it copies the differences in both directions. Between runs, your local directory is an ordinary folder on an ordinary disk.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;th&gt;rclone mount&lt;/th&gt;
&lt;th&gt;rclone bisync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where files live&lt;/td&gt;
&lt;td&gt;Cache directory, populated on first read&lt;/td&gt;
&lt;td&gt;Full local copy of everything in scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offline access&lt;/td&gt;
&lt;td&gt;Only what is already cached&lt;/td&gt;
&lt;td&gt;Every file, always readable and writable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change propagation&lt;/td&gt;
&lt;td&gt;Within the &lt;code&gt;--dir-cache-time&lt;/code&gt; window, 5 minutes by default&lt;/td&gt;
&lt;td&gt;Only when the next scheduled run fires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk required&lt;/td&gt;
&lt;td&gt;Whatever you cap the cache at&lt;/td&gt;
&lt;td&gt;At least the size of the synced tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;I/O errors reaching the application mid operation&lt;/td&gt;
&lt;td&gt;A run exits non zero and leaves both sides untouched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conflicting edits&lt;/td&gt;
&lt;td&gt;Last writer wins silently&lt;/td&gt;
&lt;td&gt;Conflict files written to both sides&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The scheduling interval is the knob that defines the experience. A 5 minute timer means a file you save on the server appears on the laptop within 5 minutes and a deletion propagates on the same clock. A 60 minute timer halves your API calls and doubles the window in which the two sides can diverge.&lt;/p&gt;

&lt;p&gt;The other practical difference is scope. A mount covers a whole remote cheaply because it stores almost nothing. Bisync covers only what you are prepared to store twice, which is why the working set versus archive split is the decision that shapes everything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which VFS cache mode should you run: off, minimal, writes or full?
&lt;/h2&gt;

&lt;p&gt;There are four modes and only two of them are realistic for a folder you actually work in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--vfs-cache-mode off&lt;/code&gt;:&lt;/strong&gt; files are read and written straight through, and any application that opens a file for both reading and writing fails, which rules out most editors, SQLite databases and Office formats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--vfs-cache-mode minimal&lt;/code&gt;:&lt;/strong&gt; files opened read write are staged on disk, everything else streams, so &lt;code&gt;vim&lt;/code&gt; saves work but a seek backwards in a large file still costs a fresh range request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--vfs-cache-mode writes&lt;/code&gt;:&lt;/strong&gt; all writes go to the cache first and uploads happen on close, while reads stream from the remote every time, which suits a machine with a small disk and an append heavy workload such as a log or backup target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--vfs-cache-mode full&lt;/code&gt;:&lt;/strong&gt; reads and writes both use the cache, sparse chunks are kept, and a second open of the same file costs local disk latency instead of a round trip, which is the only mode that feels remotely like the old desktop client.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;full&lt;/code&gt; unless the disk under the mount cannot spare the space. That disk is the real constraint, so decide where the mount lives before you tune the flags: a home server with a spare SSD, a NAS, a self managed VPS with a small volume, or a managed host. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. Whichever you pick, budget cache space on the same box that runs &lt;code&gt;rclone mount&lt;/code&gt;, because the cache is local by definition and cannot be pushed onto the remote.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much RAM and disk does an rclone mount really consume?
&lt;/h2&gt;

&lt;p&gt;Memory use is not a single number, it is a formula with three terms you control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A base process footprint:&lt;/strong&gt; a single mount process with nothing open is small enough that it is not the term you should worry about, and you can confirm yours with &lt;code&gt;ps -o rss= -C rclone&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read buffers, one per open file:&lt;/strong&gt; &lt;code&gt;--buffer-size&lt;/code&gt; defaults to 16 MiB and is allocated per file handle in use, so ten files open simultaneously is on the order of 160 MiB of buffers before anything else. Set &lt;code&gt;--buffer-size 0&lt;/code&gt; on a small box and accept more round trips.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cached directory metadata:&lt;/strong&gt; rclone keeps listings in memory for the &lt;code&gt;--dir-cache-time&lt;/code&gt; window, and the cost scales with the number of objects you have walked, not their size, so a &lt;code&gt;find&lt;/code&gt; across 500,000 files is what turns a quiet mount into a hungry one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upload concurrency:&lt;/strong&gt; &lt;code&gt;--transfers&lt;/code&gt; defaults to 4, and each in flight upload of a large file holds chunk buffers whose size depends on the backend, which is why a bulk copy into the mount is the peak you should size for.&lt;/p&gt;

&lt;p&gt;Disk is simpler and more dangerous. With &lt;code&gt;--vfs-cache-mode full&lt;/code&gt; the cache directory, by default under &lt;code&gt;~/.cache/rclone/vfs/&lt;/code&gt;, grows to hold every file you have read or written until an eviction rule removes it. Read one 40 GB video and you have written 40 GB locally. Two limits matter: &lt;code&gt;--vfs-cache-max-size&lt;/code&gt; caps total bytes, and &lt;code&gt;--vfs-cache-max-age&lt;/code&gt; defaults to 1 hour and expires idle entries. Leave both unset and the cache is bounded only by the filesystem it sits on.&lt;/p&gt;




&lt;h2&gt;
  
  
  What latency do open, stat and write calls show against a local disk baseline?
&lt;/h2&gt;

&lt;p&gt;Measure your own numbers rather than trusting anyone's table, because the dominant term is the round trip time between your server and the provider region. Get the baseline first with &lt;code&gt;ping&lt;/code&gt; to the endpoint host, then run &lt;code&gt;strace -c -f ls -l&lt;/code&gt; in a cold directory on the mount and the same command in a bisync folder. The difference you see is the entire argument of this article.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold &lt;code&gt;stat&lt;/code&gt; on a mount:&lt;/strong&gt; costs at least one round trip if the parent directory is not cached, which is why a single &lt;code&gt;ls -l&lt;/code&gt; on 2,000 files can take seconds while the same command on local disk returns in milliseconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warm &lt;code&gt;stat&lt;/code&gt; on a mount:&lt;/strong&gt; served from kernel attribute cache and rclone memory, where &lt;code&gt;--attr-timeout&lt;/code&gt; defaults to 1s and controls how long the kernel trusts what it was told.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First &lt;code&gt;open&lt;/code&gt; and read:&lt;/strong&gt; one round trip plus transfer time for the requested range, so latency scales with file size on a streaming read and with RTT on a small file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second &lt;code&gt;open&lt;/code&gt; in &lt;code&gt;full&lt;/code&gt; mode:&lt;/strong&gt; local disk latency, because the bytes are already in the cache directory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;write&lt;/code&gt; followed by &lt;code&gt;close&lt;/code&gt;:&lt;/strong&gt; the write returns at local speed, then &lt;code&gt;close&lt;/code&gt; blocks until the upload starts, so a save of a 2 MB file feels instant and a save of a 2 GB file does not.&lt;/p&gt;

&lt;p&gt;Small files are where mounts lose decisively. A tree of 10,000 source files under 4 KB each turns into 10,000 metadata operations, and no cache mode fixes the first pass. A bisync folder pays that cost once per run, in the background, with &lt;code&gt;--checkers&lt;/code&gt; running them in parallel.&lt;/p&gt;




&lt;h2&gt;
  
  
  How fast do remote changes appear, and what do dir-cache-time and poll-interval control?
&lt;/h2&gt;

&lt;p&gt;Two independent timers decide when a file written elsewhere shows up in your mount, and confusing them is the most common reason people think the mount is broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--dir-cache-time&lt;/code&gt;, default 5m0s:&lt;/strong&gt; the maximum age of a cached directory listing. When it expires, the next access re-lists that directory from the remote. Raise it to 1000h and you cut metadata calls to almost nothing, at the price of never noticing outside changes on your own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--poll-interval&lt;/code&gt;, default 1m0s:&lt;/strong&gt; how often rclone asks the backend for a change feed and invalidates only the affected directories. This is the mechanism that makes a long &lt;code&gt;--dir-cache-time&lt;/code&gt; safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend support is the catch:&lt;/strong&gt; polling depends on the backend implementing change notification. Google Drive, Dropbox and OneDrive do. Plain S3, Backblaze B2 and SFTP do not, and on those the only thing that refreshes a listing is &lt;code&gt;--dir-cache-time&lt;/code&gt; expiry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual invalidation as the escape hatch:&lt;/strong&gt; start the mount with &lt;code&gt;--rc&lt;/code&gt; and run &lt;code&gt;rclone rc vfs/refresh recursive=true&lt;/code&gt; or &lt;code&gt;rclone rc vfs/forget dir=path/to/folder&lt;/code&gt; to force a re-list immediately.&lt;/p&gt;

&lt;p&gt;The practical recipe for a polling capable backend is &lt;code&gt;--dir-cache-time 1000h --poll-interval 15s&lt;/code&gt;, which gives you near instant propagation and almost no idle API traffic. On a non polling backend such as B2, keep &lt;code&gt;--dir-cache-time&lt;/code&gt; between 1m and 5m if you need freshness, and accept the listing calls, or leave it long and drive refreshes from whatever process writes the files.&lt;/p&gt;

&lt;p&gt;None of this applies to bisync, where propagation is exactly your timer interval and nothing else.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you size the VFS cache without filling the disk?
&lt;/h2&gt;

&lt;p&gt;Start from the largest single file you will ever write, not from your total data. The cache must hold a complete copy of any file being written, so &lt;code&gt;--vfs-cache-max-size&lt;/code&gt; is a target for eviction, not a hard ceiling: an in use or not yet uploaded file is never evicted, and the directory can exceed the cap while an upload is in flight.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Settings to start with&lt;/th&gt;
&lt;th&gt;What you give up&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Archive you mostly read, 500 GB SSD&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--vfs-cache-max-size 100G --vfs-cache-max-age 720h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100 GB of disk permanently committed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small VPS with a 40 GB volume&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--vfs-cache-max-size 8G --vfs-cache-max-age 24h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Repeat reads of large files go back to the network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write heavy target, backups or logs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--vfs-cache-mode writes --vfs-cache-max-age 1h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every read is a fresh download&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laptop with limited space&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--vfs-cache-max-size 4G --vfs-cache-poll-interval 30s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;More frequent eviction churn on the disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large file editing, video or disk images&lt;/td&gt;
&lt;td&gt;Cache at least 2x the biggest file&lt;/td&gt;
&lt;td&gt;The cap is advisory during writes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eviction is least recently used and runs on &lt;code&gt;--vfs-cache-poll-interval&lt;/code&gt;, which defaults to 1m0s. &lt;code&gt;--vfs-cache-max-age&lt;/code&gt; defaults to 1h0m0s, so out of the box a file you read this morning is gone by lunchtime. Put the cache somewhere you control with &lt;code&gt;--cache-dir /var/cache/rclone&lt;/code&gt; rather than leaving it under &lt;code&gt;~/.cache&lt;/code&gt;, and alert on that path at 85 percent full. Also watch &lt;code&gt;--vfs-write-back&lt;/code&gt;, default 5s, which is how long a closed file waits before upload starts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conflicts, renames and deletes: what bisync does and what the first resync costs
&lt;/h2&gt;

&lt;p&gt;The first run is not optional and it is not symmetric. &lt;code&gt;rclone bisync ~/Cloud remote:files --resync&lt;/code&gt; establishes the baseline listings that every later run compares against, and by default path1 wins for files that differ on both sides. Run it on a copy first, or with &lt;code&gt;--dry-run&lt;/code&gt;, because a careless &lt;code&gt;--resync&lt;/code&gt; is the one command in this article that can overwrite good data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conflicts are renamed, not merged:&lt;/strong&gt; when both sides changed since the last run, the default &lt;code&gt;--conflict-resolve none&lt;/code&gt; keeps both and appends &lt;code&gt;..path1&lt;/code&gt; and &lt;code&gt;..path2&lt;/code&gt; to the filenames, leaving you to pick. Set &lt;code&gt;--conflict-resolve newer&lt;/code&gt; if you would rather it decide, and &lt;code&gt;--conflict-loser delete&lt;/code&gt; if you do not want the loser kept.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletes propagate, with a brake:&lt;/strong&gt; &lt;code&gt;--max-delete&lt;/code&gt; defaults to 50, meaning a run aborts if more than 50 percent of files on either side would be deleted, which is what saves you when a drive fails to mount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Renames cost a full transfer:&lt;/strong&gt; a renamed directory is seen as deletions plus new files, so moving a 20 GB folder re-uploads 20 GB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed run leaves a marker:&lt;/strong&gt; bisync writes its listings under &lt;code&gt;~/.cache/rclone/bisync/&lt;/code&gt; and refuses to continue after an abort until you either fix the cause or re-run with &lt;code&gt;--resync&lt;/code&gt;, which is deliberate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access checks catch empty mounts:&lt;/strong&gt; &lt;code&gt;--check-access&lt;/code&gt; requires an &lt;code&gt;RCLONE_TEST&lt;/code&gt; file present on both sides and aborts if one is missing.&lt;/p&gt;

&lt;p&gt;Budget the resync as a one time full comparison of both trees. On 40 GB it is minutes. On 2 TB it is the reason you chose a mount instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  What breaks when the network drops or the provider rate limits you?
&lt;/h2&gt;

&lt;p&gt;A mount fails loudly inside your applications. Bisync fails quietly in a log file. That difference matters more than any throughput number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mount returns I/O errors to whatever is reading:&lt;/strong&gt; a dropped link mid read surfaces as EIO, and an editor that was saving may leave a partial file. If the FUSE process itself dies you get "transport endpoint is not connected" until you run &lt;code&gt;fusermount -uz ~/Cloud&lt;/code&gt; and remount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pending uploads survive in the cache:&lt;/strong&gt; with &lt;code&gt;--vfs-cache-mode full&lt;/code&gt;, files closed but not yet uploaded stay in the cache directory and retry, so a reboot before the upload completes is the real risk, not a brief outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry behaviour is tunable:&lt;/strong&gt; &lt;code&gt;--low-level-retries&lt;/code&gt; defaults to 10 and &lt;code&gt;--retries&lt;/code&gt; to 3, while &lt;code&gt;--timeout&lt;/code&gt; defaults to 5m0s and &lt;code&gt;--contimeout&lt;/code&gt; to 1m0s. Lower the timeouts on a flaky link so failures surface in seconds rather than minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits show up as 403 and 429 responses:&lt;/strong&gt; Google Drive enforces a 750 GB per day upload cap per account, and hitting it stops uploads for the rest of the day regardless of your flags. Use &lt;code&gt;--tpslimit 10&lt;/code&gt; to stay under per second quotas, and check &lt;code&gt;rclone mount --stats 30s&lt;/code&gt; output or the log for pacer messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bisync stops rather than guesses:&lt;/strong&gt; a run that cannot reach one side aborts with a non zero exit code and no changes, which is safe but silent, so wire the timer to alert on failure with &lt;code&gt;OnFailure=&lt;/code&gt; in the systemd unit.&lt;/p&gt;

&lt;p&gt;Neither tool queues work indefinitely for you. A mount buffers only what fits in its cache, and bisync simply waits for its next scheduled attempt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running the mount under systemd and recovering it after a reboot
&lt;/h2&gt;

&lt;p&gt;A mount started from a shell dies with the shell. Write a unit file, &lt;code&gt;/etc/systemd/system/rclone-mount.service&lt;/code&gt;, and treat the mount as infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait for the network, not just for boot:&lt;/strong&gt; set &lt;code&gt;After=network-online.target&lt;/code&gt; and &lt;code&gt;Wants=network-online.target&lt;/code&gt;, because a mount that starts before DNS resolves fails immediately and leaves an empty directory that applications will happily write into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;Type=notify&lt;/code&gt;:&lt;/strong&gt; rclone signals systemd once the mount is actually ready, so dependent services do not start against a directory that is not mounted yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always set an &lt;code&gt;ExecStop&lt;/code&gt;:&lt;/strong&gt; &lt;code&gt;ExecStop=/bin/fusermount -uz /home/you/Cloud&lt;/code&gt; handles the stale endpoint case on restart, and pair it with &lt;code&gt;Restart=on-failure&lt;/code&gt; and &lt;code&gt;RestartSec=10&lt;/code&gt; so a crash recovers without you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide who can see the mount:&lt;/strong&gt; by default only the user running rclone can read it, and exposing it to Docker containers or other accounts needs &lt;code&gt;--allow-other&lt;/code&gt;, which requires &lt;code&gt;user_allow_other&lt;/code&gt; in &lt;code&gt;/etc/fuse.conf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run bisync from a timer, not cron:&lt;/strong&gt; a &lt;code&gt;.timer&lt;/code&gt; with &lt;code&gt;OnUnitInactiveSec=5min&lt;/code&gt; will not overlap runs the way cron can, and &lt;code&gt;OnFailure=&lt;/code&gt; gives you a place to hang an alert.&lt;/p&gt;

&lt;p&gt;If you run the mount as your login user rather than root, enable &lt;code&gt;loginctl enable-linger $USER&lt;/code&gt; or the unit stops when you log out. Where this box lives is your call: a home server, a NAS, a self managed VPS, or a managed host. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. On any of them, verify recovery honestly by rebooting and then running &lt;code&gt;findmnt /home/you/Cloud&lt;/code&gt; before you trust it with a working set.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where should the mount run: home server, NAS, VPS or managed Personal Cloud Server?
&lt;/h2&gt;

&lt;p&gt;Put the mount where the bandwidth and the cache disk are, then reach it from your laptop over the LAN or a tunnel. Mounting the same remote independently on four devices multiplies API calls and gives you four caches to keep warm.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Main constraint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Home server with a spare SSD&lt;/td&gt;
&lt;td&gt;Large caches and 24/7 bisync timers&lt;/td&gt;
&lt;td&gt;Your upload link caps every write to the remote&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAS appliance&lt;/td&gt;
&lt;td&gt;Reusing disks you already own&lt;/td&gt;
&lt;td&gt;Vendor kernels may not load &lt;code&gt;fuse&lt;/code&gt;, and package availability is limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self managed VPS&lt;/td&gt;
&lt;td&gt;Fast symmetric bandwidth near the provider region&lt;/td&gt;
&lt;td&gt;Small volumes force a low &lt;code&gt;--vfs-cache-max-size&lt;/code&gt;, and egress may be billed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed Personal Cloud Server&lt;/td&gt;
&lt;td&gt;One click app installs and public HTTPS without port forwarding&lt;/td&gt;
&lt;td&gt;You work within the app store and container model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The laptop itself&lt;/td&gt;
&lt;td&gt;A single device setup with no server&lt;/td&gt;
&lt;td&gt;Cache and mount vanish when the lid closes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user, with each app reachable on a public HTTPS subdomain through NSL.SH mesh routing.&lt;/p&gt;

&lt;p&gt;Two details decide more than the category. First, if rclone runs inside a container, a FUSE mount needs &lt;code&gt;--device /dev/fuse&lt;/code&gt; and &lt;code&gt;--cap-add SYS_ADMIN&lt;/code&gt;, and the mount is visible only inside that container unless you add &lt;code&gt;--allow-other&lt;/code&gt; and a shared bind mount. Second, colocation matters: a VPS in the same region as your bucket turns a 100 ms round trip into single digit milliseconds, which is the single largest latency win available to you.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you reach the same folder from a phone or a second laptop?
&lt;/h2&gt;

&lt;p&gt;Do not mount the remote a second time. Re-export the copy you already have on the server, and serve the bisync folder rather than the mount so you are not stacking one cache on top of another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;rclone serve webdav /srv/cloud --addr :8080 --user you --pass secret&lt;/code&gt;:&lt;/strong&gt; the broadest client support, readable by Finder, Windows Explorer, and mobile apps on both platforms, at the cost of chatty per file requests over a slow link.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;rclone serve sftp&lt;/code&gt;:&lt;/strong&gt; better for large files and lossy connections, and it gives you an existing client on every laptop through &lt;code&gt;sshfs&lt;/code&gt; or an SFTP capable file manager.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;rclone serve s3&lt;/code&gt;:&lt;/strong&gt; useful when the consumer is a backup tool or a script that already speaks the S3 API rather than a human with a file browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Samba on the same directory:&lt;/strong&gt; the right answer when the second laptop is on the same LAN and you want native Finder or Explorer behaviour including proper file locking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second bisync pair on the other laptop:&lt;/strong&gt; the only option that gives that machine genuine offline copies, and the reason to remember that bisync links exactly two endpoints, so three devices means each syncs to the remote, not to each other.&lt;/p&gt;

&lt;p&gt;Two rules keep this safe. Never expose &lt;code&gt;rclone serve&lt;/code&gt; directly to the internet on a plain HTTP port: put it behind a reverse proxy with TLS, or reach it over a WireGuard or Tailscale tunnel so the listener stays on a private interface. And pick one writer per file where you can, because WebDAV clients and bisync runs editing the same path within the same 5 minute window is exactly how you manufacture conflict files.&lt;/p&gt;

</description>
      <category>rclone</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Should Ntfy Run on the Home Server It Is Meant to Warn You About? Where Alerts Fail and How to Avoid It</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 10 Sep 2026 07:05:48 +0000</pubDate>
      <link>https://dev.to/john_182319291/should-ntfy-run-on-the-home-server-it-is-meant-to-warn-you-about-where-alerts-fail-and-how-to-2h86</link>
      <guid>https://dev.to/john_182319291/should-ntfy-run-on-the-home-server-it-is-meant-to-warn-you-about-where-alerts-fail-and-how-to-2h86</guid>
      <description>&lt;p&gt;Yes, if Ntfy is your only way of getting alerts, it should not run only on the server it watches. A power cut, an ISP outage, a kernel panic or a dead Docker daemon takes down the thing that failed and the thing that would tell you, in the same moment. Self-hosting Ntfy on your home server is still fine for app-level messages such as "backup finished" or "cron job failed", because the host is alive to send them. For "my server is down" alerts, something outside that box has to notice the silence and send the message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single-box homelabber (one mini PC running Jellyfin, Nextcloud and Ntfy)&lt;/strong&gt;: keep the local Ntfy for job notifications and add an external heartbeat check, because a dead host cannot report its own death.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy-first tinkerer who refuses third-party relays&lt;/strong&gt;: run a second Ntfy instance on a small VPS or at another site, because being independent of other services only helps if the second instance fails separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup-and-cron notifier who only wants "job done" messages&lt;/strong&gt;: self-host on the same server and switch to "alert me when the message does not arrive" logic, because a missing success message is the real signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iPhone user&lt;/strong&gt;: sending critical alerts to ntfy.sh with an access-protected topic costs you little, because a self-hosted server already hands iOS push wake-ups to the ntfy.sh upstream relay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two-site owner (home server plus a NAS at a relative's house)&lt;/strong&gt;: have each site host the Ntfy that watches the other, because two homes rarely lose power and internet at the same time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freelancer hosting client sites from home&lt;/strong&gt;: do not rely on a home-hosted Ntfy for uptime alerts at all, because clients will notice the outage before you do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core tradeoff is keeping your notification pipeline fully under your control versus having an alert path that survives the exact failure you need to hear about.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What actually goes silent when your home server goes down?&lt;/li&gt;
&lt;li&gt;Which outages can a same-host Ntfy still report, and which can it never report?&lt;/li&gt;
&lt;li&gt;How Ntfy's message cache and missed-message recovery behave during an outage&lt;/li&gt;
&lt;li&gt;Why silence is the signal: dead man's switch patterns for Ntfy alerts&lt;/li&gt;
&lt;li&gt;Is ntfy.sh reliable and private enough for critical home server alerts?&lt;/li&gt;
&lt;li&gt;Is a second, off-site Ntfy instance worth the extra maintenance?&lt;/li&gt;
&lt;li&gt;Where should the Ntfy instance that watches your home server run?&lt;/li&gt;
&lt;li&gt;Does the phone side of Ntfy add its own failure points?&lt;/li&gt;
&lt;li&gt;Wiring Uptime Kuma, Healthchecks and cron to Ntfy without a shared failure domain&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What actually goes silent when your home server goes down?
&lt;/h2&gt;

&lt;p&gt;If Ntfy runs on the box it watches, one failure can shut down three things at once: the Ntfy server, the tools that publish to it (Uptime Kuma, cron scripts, smartd, a UPS daemon) and the cache holding recent messages. How much you lose depends on how the server failed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Power loss&lt;/strong&gt;: every process stops at the same moment, so nothing gets a chance to send a last message. A UPS only helps if something like a NUT shutdown hook runs &lt;code&gt;curl -d "on battery" ntfy.example.com/alerts&lt;/code&gt; before the battery runs out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ISP or router outage&lt;/strong&gt;: the server stays up and local publishing still works. Your phone on mobile data just can't reach Ntfy, so the messages wait until the connection is back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kernel panic or hard freeze&lt;/strong&gt;: no userspace code runs, so no monitor on the host can notice the failure, let alone report it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker daemon crash&lt;/strong&gt;: the host still answers ping, but the Ntfy container and the Uptime Kuma container stop together. Running Ntfy as a systemd service from the official package instead of in Docker avoids this particular case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk full&lt;/strong&gt;: Ntfy's &lt;code&gt;cache-file&lt;/code&gt; database, logs and your scripts' temp files all fail to write. You usually find out when things start behaving strangely, not from an alert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this depends on the hardware. A home server, a NAS, a self-managed VPS or Yundera are each a single failure domain when they both run the monitored apps and send the alerts about them. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. The rest of this article is about what to move off that one machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which outages can a same-host Ntfy still report, and which can it never report?
&lt;/h2&gt;

&lt;p&gt;Useful rule: a same-host Ntfy can report a failure only if the kernel, the network stack and Ntfy itself are still running. That covers more than you might expect, but not the failures that matter most.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Can a same-host Ntfy report it?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One app container crashes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Uptime Kuma or a healthcheck script sees it and publishes locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup or cron job exits non-zero&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;The script can call &lt;code&gt;curl&lt;/code&gt; on its failure path before it exits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk usage passes 90%&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;A threshold check fires while there is still room to write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMART warning from &lt;code&gt;smartd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;The disk is degraded, not dead yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk at 100%&lt;/td&gt;
&lt;td&gt;Unreliable&lt;/td&gt;
&lt;td&gt;The cache database and the sending scripts may fail to write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker daemon crash&lt;/td&gt;
&lt;td&gt;Only if Ntfy runs outside Docker&lt;/td&gt;
&lt;td&gt;Containerised publishers and the containerised server stop together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISP or router outage&lt;/td&gt;
&lt;td&gt;Late&lt;/td&gt;
&lt;td&gt;The message is published locally and reaches your phone only after the link comes back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power loss or kernel panic&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;td&gt;Nothing is running to notice or publish&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is clear. A same-host Ntfy is good at "something inside the server went wrong" and useless for "the server itself is gone". The first group is the everyday noise of a homelab, so local Ntfy still earns its place. The second group is the one you set up alerting for in the first place.&lt;/p&gt;

&lt;p&gt;This does not change with the platform. A home server, a NAS, a self-managed VPS or Yundera all show the same split. When the bottom rows of the table happen, only an observer running somewhere else can tell you.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Ntfy's message cache and missed-message recovery behave during an outage
&lt;/h2&gt;

&lt;p&gt;Ntfy is built to cope with clients that disconnect. It stores messages on the server so a phone that was offline can catch up later. That design helps with some outages and does nothing for others, so it is worth knowing exactly how it works.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cache is only as durable as its configuration&lt;/strong&gt;: without &lt;code&gt;cache-file&lt;/code&gt; set in &lt;code&gt;server.yml&lt;/code&gt;, Ntfy keeps messages in memory only, so a container restart after a crash wipes everything that had not been delivered yet. Point it at a SQLite file on a persistent volume, such as &lt;code&gt;/var/cache/ntfy/cache.db&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messages expire after &lt;code&gt;cache-duration&lt;/code&gt;&lt;/strong&gt;: the default is 12 hours. If your ISP drops overnight and comes back 14 hours later, alerts from the start of the outage have already been deleted before your phone reconnects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clients catch up with &lt;code&gt;since&lt;/code&gt;&lt;/strong&gt;: the apps reconnect and ask for everything after the last message ID they saw. You can do the same by hand with &lt;code&gt;curl "https://ntfy.example.com/alerts/json?poll=1&amp;amp;since=all"&lt;/code&gt; to check what the server actually holds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attachments have their own shorter clock&lt;/strong&gt;: &lt;code&gt;attachment-expiry-duration&lt;/code&gt; defaults to 3 hours, so a log file attached to an alert may already be gone while the text is still there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messages sent with &lt;code&gt;X-Cache: no&lt;/code&gt; are never stored&lt;/strong&gt;: that is handy for chatty status pings, but a phone that is offline when one arrives never gets it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failed publishes are not queued anywhere&lt;/strong&gt;: when the server itself is unreachable, a plain &lt;code&gt;curl&lt;/code&gt; from a remote script just fails. Unless you add &lt;code&gt;--retry 5&lt;/code&gt; or your own queue, the message is lost before the cache ever sees it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cache recovers messages that were delayed. It cannot recover messages that were never published.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why silence is the signal: dead man's switch patterns for Ntfy alerts
&lt;/h2&gt;

&lt;p&gt;A server that has lost power cannot send a message, so the alert has to come from something that notices the messages stopped. This is a dead man's switch: the server checks in on a schedule, and a watcher somewhere else alerts you when the check-ins stop. Ntfy has no built-in "alert me if no message arrives" feature, so the watcher is always a separate tool.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hosted heartbeat checks&lt;/strong&gt;: a crontab line like &lt;code&gt;*/5 * * * * curl -fsS -m 10 --retry 3 https://hc-ping.com/&amp;lt;uuid&amp;gt;&lt;/code&gt; pings Healthchecks.io every 5 minutes. When a ping is missed and the grace period runs out, Healthchecks sends an alert through its native Ntfy integration to whatever Ntfy server you point it at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uptime Kuma push monitors off-site&lt;/strong&gt;: run Uptime Kuma somewhere other than home and add a monitor of type Push. Your server calls the push URL on a timer, and Kuma marks it down after one heartbeat interval with no call, then notifies you through its Ntfy provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote pull checks&lt;/strong&gt;: the same off-site Kuma can probe a public URL or TCP port on your home server. Unlike a push heartbeat, this also catches the case where the server is fine but your ISP has dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grace periods sized to your maintenance&lt;/strong&gt;: if a kernel update reboot takes 4 minutes, a 5-minute schedule with a 10-minute grace keeps routine reboots quiet and still alerts you in under a quarter of an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeats from the end of the chain&lt;/strong&gt;: put the ping at the end of the backup script, not in its own cron entry. That way it proves the job finished, not only that cron started.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The watcher's location decides everything. If it runs on the home server, it goes silent along with everything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is ntfy.sh reliable and private enough for critical home server alerts?
&lt;/h2&gt;

&lt;p&gt;The public instance at ntfy.sh runs the same open source server you would host yourself, on infrastructure that has nothing in common with your home. That alone makes it a strong candidate for the one alert that has to survive a power cut. The tradeoffs are about trust and limits, not features.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Topic names are the password on anonymous use&lt;/strong&gt;: anyone who guesses &lt;code&gt;homelab-alerts&lt;/code&gt; can read your messages or send you fake ones. A random topic such as &lt;code&gt;hl-7f3k9q2xw8&lt;/code&gt; makes guessing impractical. An account with access tokens, sent as &lt;code&gt;Authorization: Bearer tk_...&lt;/code&gt;, closes the hole properly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message bodies are readable by the operator&lt;/strong&gt;: Ntfy does not encrypt messages end to end, so whatever you send sits in plain text in ntfy.sh's cache until it expires. Keep hostnames, IP addresses and file paths out of alert text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anonymous use has rate limits&lt;/strong&gt;: ntfy.sh caps daily messages and attachment sizes per visitor, and the paid ntfy Pro tiers raise those caps and add reserved topics. A heartbeat alert that fires only on failure stays far below any cap. A chatty Uptime Kuma pointed at it may not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No uptime guarantee on the free service&lt;/strong&gt;: the public instance is run by the project itself, and free use comes with no guarantee. For a backup alert path that is acceptable, because it only has to be up at the rare moment your home server is down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The split design works best&lt;/strong&gt;: send routine job notifications to your local Ntfy and send only "heartbeat missed" alerts from Healthchecks.io to ntfy.sh. That way ntfy.sh sees almost nothing about your systems, while the alert that matters travels outside your home.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most homelabs, ntfy.sh is private enough for a single, carefully worded outage alert.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is a second, off-site Ntfy instance worth the extra maintenance?
&lt;/h2&gt;

&lt;p&gt;The Ntfy server is a single Go binary with modest resource needs, so compute is not the cost of a second instance. The cost is that you now run a public service, and a public service needs more care than a LAN-only container.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Same-host Ntfy only&lt;/th&gt;
&lt;th&gt;Adding an off-site instance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Access control&lt;/td&gt;
&lt;td&gt;Often left open on the LAN&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;auth-default-access: deny-all&lt;/code&gt;, then &lt;code&gt;ntfy user add&lt;/code&gt;, &lt;code&gt;ntfy access&lt;/code&gt; and &lt;code&gt;ntfy token add&lt;/code&gt; for every publisher&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS and proxy&lt;/td&gt;
&lt;td&gt;Optional behind the home router&lt;/td&gt;
&lt;td&gt;Public HTTPS, &lt;code&gt;behind-proxy: true&lt;/code&gt; and certificate renewal you have to keep an eye on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upgrades&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;docker pull binwiederhier/ntfy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Two instances to keep on the same release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backups&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cache-file&lt;/code&gt; if you care&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;auth-file&lt;/code&gt; as well, or every token has to be reissued after a rebuild&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Watching the watcher&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;td&gt;The VPS needs its own heartbeat, or it can fail silently for weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone setup&lt;/td&gt;
&lt;td&gt;One server in the app&lt;/td&gt;
&lt;td&gt;Subscriptions on two servers, with different topics and credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly cost&lt;/td&gt;
&lt;td&gt;Electricity you already pay&lt;/td&gt;
&lt;td&gt;A small VPS fee, plus your time for patching the OS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The row that usually decides it is watching the watcher. An off-site instance that nobody checks gives you false confidence: the day your home server dies may be months after the VPS quietly ran out of disk.&lt;/p&gt;

&lt;p&gt;A second instance makes sense in two cases. The first is when you refuse to let any third-party service carry your alerts. The second is when you already run a VPS for other things, so the patching and backups are already done. If the only reason is one "server down" message, a hosted heartbeat check sending to ntfy.sh gets you the same failure-domain separation with no new server to maintain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where should the Ntfy instance that watches your home server run?
&lt;/h2&gt;

&lt;p&gt;Separate two roles before choosing a location. The watcher notices the silence, and the messenger delivers the alert. Both have to survive the failures they are meant to report, and each place you might put them covers a different set of failures.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The same home server&lt;/strong&gt;: goes down with everything it watches. Keep it for job notifications and nothing that says "the server is down".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second device on the same LAN&lt;/strong&gt;: a Raspberry Pi running the arm64 Ntfy build outlives a kernel panic, a Docker crash or a full disk on the main box. It still dies in a power cut unless it has its own UPS, and during an ISP outage it cannot reach your phone either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A NAS or Pi at a second site&lt;/strong&gt;: a relative's house gives you separate power and a separate uplink. You depend on their router, their outages and your ability to fix things remotely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small self-managed VPS&lt;/strong&gt;: separate power, network and hardware, at the cost of the public-service maintenance covered in the previous section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ntfy.sh as the messenger&lt;/strong&gt;: no maintenance and fully separate infrastructure, with the privacy and rate-limit tradeoffs already covered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hosted watcher with any messenger&lt;/strong&gt;: Healthchecks.io spots the missed heartbeat on its own infrastructure, so even a same-LAN messenger only has to be reachable when it matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you pick a host, whether a self-managed VPS, a NAS at a friend's house or Yundera, ask three questions. Does it share a power circuit with the monitored machine? Does it share an internet uplink? Does it resolve its name through DNS running on that machine? One "yes" means that failure will silence your alerts again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Does the phone side of Ntfy add its own failure points?
&lt;/h2&gt;

&lt;p&gt;Getting the alert to the server is only half of the path. The other half is waking a phone that may be asleep, in a pocket, on battery saver or connected to a flaky mobile network, and each platform does that differently.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;How it gets woken&lt;/th&gt;
&lt;th&gt;What else must be working&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Android, Google Play build&lt;/td&gt;
&lt;td&gt;Firebase for ntfy.sh topics, its own always-on connection for self-hosted servers&lt;/td&gt;
&lt;td&gt;Battery optimisation exemption so Android does not kill the connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android, F-Droid build&lt;/td&gt;
&lt;td&gt;Always its own connection, no Firebase at all&lt;/td&gt;
&lt;td&gt;The same exemption, plus the ongoing foreground service notification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS app&lt;/td&gt;
&lt;td&gt;Your server sends a poll request through &lt;code&gt;upstream-base-url: "https://ntfy.sh"&lt;/code&gt;, Apple's push service wakes the app, and the app then fetches the message from your server&lt;/td&gt;
&lt;td&gt;ntfy.sh, Apple's push service and your server, all at once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web app in a browser&lt;/td&gt;
&lt;td&gt;Web Push through the browser vendor's push service, once VAPID keys are configured&lt;/td&gt;
&lt;td&gt;The browser's push infrastructure and an active subscription&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email forwarding&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-Email&lt;/code&gt; header, sent through the server's &lt;code&gt;smtp-sender-addr&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A working SMTP relay and a mailbox you actually check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The iOS row matters most here. If your self-hosted Ntfy is the messenger and ntfy.sh has a problem at the same moment, iPhone alerts show up late or only when you open the app. If your own server is the thing that is down, the app has nothing to fetch even when the wake-up does arrive.&lt;/p&gt;

&lt;p&gt;Two cheap fixes cover most of the phone-side risk. Send outage alerts with &lt;code&gt;X-Priority: 5&lt;/code&gt; and allow that max-priority notification channel to override Do Not Disturb in Android settings. Then add a second delivery channel, such as email, for the heartbeat alert only.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wiring Uptime Kuma, Healthchecks and cron to Ntfy without a shared failure domain
&lt;/h2&gt;

&lt;p&gt;The goal is simple to state: every alert path ends at a messenger that does not share power, network or a Docker daemon with whatever raised the alert. In practice that means deliberately choosing the destination for each tool.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cron jobs with a fallback URL&lt;/strong&gt;: wrap your publish call as &lt;code&gt;curl -fsS -m 5 -d "backup failed" http://ntfy.lan/jobs || curl -fsS -m 10 -d "backup failed" https://ntfy.sh/hl-7f3k9q2xw8&lt;/code&gt;. When the local container is down, the second call still gets the message out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local Uptime Kuma for container health&lt;/strong&gt;: point its Ntfy notification at your local server with a dedicated &lt;code&gt;health&lt;/code&gt; topic and credentials. It is the right tool for "Nextcloud returns 502", and it is fine if it dies along with the host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Healthchecks.io for host death&lt;/strong&gt;: in its Ntfy integration, set an off-site server URL and a separate &lt;code&gt;outage&lt;/code&gt; topic, with the highest priority for down events and a low priority for recovery events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A remote Uptime Kuma pointed away from home&lt;/strong&gt;: the most common mistake is an off-site Kuma whose notification still targets &lt;code&gt;ntfy.yourdomain.com&lt;/code&gt; at home. It detects the outage perfectly and then tries to report it to a server that is down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A boot announcement&lt;/strong&gt;: a systemd oneshot unit with &lt;code&gt;After=network-online.target&lt;/code&gt; that runs &lt;code&gt;ntfy publish outage "host rebooted"&lt;/code&gt; tells you that a short power cut happened, even when it ended inside the heartbeat grace period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One topic per severity&lt;/strong&gt;: keeping &lt;code&gt;jobs&lt;/code&gt;, &lt;code&gt;health&lt;/code&gt; and &lt;code&gt;outage&lt;/code&gt; separate lets you mute routine noise on your phone without also muting the alert that matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before trusting any path, write down the full chain for each alert: the tool, the host, the messenger, the phone. Any host that appears twice in the same chain is a shared failure domain.&lt;/p&gt;

</description>
      <category>ntfy</category>
      <category>selfhosting</category>
      <category>homelab</category>
      <category>monitoring</category>
    </item>
  </channel>
</rss>
