<?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>Immich Sizing Guide: What a 20,000 Photo Google Takeout Import Really Costs in Disk, RAM and Hours</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:05:56 +0000</pubDate>
      <link>https://dev.to/john_182319291/immich-sizing-guide-what-a-20000-photo-google-takeout-import-really-costs-in-disk-ram-and-hours-48ic</link>
      <guid>https://dev.to/john_182319291/immich-sizing-guide-what-a-20000-photo-google-takeout-import-really-costs-in-disk-ram-and-hours-48ic</guid>
      <description>&lt;p&gt;Budget roughly 1.35 to 1.6 times your raw photo size on disk, 6 GB of RAM for the full stack with machine learning enabled, 4 CPU cores, and a full weekend of background processing before a 20,000 item library is fully searchable. The import itself is the fast part. Thumbnail generation, video transcoding and machine learning jobs are what actually keep the server busy, and three settings decided in the first hour, the storage template, the machine learning model, and the transcoding policy, are the ones that force a full re-run of every job if you change your mind in month two.&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 phone-only archivist, one iPhone and 20,000 photos with almost no video:&lt;/strong&gt; 2 CPU cores, 4 GB RAM and 1.4x your library size is enough, because thumbnails dominate and transcoding barely runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The family with a decade of camcorder video, 20,000 items where 3,000 are clips:&lt;/strong&gt; go to 4 cores and 8 GB RAM before you import, because video transcoding is the single job that will run for days on weak hardware.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The RAW shooter with a Lightroom folder tree, 20,000 files averaging 25 MB:&lt;/strong&gt; use an external library in read-only mode rather than uploading, so Immich never becomes the only copy of your originals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The privacy-first switcher leaving Google Photos this week:&lt;/strong&gt; set the storage template and the machine learning model before the first upload, then never touch them again, because both settings rewrite work already done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The low-power host, a Raspberry Pi 5 or an N100 mini PC:&lt;/strong&gt; disable the smart search model on day one and run facial recognition only, or accept that machine learning jobs will still be queued a week later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The person who just wants it working tonight:&lt;/strong&gt; import with default settings, leave transcoding on the optimal policy, and treat the first 72 hours as unattended processing time rather than a broken install.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is that every setting which makes Immich feel fast and searchable later, larger machine learning models, generated previews, transcoded video, costs you disk and hours of CPU during the first week, and changing your mind afterwards means paying that cost a second time.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;How much disk space does Immich actually need for 20,000 photos?&lt;/li&gt;
&lt;li&gt;Why is the Immich database and thumbnail folder bigger than you expected?&lt;/li&gt;
&lt;li&gt;How much RAM does Immich need, and what happens when it runs out?&lt;/li&gt;
&lt;li&gt;How many CPU cores does the first import really use?&lt;/li&gt;
&lt;li&gt;How long does a 20,000 item Google Takeout import take from start to searchable?&lt;/li&gt;
&lt;li&gt;Should you upload into Immich or point it at an external library?&lt;/li&gt;
&lt;li&gt;What does the Google Takeout format break, and how do you fix it before importing?&lt;/li&gt;
&lt;li&gt;Which storage template should you set before the first upload?&lt;/li&gt;
&lt;li&gt;Which machine learning model should you choose on day one?&lt;/li&gt;
&lt;li&gt;What should the video transcoding policy be, and what does it cost you?&lt;/li&gt;
&lt;li&gt;Where should you actually run Immich: NAS, mini PC, home server or hosted?&lt;/li&gt;
&lt;li&gt;Which day-one settings force a full re-run if you change them later?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How much disk space does Immich actually need for 20,000 photos?
&lt;/h2&gt;

&lt;p&gt;Start from the size of your Google Takeout, not the photo count. A 20,000 item library from a modern phone is usually somewhere between 60 GB and 120 GB of originals, because a 12 megapixel HEIC frame lands near 2 MB while a single 4K clip can pass 400 MB. Immich stores your originals untouched, then adds derived files on top.&lt;/p&gt;

&lt;p&gt;The multiplier you should plan for is 1.35x to 1.6x the original size, split across four directories under &lt;code&gt;UPLOAD_LOCATION&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;upload/&lt;/code&gt; and &lt;code&gt;library/&lt;/code&gt;, the originals:&lt;/strong&gt; 100 percent of your Takeout size, byte for byte, because Immich never recompresses the file you gave it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;thumbs/&lt;/code&gt;, the previews:&lt;/strong&gt; typically 15 to 25 percent on a photo heavy library, since every asset gets a small thumbnail plus a larger preview image used by the timeline and the detail view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;encoded-video/&lt;/code&gt;, the transcodes:&lt;/strong&gt; zero if you have no video, but easily 50 to 100 percent of your video bytes again when the default policy re-encodes clips your browser cannot play natively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Postgres volume, metadata and machine learning vectors:&lt;/strong&gt; small in absolute terms, usually a few hundred megabytes at this scale, but it grows with embeddings and faces rather than with file size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Practical floor: if your Takeout unzips to 90 GB, provision 200 GB and do not let the filesystem cross 80 percent during import. Running &lt;code&gt;du -sh&lt;/code&gt; on each subdirectory after the first 1,000 assets gives you a real multiplier for your own library, which beats any generic estimate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why is the Immich database and thumbnail folder bigger than you expected?
&lt;/h2&gt;

&lt;p&gt;Because Immich does not generate one thumbnail per photo. It generates a small WebP tile for the timeline grid and a much larger preview used whenever you open an asset, and that second file is the one that surprises people. A 2 MB HEIC original can produce a preview of several hundred kilobytes, so the ratio between a phone photo and its derivatives is far worse than it is for a 25 MB RAW file, where the same preview is a rounding error.&lt;/p&gt;

&lt;p&gt;Four things inflate these directories beyond a naive estimate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Preview resolution is a setting, not a constant:&lt;/strong&gt; the preview size configured in Administration, Settings, Image Settings applies to every asset, so raising it after import means every existing preview is regenerated and the old ones replaced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Motion photos count twice:&lt;/strong&gt; an iPhone Live Photo or a Samsung motion shot arrives as a still plus a short video, and Immich stores and processes both, which quietly turns 20,000 selected items into more than 20,000 stored assets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres carries vectors, not just rows:&lt;/strong&gt; smart search stores an embedding per asset and facial recognition stores one per detected face, so a library with many group photos grows the database faster than a library of landscapes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleted assets linger for 30 days:&lt;/strong&gt; the trash retention default keeps originals and derivatives on disk until the period expires, so disk usage during a messy first week reflects your mistakes as well as your library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check the real split with &lt;code&gt;du -sh thumbs encoded-video&lt;/code&gt; inside &lt;code&gt;UPLOAD_LOCATION&lt;/code&gt; before you conclude anything about total size.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much RAM does Immich need, and what happens when it runs out?
&lt;/h2&gt;

&lt;p&gt;Plan for 6 GB of system RAM for the full stack with machine learning enabled, and 4 GB if you disable smart search. The four containers are not equal: the server and Postgres are steady and modest, Redis is tiny, and &lt;code&gt;immich-machine-learning&lt;/code&gt; is the one that spikes, because it loads a model into memory the first time a job needs it and holds it there.&lt;/p&gt;

&lt;p&gt;What failure looks like is the part nobody warns you about. The kernel OOM killer does not stop Immich politely. It kills the machine learning container mid job, the job returns to the queue, the container restarts, reloads the model, and fails again on the same asset. You get a silent loop that burns CPU for hours and never advances the counter.&lt;/p&gt;

&lt;p&gt;Three signals tell you this is happening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker compose ps&lt;/code&gt; shows a restart count climbing:&lt;/strong&gt; the machine learning container sits at 5 or 10 restarts while the server container sits at 0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The job queue stalls at the same number:&lt;/strong&gt; smart search stays frozen while thumbnail generation keeps moving, because only one container is dying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dmesg | grep -i oom&lt;/code&gt; prints kill lines:&lt;/strong&gt; this is the definitive check, and it names the process that was terminated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is either more RAM, a smaller model, or fewer concurrent machine learning jobs, set to 1 in the job settings.&lt;/p&gt;

&lt;p&gt;Hosting choice matters here because you cannot add RAM to a box that has none free. A NAS with 4 GB shared with other services, a self-managed VPS, a mini PC and a managed option are all viable starting points. 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;
  
  
  How many CPU cores does the first import really use?
&lt;/h2&gt;

&lt;p&gt;All of them, unless you tell Immich otherwise. Immich runs background work as named job queues, and each queue has its own concurrency setting in Administration, Settings, Job Settings. The defaults are tuned for a machine that is doing nothing else, so on a 4 core box the first import will pin every core for hours and make the web interface feel sluggish while you are still uploading.&lt;/p&gt;

&lt;p&gt;The queues do not weigh the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thumbnail Generation:&lt;/strong&gt; the heaviest sustained consumer during a bulk import, because it decodes every original and writes two derivatives, and it scales almost linearly with the concurrency value you set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video Transcoding:&lt;/strong&gt; the one queue you should keep at concurrency 1, since FFmpeg already uses multiple threads internally and running two transcodes at once mostly makes both slower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart Search and Face Detection:&lt;/strong&gt; CPU bound unless you have a supported GPU, and these are the queues that decide whether your library is searchable on day three or day seven.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata Extraction:&lt;/strong&gt; cheap per asset, fast to complete, and rarely the bottleneck at 20,000 items.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four cores is a workable floor. Two cores works but roughly doubles your wall clock time, and a shared vCPU with a burst credit balance will collapse to baseline speed partway through and stay there.&lt;/p&gt;

&lt;p&gt;Watch it live with &lt;code&gt;docker stats&lt;/code&gt;, which shows per container CPU percentage. If &lt;code&gt;immich-server&lt;/code&gt; sits near your core count times 100 percent for hours, that is normal during first import, not a fault. Lower Thumbnail Generation concurrency to 2 if you need the machine responsive for anything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  How long does a 20,000 item Google Takeout import take from start to searchable?
&lt;/h2&gt;

&lt;p&gt;Think in phases, not in one number. The upload finishes long before the library is usable, and the gap between those two moments is where people assume something is broken.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;What is happening&lt;/th&gt;
&lt;th&gt;Typical shape on 4 cores&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upload via &lt;code&gt;immich-go&lt;/code&gt; or the CLI&lt;/td&gt;
&lt;td&gt;Files transferred, metadata read, duplicates skipped&lt;/td&gt;
&lt;td&gt;Minutes to a few hours, limited by disk or LAN speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata extraction&lt;/td&gt;
&lt;td&gt;Dates, GPS, camera fields written to Postgres&lt;/td&gt;
&lt;td&gt;Completes soon after upload, rarely the bottleneck&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thumbnail generation&lt;/td&gt;
&lt;td&gt;Two derivatives written per asset&lt;/td&gt;
&lt;td&gt;Several hours, and the timeline stays gappy until it ends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video transcoding&lt;/td&gt;
&lt;td&gt;Non compatible clips re-encoded by FFmpeg&lt;/td&gt;
&lt;td&gt;Hours to days, entirely driven by how much video you have&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smart search and face detection&lt;/td&gt;
&lt;td&gt;Embeddings and faces computed per asset&lt;/td&gt;
&lt;td&gt;The long tail, often the last queue still running&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A photo heavy library with under 500 short clips is usually fully processed inside 24 hours on 4 cores. Add 3,000 camcorder clips and the same library can take a long weekend, because transcoding runs at concurrency 1 by design.&lt;/p&gt;

&lt;p&gt;Two practical points. First, upload speed and processing speed are independent: &lt;code&gt;immich-go&lt;/code&gt; can finish at 2 a.m. while the job queues still have 18,000 items pending. Second, the Jobs page in the admin panel shows an active and a waiting count per queue, and the waiting count falling is the only honest progress bar you have.&lt;/p&gt;

&lt;p&gt;Do not judge the install until every queue reads zero. Search results, people grouping and the map view are all incomplete before that point, and re-running them costs the same hours again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Should you upload into Immich or point it at an external library?
&lt;/h2&gt;

&lt;p&gt;Upload if Immich is becoming your primary photo home. Use an external library if you already have a folder tree you edit with other tools and want to keep owning.&lt;/p&gt;

&lt;p&gt;The two paths differ in who controls the files on disk:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uploaded assets live under &lt;code&gt;UPLOAD_LOCATION&lt;/code&gt; and obey the storage template:&lt;/strong&gt; Immich decides the folder layout and filename, moves files when you change the template, and treats deletion in the app as deletion on disk after the trash period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External libraries are read only by design:&lt;/strong&gt; you register a path such as &lt;code&gt;/mnt/photos&lt;/code&gt; in the container, Immich indexes what it finds, generates thumbnails into its own directories, and never writes to or renames your originals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External libraries need a rescan to notice changes:&lt;/strong&gt; new files added by Lightroom, Syncthing or a NAS share appear after a scan job runs, either on the configured interval or when you trigger it manually, which is not the instant behaviour uploads give you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile backup always uploads:&lt;/strong&gt; the phone app has no concept of an external path, so a mixed setup is normal, historic archive as an external library and new phone photos as uploads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate risk is real if you do both with the same files:&lt;/strong&gt; importing a Takeout with &lt;code&gt;immich-go&lt;/code&gt; and also mounting that same folder externally gives you every asset twice, counted twice in storage reporting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a 20,000 file RAW archive averaging 25 MB, the external route is the safer first week choice: your originals stay where your backup script already finds them, and a mistake in Immich cannot rename or move 500 GB of files.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does the Google Takeout format break, and how do you fix it before importing?
&lt;/h2&gt;

&lt;p&gt;Takeout does not hand you a photo library. It hands you archives of files whose metadata has been moved out of the images and into JSON sidecars, and if you import them naively your entire timeline collapses onto the import date.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dates and GPS live in sidecar JSON, not in the file:&lt;/strong&gt; each media file gets a companion &lt;code&gt;.json&lt;/code&gt; holding &lt;code&gt;photoTakenTime&lt;/code&gt; and location, and Immich's plain upload path does not merge them, which is why so many first imports show 20,000 photos all dated today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sidecar filenames do not reliably match their media:&lt;/strong&gt; Google truncates long names and appends counters like &lt;code&gt;IMG_1234(1).jpg&lt;/code&gt;, so naive pairing by filename fails on a meaningful slice of any large export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edited copies arrive alongside originals:&lt;/strong&gt; a file plus its &lt;code&gt;-edited&lt;/code&gt; variant are two assets, which inflates your item count above what Google Photos showed you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Albums are folders, not metadata:&lt;/strong&gt; album membership is expressed by directory layout and an album JSON, so a plain folder upload gives you every photo and zero albums.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live Photos are split:&lt;/strong&gt; the still and the paired video land as separate files that need rejoining, not as one asset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is tooling, not manual cleanup. &lt;code&gt;immich-go&lt;/code&gt; was written for exactly this: it reads the sidecars, rebuilds dates, recreates albums and pairs motion photos, and it can consume the Takeout zip files directly without you unzipping 100 GB first.&lt;/p&gt;

&lt;p&gt;Import a single archive as a test run of a few hundred assets, confirm the timeline dates look correct, then delete those assets and run the full set. Discovering a date problem after 20,000 items means redoing all of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which storage template should you set before the first upload?
&lt;/h2&gt;

&lt;p&gt;Decide this before a single asset lands, because the template controls the on disk path of every uploaded file, and changing it later triggers a Storage Migration job that physically moves all 20,000 files.&lt;/p&gt;

&lt;p&gt;Templates are built from variables such as &lt;code&gt;{{y}}&lt;/code&gt;, &lt;code&gt;{{MM}}&lt;/code&gt;, &lt;code&gt;{{filename}}&lt;/code&gt; and &lt;code&gt;{{ext}}&lt;/code&gt;, set in Administration, Settings, Storage Template. Four sane choices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template&lt;/th&gt;
&lt;th&gt;Resulting path shape&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Disabled, the default&lt;/td&gt;
&lt;td&gt;Random directory and asset id under the user folder&lt;/td&gt;
&lt;td&gt;People who will never touch the files outside Immich&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{y}}/{{MM}}/{{filename}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2019/07/IMG_1234.jpg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anyone who wants a browsable archive that survives Immich&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2019/2019-07-14/IMG_1234.jpg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Event heavy libraries where one day equals one shoot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{album}}/{{filename}}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Corsica 2019/IMG_1234.jpg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Album driven workflows, with the caveat that assets in no album fall back&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default is genuinely defensible. Random paths never collide and never break when you rename an album. The argument against it is portability: if Immich is your only index, a corrupted database leaves you with a directory of meaningless filenames.&lt;/p&gt;

&lt;p&gt;Two hazards. Filename collisions inside the same folder get a numeric suffix, so &lt;code&gt;{{y}}/{{filename}}&lt;/code&gt; on a phone that resets its counter will produce &lt;code&gt;IMG_0001_1.jpg&lt;/code&gt;. And the template applies to uploaded assets only, never to external libraries, which keep their original paths untouched.&lt;/p&gt;

&lt;p&gt;Pick a year and month layout unless you have a specific reason not to. It is readable, it sorts, and it means a plain file browser can still make sense of your archive years from now.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which machine learning model should you choose on day one?
&lt;/h2&gt;

&lt;p&gt;This is the setting with the harshest change penalty. Smart search stores one embedding per asset, and embeddings from different models are not interchangeable, so switching models invalidates all 20,000 of them and forces a full re-run of the Smart Search queue.&lt;/p&gt;

&lt;p&gt;The choice lives in Administration, Settings, Machine Learning Settings, and the models are pulled from the Immich Hugging Face collection on first use.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The default CLIP model:&lt;/strong&gt; shipped because it fits modest hardware, and on a 4 core box with no GPU it is the only option that finishes a 20,000 item library in hours rather than days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Larger visual models:&lt;/strong&gt; better at abstract queries like "birthday cake" or "snow on a mountain", at the cost of more RAM in the machine learning container and a longer queue, which is exactly the combination that triggers the OOM loop on a 4 GB host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multilingual models:&lt;/strong&gt; the only way to search in a language other than English, noticeably heavier than their English only equivalents, and worth choosing on day one if your household does not think in English.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facial recognition is separate:&lt;/strong&gt; it runs its own detection and recognition models with their own concurrency, so you can keep faces enabled while leaving smart search off entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Off is a valid answer:&lt;/strong&gt; on a Raspberry Pi 5 or an N100, disabling smart search turns a week of queued jobs into a library that is fully browsable by date and album tonight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Test with 200 assets before committing. Search for three things you would realistically look for, judge the results, then import the rest. Making that judgement after the full import costs you the entire queue again.&lt;/p&gt;




&lt;h2&gt;
  
  
  What should the video transcoding policy be, and what does it cost you?
&lt;/h2&gt;

&lt;p&gt;Leave it on the default optimal policy unless you have a specific reason not to, then understand exactly what that policy is doing to your disk and your weekend.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transcode policy&lt;/th&gt;
&lt;th&gt;What it re-encodes&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;Don't transcode&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Zero extra disk, but clips your browser cannot decode simply will not play&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Videos not in an accepted format&lt;/td&gt;
&lt;td&gt;Only unsupported codecs and containers&lt;/td&gt;
&lt;td&gt;The cheapest useful option, ideal for phone footage that is already H.264 in MP4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Videos higher than target resolution or not in an accepted format&lt;/td&gt;
&lt;td&gt;The above, plus anything above the target resolution, 720p by default&lt;/td&gt;
&lt;td&gt;The default, and the reason a 4K library balloons in processing time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All videos&lt;/td&gt;
&lt;td&gt;Every clip regardless of source&lt;/td&gt;
&lt;td&gt;Doubles your video storage and can run for days on 3,000 items&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things decide the real cost. The target resolution, because raising it to 1080p produces larger transcodes but keeps quality your phone actually shot. The constant rate factor, where the default of 23 trades size against fidelity. And hardware acceleration, which is the difference between hours and days: Immich supports NVENC, QSV, VAAPI and RKMPP, enabled by adding &lt;code&gt;hwaccel.transcoding.yml&lt;/code&gt; to your compose command and selecting the matching API in the settings.&lt;/p&gt;

&lt;p&gt;The trap is that the original is always kept. Transcodes are additional files in &lt;code&gt;encoded-video/&lt;/code&gt;, never replacements, so an aggressive policy costs storage on top of your originals rather than saving any.&lt;/p&gt;

&lt;p&gt;If your video is mostly modern phone footage, switching to "videos not in an accepted format" before the first import can remove most of the transcoding queue entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where should you actually run Immich: NAS, mini PC, home server or hosted?
&lt;/h2&gt;

&lt;p&gt;The hardware question is really a question about video and about who fixes it at 11 p.m. when the phone app stops backing up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A consumer NAS, Synology or QNAP class:&lt;/strong&gt; attractive because the disks are already there, but many models ship 2 GB or 4 GB of RAM shared with the NAS operating system, and their Celeron and ARM CPUs turn the transcoding queue into a multi day job. Check for a container station and free RAM before assuming it will work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mini PC, N100 or similar:&lt;/strong&gt; the sweet spot for a first Immich server. Four cores, upgradable RAM, low idle draw, and Intel Quick Sync on board, which is the single change that collapses video processing time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An old desktop or a home server:&lt;/strong&gt; the most CPU per euro you will ever get, and the right answer if you already own one. The costs are electricity, noise and the fact that your library now depends on a machine with no redundancy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A VPS or a managed personal server:&lt;/strong&gt; removes the hardware problem and the port forwarding problem, and moves the storage question into a monthly bill, since 200 GB of block storage is a different line item than a 4 TB disk you buy once. Yundera is a managed Personal Cloud Server, built on CasaOS, where apps are installed from an app store in one click rather than assembled from compose files by hand, and each app is reachable on a public HTTPS subdomain via NSL.SH mesh routing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you pick, the deciding number is free RAM, not disk. Disk you can add later. A 4 GB ceiling shared with three other containers is what actually stops a 20,000 item import.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which day-one settings force a full re-run if you change them later?
&lt;/h2&gt;

&lt;p&gt;Some Immich settings are free to change at any time. Five are not, and the difference is whether the change invalidates work already written to disk. On a 20,000 item library, each of these costs you the same hours you spent during the first import.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;What changing it later triggers&lt;/th&gt;
&lt;th&gt;Safe default for week one&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage template&lt;/td&gt;
&lt;td&gt;A Storage Migration job that moves every uploaded file on disk&lt;/td&gt;
&lt;td&gt;Set it before the first upload, then leave it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smart search model&lt;/td&gt;
&lt;td&gt;All existing embeddings discarded, full Smart Search queue re-run&lt;/td&gt;
&lt;td&gt;Pick the model you can afford to run, test on 200 assets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preview and thumbnail size&lt;/td&gt;
&lt;td&gt;Thumbnail Generation re-runs for every asset, old derivatives replaced&lt;/td&gt;
&lt;td&gt;Accept the default unless you view photos on a 4K display&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transcode policy or target resolution&lt;/td&gt;
&lt;td&gt;Every clip matching the new rule is re-encoded from the original&lt;/td&gt;
&lt;td&gt;Decide by looking at what codecs your clips actually use&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facial recognition model&lt;/td&gt;
&lt;td&gt;Face detection and recognition re-run, and people you named can need reassigning&lt;/td&gt;
&lt;td&gt;Enable it once and do not switch models casually&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two settings that look scary and are not: &lt;code&gt;UPLOAD_LOCATION&lt;/code&gt; can be moved if you move the directory and keep the structure intact, and job concurrency can be raised or lowered mid import with no penalty at all.&lt;/p&gt;

&lt;p&gt;The practical rule is simple. Anything that changes how a derived file is generated forces regeneration of every derived file. Anything that changes scheduling or naming of the running system does not.&lt;/p&gt;

&lt;p&gt;Write your five choices down before you import. Reviewing them takes 15 minutes. Discovering one was wrong in month two costs you another weekend of queued jobs.&lt;/p&gt;

</description>
      <category>immich</category>
      <category>selfhosted</category>
      <category>docker</category>
      <category>photography</category>
    </item>
    <item>
      <title>ConvertX Hardware Requirements: How Much CPU, RAM and Disk Before Conversions Start Failing</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 27 Aug 2026 07:06:17 +0000</pubDate>
      <link>https://dev.to/john_182319291/convertx-hardware-requirements-how-much-cpu-ram-and-disk-before-conversions-start-failing-5dal</link>
      <guid>https://dev.to/john_182319291/convertx-hardware-requirements-how-much-cpu-ram-and-disk-before-conversions-start-failing-5dal</guid>
      <description>&lt;p&gt;ConvertX will install and run on a 1 vCPU, 1 GB virtual machine, and it will convert a 2 MB PNG to WebP in a second. That box will also kill a 4K video conversion, a 300 MB PDF and a large EPUB rebuild, usually with no visible error at all. The honest hardware floor for general use is 2 vCPU and 4 GB of RAM with 3 times your largest file free on disk, and the floor for routine video work is 4 vCPU and 8 GB. If your real workload is long video transcodes or 500 MB office documents, and you have no appetite for reading container logs, a hosted converter will fail less than a small self-hosted instance.&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 occasional image converter, for example someone flattening HEIC holiday photos to JPEG once a month:&lt;/strong&gt; self-host ConvertX on the smallest tier you have, because single image jobs finish in seconds and memory pressure never builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The document and ebook person, for example a researcher pushing DOCX and EPUB through Pandoc and Calibre weekly:&lt;/strong&gt; self-host on 2 vCPU and 4 GB, because LibreOffice and Calibre are single threaded and want headroom rather than cores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The video converter, for example a parent re-encoding phone clips to H.264 for a TV:&lt;/strong&gt; budget 4 vCPU, 8 GB and a large scratch volume, or accept conversions measured in hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The privacy-driven user with sensitive files, for example someone converting bank statements and medical scans:&lt;/strong&gt; self-host even on modest hardware, because the failure mode is a slow job, not a document sitting on a third party server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shared household or small team instance, for example five people with one link:&lt;/strong&gt; treat concurrency as the real constraint, because two overlapping jobs on 4 GB will OOM before either one finishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reader with no sysadmin appetite at all, for example someone who cannot read &lt;code&gt;docker logs&lt;/code&gt; and does not want to:&lt;/strong&gt; use a hosted converter for anything over 100 MB, because silent failure with no diagnosis is worse than a public upload you understand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is this: ConvertX moves your files off other people's servers, but it also moves every timeout, memory limit and disk exhaustion onto a machine you now have to size correctly yourself.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;What happens inside ConvertX when you press Convert&lt;/li&gt;
&lt;li&gt;What are the real hardware floors for ConvertX, tier by tier&lt;/li&gt;
&lt;li&gt;The upload path: where large files die before ConvertX ever sees them&lt;/li&gt;
&lt;li&gt;How do you tell an OOM kill from a timeout in ConvertX&lt;/li&gt;
&lt;li&gt;Why does a ConvertX job finish with no output and no error&lt;/li&gt;
&lt;li&gt;Scratch disk: the hidden multiplier on every ConvertX conversion&lt;/li&gt;
&lt;li&gt;Concurrency in ConvertX: what breaks when two jobs overlap&lt;/li&gt;
&lt;li&gt;Video and FFmpeg in ConvertX: the workload that decides your tier&lt;/li&gt;
&lt;li&gt;Images and ImageMagick in ConvertX: pixel maths, memory limits and policy files&lt;/li&gt;
&lt;li&gt;Documents, ebooks and LibreOffice in ConvertX: slow, single threaded, quietly fragile&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What happens inside ConvertX when you press Convert
&lt;/h2&gt;

&lt;p&gt;ConvertX is not one converter. It is a Bun web application that receives your upload, writes it to disk, then shells out to whichever command line tool handles that format pair. Understanding that chain is the whole hardware question, because the web layer costs almost nothing and the child process costs everything.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The upload lands on disk first:&lt;/strong&gt; your file is written into the container's data volume before any conversion starts, so a 500 MB video occupies 500 MB before a single frame is encoded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A converter binary is selected by format pair:&lt;/strong&gt; FFmpeg for audio and video, ImageMagick and libvips for raster images, Inkscape and resvg for vectors, Calibre for ebooks, Pandoc and LibreOffice for documents, Assimp for 3D meshes. Each has its own memory behaviour and none of them know about the others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The job runs as a child process, not inside the web server:&lt;/strong&gt; ConvertX waits for that process to exit. If the kernel kills it, ConvertX sees a dead process, not an explanation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output is written beside the input:&lt;/strong&gt; peak disk usage is input plus output plus whatever temporary files the tool creates, which for LibreOffice and Calibre is substantial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results persist until cleanup runs:&lt;/strong&gt; &lt;code&gt;AUTO_DELETE_EVERY_N_HOURS&lt;/code&gt; defaults to 24, so a day of conversions accumulates on the volume before anything is reclaimed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical consequence: ConvertX itself idles at a few hundred megabytes. Your 8 GB requirement is not ConvertX, it is FFmpeg encoding 4K, or ImageMagick decompressing a 200 megapixel TIFF into an uncompressed pixel buffer. Size the box for the worst binary you will ever invoke, not for the web interface.&lt;/p&gt;




&lt;h2&gt;
  
  
  What are the real hardware floors for ConvertX, tier by tier
&lt;/h2&gt;

&lt;p&gt;Pick your tier from the heaviest format you actually convert, not from the average one. The table below maps common hardware to what genuinely completes and what starts failing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Completes reliably&lt;/th&gt;
&lt;th&gt;Starts failing at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 vCPU, 1 GB RAM&lt;/td&gt;
&lt;td&gt;Single images under 20 megapixels, PDF to text, small Markdown and DOCX via Pandoc, audio transcodes&lt;/td&gt;
&lt;td&gt;Any video over roughly 2 minutes, LibreOffice on large spreadsheets, two concurrent jobs of any kind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 vCPU, 4 GB RAM&lt;/td&gt;
&lt;td&gt;Batches of 20 photos, EPUB and MOBI via Calibre, 1080p clips under 5 minutes, most office documents&lt;/td&gt;
&lt;td&gt;4K video, TIFF files over 100 megapixels, three or more overlapping users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 vCPU, 8 GB RAM&lt;/td&gt;
&lt;td&gt;1080p video at usable speed, 4K short clips, large PDF rasterisation, 2 to 3 concurrent jobs&lt;/td&gt;
&lt;td&gt;Long 4K transcodes measured in hours, batch video, sustained multi user load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 vCPU, 16 GB RAM&lt;/td&gt;
&lt;td&gt;Feature length video, large batch image work, several simultaneous users&lt;/td&gt;
&lt;td&gt;Little in practice, the constraint becomes disk throughput and patience&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Disk matters as much as RAM. Reserve at least 3 times the size of your largest file, and remember that with &lt;code&gt;AUTO_DELETE_EVERY_N_HOURS&lt;/code&gt; at its default of 24, a day of jobs stays resident.&lt;/p&gt;

&lt;p&gt;Where that hardware lives is a separate decision. A self managed VPS, an always on home server, a NAS running Docker and a managed option all reach the same floors. 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 choose, the tier table above is what decides whether a conversion finishes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The upload path: where large files die before ConvertX ever sees them
&lt;/h2&gt;

&lt;p&gt;Half of the "ConvertX is broken" reports are not ConvertX at all. The file never reached the container. Every layer between your browser and port 3000 has its own size ceiling, and most of them reject silently or with a generic 413.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nginx caps uploads at 1 MB by default:&lt;/strong&gt; &lt;code&gt;client_max_body_size&lt;/code&gt; starts at &lt;code&gt;1m&lt;/code&gt;. Until you raise it, anything larger returns 413 Request Entity Too Large and ConvertX logs nothing, because no request arrived. Set it to a value above your largest file, or to &lt;code&gt;0&lt;/code&gt; to disable the check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare's free proxy caps request bodies at 100 MB:&lt;/strong&gt; if your domain is proxied through Cloudflare on a free plan, a 400 MB video cannot reach your server regardless of your own configuration. Grey clouding the record or using a direct hostname is the only fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy read timeouts kill slow uploads:&lt;/strong&gt; nginx defaults &lt;code&gt;proxy_read_timeout&lt;/code&gt; to 60 seconds. A large file over a slow home uplink can exceed that before the body finishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your upstream bandwidth is the real limit at home:&lt;/strong&gt; a 10 Mbit upload link moves roughly 1.25 MB per second, so a 500 MB file takes over 6 minutes of held connection before conversion even begins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caddy and Traefik behave differently:&lt;/strong&gt; Caddy applies no default body limit, so people migrating from nginx often see the ceiling vanish without understanding why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where you run the instance changes which of these you have to configure. A self managed VPS means editing the proxy yourself, a NAS exposes its own reverse proxy settings, and on Yundera each app is reachable on a public HTTPS subdomain via NSL.SH mesh routing. Test with a file at your true maximum size before trusting any of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you tell an OOM kill from a timeout in ConvertX
&lt;/h2&gt;

&lt;p&gt;These two produce almost identical symptoms in the browser: a job that never completes. They need opposite fixes. More RAM does nothing for a timeout, and a longer timeout does nothing for an OOM kill. Diagnose before you resize.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Points to an OOM kill&lt;/th&gt;
&lt;th&gt;Points to a timeout&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exit status in &lt;code&gt;docker logs convertx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Process terminated by signal 9, container exit code 137&lt;/td&gt;
&lt;td&gt;Process still running, or killed cleanly after a fixed interval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker inspect --format '{{.State.OOMKilled}}' convertx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;true&lt;/code&gt; after the container itself dies&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;false&lt;/code&gt;, the container is healthy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host kernel log via &lt;code&gt;dmesg -T&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Contains "Out of memory: Killed process" naming ffmpeg, soffice or convert&lt;/td&gt;
&lt;td&gt;Contains nothing at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser behaviour&lt;/td&gt;
&lt;td&gt;Connection stays open, then the job vanishes from progress&lt;/td&gt;
&lt;td&gt;504 Gateway Timeout from the reverse proxy while the job continues server side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;docker stats&lt;/code&gt; during the job&lt;/td&gt;
&lt;td&gt;Memory climbs to the limit then the row disappears&lt;/td&gt;
&lt;td&gt;Memory sits flat, CPU pegged at 100 percent on one core&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern worth memorising: an OOM kill is sudden and leaves kernel evidence, a timeout is patient and leaves the converter alive. A frequent surprise is a job that hits both, where FFmpeg keeps encoding for 40 minutes after the proxy already returned 504, so the output file appears later with no matching entry in your browser.&lt;/p&gt;

&lt;p&gt;Add swap before you upgrade the tier. On a 4 GB box, 2 GB of swap turns some ImageMagick kills into slow but successful conversions. It will not save a 4K transcode, and a job that swaps heavily for 20 minutes is usually a signal that the tier is wrong rather than the configuration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why does a ConvertX job finish with no output and no error
&lt;/h2&gt;

&lt;p&gt;This is the failure mode that drives people back to hosted converters. The job appears in the history, the status does not scream at you, and the download is either missing or a 0 byte file. ConvertX only knows what the child process told it, and command line converters lie about success constantly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The converter exited 0 but wrote nothing:&lt;/strong&gt; LibreOffice is the worst offender here. It returns success after refusing to convert a document it could not parse, so ConvertX records a finished job pointing at an empty file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stale LibreOffice profile lock:&lt;/strong&gt; if a previous conversion was killed mid run, the leftover profile directory blocks the next invocation. The fix is restarting the container, which is why the same document fails five times then works after &lt;code&gt;docker restart convertx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing fonts or unsupported subfeatures:&lt;/strong&gt; a DOCX with an embedded font, or a PDF using a CJK typeface the container lacks, converts to a page of blank boxes rather than an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output was cleaned up before you fetched it:&lt;/strong&gt; with &lt;code&gt;AUTO_DELETE_EVERY_N_HOURS&lt;/code&gt; at 24, a job you started last night and downloaded this evening can be gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk filled mid write:&lt;/strong&gt; the converter writes a partial file, then fails on the final flush. You get a truncated result and a job marked done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your only reliable diagnostic is &lt;code&gt;docker logs convertx&lt;/code&gt;, ideally with &lt;code&gt;--tail 200&lt;/code&gt; right after the failure. If reading container logs to work out why a PDF came back blank sounds like a bad evening, that is a legitimate reason to keep a hosted converter for anything important.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scratch disk: the hidden multiplier on every ConvertX conversion
&lt;/h2&gt;

&lt;p&gt;RAM gets the attention, disk causes the outages. ConvertX needs room for the input, the output and whatever intermediate the converter invents along the way, all at the same moment. That peak is routinely 5 to 10 times the size of the file you uploaded.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rasterising a PDF is arithmetic, not magic:&lt;/strong&gt; one A4 page at 300 DPI in RGB is about 3508 by 2480 pixels, roughly 26 MB uncompressed. A 100 page document becomes around 2.6 GB of intermediate data before anything is written as PNG or JPEG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uncompressed video intermediates are enormous:&lt;/strong&gt; a single 1080p RGB frame is 1920 by 1080 by 3 bytes, about 6.2 MB. At 25 frames per second that is 155 MB of scratch per second of footage if a lossless intermediate is produced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calibre and LibreOffice unpack before they convert:&lt;/strong&gt; an EPUB or DOCX is a zip archive, and the expanded working directory plus embedded images can dwarf the original file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything accumulates in the same volume:&lt;/strong&gt; uploads, outputs and &lt;code&gt;mydb.sqlite&lt;/code&gt; share &lt;code&gt;/app/data&lt;/code&gt;. If you did not mount a named volume, all of it lands on the container layer and fills the host root partition instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention multiplies the total:&lt;/strong&gt; at the default 24 hour cleanup, a day of 20 conversions averaging 200 MB in and 150 MB out holds roughly 7 GB before anything is reclaimed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check with &lt;code&gt;df -h&lt;/code&gt; on the host and &lt;code&gt;docker system df&lt;/code&gt; for volume usage, not by guessing from your upload sizes. A full disk produces truncated outputs rather than clean errors, so a 20 GB volume on a video oriented instance is a floor, not a comfortable allocation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Concurrency in ConvertX: what breaks when two jobs overlap
&lt;/h2&gt;

&lt;p&gt;ConvertX has no job queue that serialises heavy work. A batch upload of 12 files, or two people clicking Convert at the same moment, means multiple converter processes competing for the same cores and the same memory ceiling. This is the single most common reason a box that felt adequate suddenly fails.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FFmpeg claims every core by default:&lt;/strong&gt; with no explicit &lt;code&gt;-threads&lt;/code&gt; value it scales to all available CPUs. On 4 vCPU, two simultaneous encodes request 8 threads across 4 cores, so both run at roughly half speed plus context switching overhead, and neither finishes when you expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ImageMagick multiplies memory, not just time:&lt;/strong&gt; each process holds its own pixel buffer. Two 100 megapixel TIFF conversions on a 4 GB box are not one job twice, they are two independent allocations racing the same limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LibreOffice does not like company:&lt;/strong&gt; two &lt;code&gt;soffice&lt;/code&gt; invocations contending for the same user profile directory produce one success and one job that returns nothing usable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite serialises writes:&lt;/strong&gt; &lt;code&gt;mydb.sqlite&lt;/code&gt; handles job records fine at household scale, but it is a single writer database, not something to plan a 20 user instance around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The web interface stays responsive while everything else starves:&lt;/strong&gt; Bun keeps serving pages, so the instance looks healthy while conversions crawl. Health checks tell you nothing here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical control is Docker resource limits. Setting &lt;code&gt;cpus: "3.0"&lt;/code&gt; and &lt;code&gt;mem_limit: 6g&lt;/code&gt; on a 4 vCPU, 8 GB host leaves the operating system breathing room and makes the failure predictable rather than random. If your instance serves more than three people who convert video, plan for one job at a time and tell them so.&lt;/p&gt;




&lt;h2&gt;
  
  
  Video and FFmpeg in ConvertX: the workload that decides your tier
&lt;/h2&gt;

&lt;p&gt;If you never convert video, ignore most of the hardware advice in this article and run ConvertX on whatever you have. If you do, video alone sets your tier, because encoding is the only workload here that saturates every core for a sustained period.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source material&lt;/th&gt;
&lt;th&gt;On 2 vCPU, 4 GB&lt;/th&gt;
&lt;th&gt;On 4 vCPU, 8 GB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Audio only, MP3 to FLAC or WAV&lt;/td&gt;
&lt;td&gt;Completes in seconds, no real load&lt;/td&gt;
&lt;td&gt;Identical, the tier is irrelevant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1080p H.264 clip under 5 minutes&lt;/td&gt;
&lt;td&gt;Completes, slower than realtime, tolerable&lt;/td&gt;
&lt;td&gt;Completes comfortably, cores are the binding constraint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1080p H.264 to H.265&lt;/td&gt;
&lt;td&gt;Encoding runs far slower than the equivalent x264 job, expect to leave it running&lt;/td&gt;
&lt;td&gt;Usable, still the slowest common conversion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4K to 1080p, 10 minutes or longer&lt;/td&gt;
&lt;td&gt;Frequently exceeds proxy patience and sometimes memory&lt;/td&gt;
&lt;td&gt;Completes, but plan for an unattended run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature length or batch video&lt;/td&gt;
&lt;td&gt;Not a realistic workload&lt;/td&gt;
&lt;td&gt;Marginal, this is where you want 8 vCPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three facts explain the whole table. A 4K frame carries 8.3 million pixels against 2.1 million at 1080p, so the same encoder does roughly four times the work per frame. The x264 preset ladder from &lt;code&gt;ultrafast&lt;/code&gt; to &lt;code&gt;veryslow&lt;/code&gt; spans an enormous speed range at similar quality targets, and ConvertX exposes this through the &lt;code&gt;FFMPEG_ARGS&lt;/code&gt; environment variable, so setting &lt;code&gt;-preset veryfast&lt;/code&gt; is the cheapest tier upgrade available. Finally, the container has no GPU acceleration unless you explicitly pass a device through, so everything runs on the CPU.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;FFMPEG_ARGS&lt;/code&gt; before you buy more vCPU. A preset change costs nothing and often moves a job from abandoned to finished.&lt;/p&gt;




&lt;h2&gt;
  
  
  Images and ImageMagick in ConvertX: pixel maths, memory limits and policy files
&lt;/h2&gt;

&lt;p&gt;Image conversion looks harmless because the files are small. The decoded buffer is what matters, and that number has nothing to do with the size on disk. A 12 MB JPEG and a 12 MB PNG can consume wildly different amounts of RAM.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Work out the buffer, not the file size:&lt;/strong&gt; ImageMagick is normally built at Q16, meaning 2 bytes per channel. RGBA at 16 bits is 8 bytes per pixel, so a 100 megapixel scan needs roughly 800 MB of memory before any processing begins. That alone explains most failures on a 1 GB box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pixel cache silently falls back to disk:&lt;/strong&gt; when the memory limit is reached, ImageMagick starts using a disk backed cache rather than failing. The job still completes, hundreds of times slower, which is why one photo occasionally takes 20 minutes while its neighbours take 2 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;policy.xml&lt;/code&gt; can refuse the conversion outright:&lt;/strong&gt; Debian based images ship with the PDF, PS and EPS coders disabled. The giveaway in the logs is "attempt to perform an operation not allowed by the security policy". That is a policy decision, not a hardware problem, and adding RAM will never fix it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same file has a cheap path and an expensive one:&lt;/strong&gt; ConvertX also bundles libvips, which streams in tiles rather than loading the full image. Where both can handle a format pair, the vips route uses a fraction of the memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batches compound the buffer, not the file count:&lt;/strong&gt; 20 photos at 24 megapixels is not 20 small jobs, it is repeated 400 MB allocations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check a suspicious file with &lt;code&gt;identify -verbose&lt;/code&gt; before blaming the tier.&lt;/p&gt;




&lt;h2&gt;
  
  
  Documents, ebooks and LibreOffice in ConvertX: slow, single threaded, quietly fragile
&lt;/h2&gt;

&lt;p&gt;Document conversion is the workload people assume is trivial and then cannot explain. It rarely triggers an OOM kill on 4 GB. It just takes far longer than the file size suggests, and it does not get faster when you add cores.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Which binary handles the job decides everything:&lt;/strong&gt; Pandoc converting DOCX to Markdown or HTML is a text transformation that finishes almost immediately on any tier. The same DOCX to PDF goes through LibreOffice or XeLaTeX, which is a completely different order of cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LibreOffice pays a cold start on every single job:&lt;/strong&gt; each conversion launches &lt;code&gt;soffice&lt;/code&gt; in headless mode, which means loading an entire office suite before the first page is laid out. That fixed overhead dominates on small documents, so a 40 KB letter and a 4 MB report can take a similar time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layout is single threaded:&lt;/strong&gt; repagination, table reflow and font metrics run on one core. A 4 vCPU box converts a large spreadsheet no faster than a 1 vCPU box, which is why upgrading the tier for document work is usually wasted money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LaTeX routes run multiple passes:&lt;/strong&gt; XeLaTeX processes a document two or three times to resolve the table of contents and cross references, so the wall clock is a multiple of one pass, not one pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calibre scales with embedded images, not page count:&lt;/strong&gt; a 600 page text only EPUB converts easily, while a 90 page illustrated PDF to EPUB decodes every image and needs far more headroom.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The useful move is choosing your target format deliberately. If HTML or Markdown will do, you avoid LibreOffice and LaTeX entirely and turn a minutes long job into a seconds long one.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>ffmpeg</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Your Self-Hosted Stirling-PDF Container OOMs, Hangs or Returns a Broken PDF: 15 Anti-Patterns and Their Fixes</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 25 Aug 2026 07:06:47 +0000</pubDate>
      <link>https://dev.to/john_182319291/why-your-self-hosted-stirling-pdf-container-ooms-hangs-or-returns-a-broken-pdf-15-anti-patterns-4db2</link>
      <guid>https://dev.to/john_182319291/why-your-self-hosted-stirling-pdf-container-ooms-hangs-or-returns-a-broken-pdf-15-anti-patterns-4db2</guid>
      <description>&lt;p&gt;Stirling-PDF is not an unstable application. Almost every crash, hang and quietly mangled output traces back to one of fifteen decisions you made outside the app: an unbounded container, the wrong image variant, a reverse proxy that gives up after 60 seconds, or an OCR call with no language pack behind it. The Java process inside the container claims a default heap of 25 percent of visible RAM, office conversions serialise through a single background LibreOffice process, and every job writes scratch files to disk before it returns a single byte. Correct those four assumptions and most of the remaining catalogue stops happening.&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 weekend homelab tinkerer (one N100 mini PC, roughly ten PDFs a week, merge and split only):&lt;/strong&gt; run the ultra-lite image with a hard 1 GB container limit, because you never touch the two subsystems that consume memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scan hoarder (a decade of paper, 40,000 pages, OCR every one of them):&lt;/strong&gt; run the full image, mount a real tessdata volume with your languages, and process in batches of 50 pages or fewer, because OCR cost scales with page count and DPI, not file count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The automation builder (an n8n or cron job calling the Stirling-PDF API unattended):&lt;/strong&gt; add your own queue and set explicit timeouts on both sides, because the app accepts every concurrent request you throw at it and holds each one in memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The small team sharing one instance (four people behind a VPN, mixed office conversions):&lt;/strong&gt; budget for LibreOffice serialisation and raise proxy timeouts before you raise CPU, because your bottleneck is a single conversion process, not cores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The NAS owner (a box already running twenty other containers):&lt;/strong&gt; set the memory limit and the temp volume first, because an unbounded Stirling-PDF job is the container most likely to evict your other services.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is headroom against completion: every limit that stops Stirling-PDF from taking down its host also stops some legitimate large job from ever finishing.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;What is actually failing when Stirling-PDF dies, and how do you confirm it in two minutes?&lt;/li&gt;
&lt;li&gt;Anti-pattern 1 and 2: no container memory limit, and no JVM heap ceiling inside it&lt;/li&gt;
&lt;li&gt;Anti-pattern 3 and 4: running the wrong image variant, then calling a tool it does not contain&lt;/li&gt;
&lt;li&gt;Where does Stirling-PDF write its temporary files, and why does that fill your disk?&lt;/li&gt;
&lt;li&gt;Why does OCR hang, fail outright, or return a file with no selectable text?&lt;/li&gt;
&lt;li&gt;Anti-pattern 9 and 10: treating office conversion as fast, parallel and font-independent&lt;/li&gt;
&lt;li&gt;Why does a large upload fail before Stirling-PDF ever sees the file?&lt;/li&gt;
&lt;li&gt;Anti-pattern 12: reverse proxy timeouts shorter than the job you just started&lt;/li&gt;
&lt;li&gt;Anti-pattern 13: compression and repair settings that return a valid file with wrong content&lt;/li&gt;
&lt;li&gt;Anti-pattern 14: volume permissions, custom fonts and language packs mounted incorrectly&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What is actually failing when Stirling-PDF dies, and how do you confirm it in two minutes?
&lt;/h2&gt;

&lt;p&gt;Three different failures get reported as "Stirling-PDF crashed", and they need opposite fixes. Run &lt;code&gt;docker inspect stirling-pdf --format '{{.State.ExitCode}}'&lt;/code&gt; and &lt;code&gt;docker logs --tail 200 stirling-pdf&lt;/code&gt; before you change anything. The exit code alone separates two of the three cases.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exit code 137, container killed by the host:&lt;/strong&gt; the kernel OOM killer reclaimed the process because the container exceeded its cgroup memory limit, or the host ran out of RAM entirely. The application log ends mid-sentence with no stack trace, which is the giveaway. Confirm it with &lt;code&gt;dmesg -T | grep -i oom&lt;/code&gt; on the host or &lt;code&gt;docker inspect&lt;/code&gt; reporting &lt;code&gt;OOMKilled: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;java.lang.OutOfMemoryError: Java heap space&lt;/code&gt; stack trace, container still running:&lt;/strong&gt; the JVM hit its own heap ceiling while the container still had free memory. The web UI usually stays reachable and only that one job fails. This is the fix nobody applies, because the container looks healthy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No error at all, the request just never returns:&lt;/strong&gt; the job is running, but something between your browser and the app gave up first. A 504 after roughly 60 seconds points at the reverse proxy, not at Stirling-PDF.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP 200 with a file that opens but is wrong:&lt;/strong&gt; text vanished, fonts substituted, images blurred to unreadable. Nothing in the log marks this as an error, because from the application's point of view it succeeded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first two are memory problems, the third is a timeout problem, and the fourth is a settings problem. Diagnosing the wrong one costs you a weekend.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anti-pattern 1 and 2: no container memory limit, and no JVM heap ceiling inside it
&lt;/h2&gt;

&lt;p&gt;Docker applies no memory limit by default. A container-aware JVM with no &lt;code&gt;-Xmx&lt;/code&gt; claims a maximum heap of 25 percent of the memory it can see, which is the host's total RAM when you set no limit. Both defaults are wrong for this workload, and they fail in opposite directions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;What the JVM sees&lt;/th&gt;
&lt;th&gt;What breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;mem_limit&lt;/code&gt;, no heap setting&lt;/td&gt;
&lt;td&gt;25 percent of host RAM as max heap, on 8 GB that is 2 GB&lt;/td&gt;
&lt;td&gt;The host runs out of RAM before the container does, and your other services get evicted first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mem_limit: 2g&lt;/code&gt;, no heap setting&lt;/td&gt;
&lt;td&gt;512 MB max heap, calculated from the limit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;OutOfMemoryError&lt;/code&gt; on a job the machine could easily have handled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mem_limit: 2g&lt;/code&gt;, heap raised to 1.5 GB&lt;/td&gt;
&lt;td&gt;1.5 GB heap, 512 MB left for everything else&lt;/td&gt;
&lt;td&gt;Exit 137 the moment a native helper process starts, because Ghostscript and Tesseract live outside the heap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mem_limit: 4g&lt;/code&gt;, heap capped at 2 GB&lt;/td&gt;
&lt;td&gt;2 GB heap, 2 GB for native processes and page cache&lt;/td&gt;
&lt;td&gt;Nothing, for a single-user instance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The subtlety that catches people out: capping the Java heap does not cap the container. LibreOffice, Ghostscript, Tesseract and qpdf are separate native processes. Their memory counts against the cgroup limit but never against &lt;code&gt;-Xmx&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Set both, and leave the native processes at least as much room as the heap. Add &lt;code&gt;JAVA_TOOL_OPTIONS=-XX:MaxRAMPercentage=50&lt;/code&gt; rather than a fixed &lt;code&gt;-Xmx&lt;/code&gt;, so the ratio survives a change to the container limit. Then watch &lt;code&gt;docker stats stirling-pdf&lt;/code&gt; during your largest real job instead of guessing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anti-pattern 3 and 4: running the wrong image variant, then calling a tool it does not contain
&lt;/h2&gt;

&lt;p&gt;Stirling-PDF ships as more than one image, and they are not interchangeable. Pulling &lt;code&gt;latest&lt;/code&gt; because it sounds current, or &lt;code&gt;ultra-lite&lt;/code&gt; because it sounds efficient, decides which of the 50 plus tools actually work at runtime. The UI still shows every button either way, which is why this fails so confusingly.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stirlingtools/stirling-pdf:latest-ultra-lite&lt;/code&gt;:&lt;/strong&gt; core PDF manipulation only, so merge, split, rotate, reorder and metadata edits. No OCR, no LibreOffice, no Python. It is the right choice if you genuinely never convert or OCR, and the wrong choice the first time you try.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stirlingtools/stirling-pdf:latest&lt;/code&gt;:&lt;/strong&gt; the standard build, with OCR and office conversion included. This is the default answer for most self-hosters, and the variant the rest of this article assumes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stirlingtools/stirling-pdf:latest-fat&lt;/code&gt;:&lt;/strong&gt; everything preinstalled, including the ebook and advanced HTML tooling that the standard image otherwise fetches at container start when you set &lt;code&gt;INSTALL_BOOK_AND_ADVANCED_HTML_OPS=true&lt;/code&gt;. It trades disk for a container that starts ready and needs no outbound network on boot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tool you call but did not install:&lt;/strong&gt; the request reaches the backend, the underlying binary is absent, and you get a generic failure rather than "this image cannot do that". Confirm with &lt;code&gt;docker exec stirling-pdf which soffice tesseract&lt;/code&gt; before blaming your configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where you run it changes how much this bites. On a self-managed VPS, a home server or a NAS you pick the tag yourself in a compose file. 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 the variant arrives already chosen by the packaged app. Either way, verify which binaries exist before you design a workflow around them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where does Stirling-PDF write its temporary files, and why does that fill your disk?
&lt;/h2&gt;

&lt;p&gt;Every non-trivial operation is a file-on-disk pipeline, not an in-memory transform. The container writes scratch files under &lt;code&gt;/tmp/stirling-pdf&lt;/code&gt;, hands them to Ghostscript, Tesseract or LibreOffice, and only then streams a result back to your browser. If you never mounted anything at that path, all of it lands on the container's writable overlay layer, on the same filesystem as your Docker root.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The multiplication nobody budgets for:&lt;/strong&gt; an OCR pass rasterises every page, writes the image set, writes an intermediate PDF, then writes the output. Peak disk use is several times the input size, and it happens before you see any progress at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failed jobs do not always clean up:&lt;/strong&gt; a container killed at exit 137, or a request abandoned when you closed the tab, leaves its scratch files behind. Check with &lt;code&gt;docker exec stirling-pdf du -sh /tmp/stirling-pdf&lt;/code&gt; after a week of real use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The overlay layer is the worst possible target:&lt;/strong&gt; it is slow, it counts against your Docker storage pool, and it disappears on &lt;code&gt;docker compose down&lt;/code&gt;, taking any recoverable partial output with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two fixes, different tradeoffs:&lt;/strong&gt; mount a tmpfs sized at 1 GB or 2 GB for speed, accepting that a job larger than the tmpfs fails outright, or bind mount a real directory for capacity, accepting slower rasterisation on spinning disks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup is configurable:&lt;/strong&gt; the temp file management block in &lt;code&gt;/configs/settings.yml&lt;/code&gt; controls the cleanup interval, the maximum age of scratch files and whether a sweep runs at startup. Set it once rather than adding a cron job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters more on constrained storage than on a roomy VPS, whether that is a NAS volume, a home server SSD or a Yundera instance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why does OCR hang, fail outright, or return a file with no selectable text?
&lt;/h2&gt;

&lt;p&gt;OCR is the most expensive thing this application does, and it is driven by option combinations most people pick at random. Three failures dominate: no language data present, the wrong OCR mode for the input, and a page count the container cannot chew through before something upstream gives up.&lt;/p&gt;

&lt;p&gt;Language data comes first. Run &lt;code&gt;docker exec stirling-pdf tesseract --list-langs&lt;/code&gt;. If your language is missing, OCR fails or produces nonsense, and mounting a volume at &lt;code&gt;/usr/share/tessdata&lt;/code&gt; with the traineddata files you need is the fix, not a setting change.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;OCR option&lt;/th&gt;
&lt;th&gt;What it actually does&lt;/th&gt;
&lt;th&gt;Where it bites&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Force OCR&lt;/td&gt;
&lt;td&gt;Rasterises every page, then layers recognised text over the image&lt;/td&gt;
&lt;td&gt;Destroys existing vector text and searchable layers, and inflates output size on documents that were already digital&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skip text&lt;/td&gt;
&lt;td&gt;Leaves pages that already contain a text layer untouched&lt;/td&gt;
&lt;td&gt;Returns a file with no new selectable text on mixed documents, which reads as a silent failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean and deskew preprocessing&lt;/td&gt;
&lt;td&gt;Runs image correction before recognition&lt;/td&gt;
&lt;td&gt;Adds a full extra image pass per page, so processing time rises on exactly the large scans you were already struggling with&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sidecar output&lt;/td&gt;
&lt;td&gt;Writes recognised text to a separate file alongside the PDF&lt;/td&gt;
&lt;td&gt;Doubles the scratch files per job, which matters if your temp mount is a small tmpfs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cost scales with pages and resolution, not with file count. A 400 page scan at 600 DPI is a fundamentally different job from 40 pages at 200 DPI, even at similar file sizes. Batch large documents into chunks of 50 pages or fewer, run them one at a time, and treat any OCR job over a few minutes as something that needs a raised timeout rather than a retry.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anti-pattern 9 and 10: treating office conversion as fast, parallel and font-independent
&lt;/h2&gt;

&lt;p&gt;Converting DOCX, XLSX or PPTX to PDF does not happen in Java. Stirling-PDF hands the file to a headless LibreOffice process inside the container. That single detail explains both of this section's anti-patterns.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first conversion after a container start is the slow one:&lt;/strong&gt; the headless &lt;code&gt;soffice&lt;/code&gt; process has to initialise before it can do any work. Measure your second conversion, not your first, or you will size the machine against a number you will never see again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversions serialise, so more cores do not help:&lt;/strong&gt; requests queue behind one conversion backend. Two users submitting large presentations at the same time do not each get half the speed, the second one waits. If office conversion is your bottleneck, raise your reverse proxy timeout before you add vCPUs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A wedged &lt;code&gt;soffice&lt;/code&gt; process blocks everything behind it:&lt;/strong&gt; one malformed document can leave the backend stuck, and every later conversion times out while the rest of the application stays perfectly healthy. Check with &lt;code&gt;docker exec stirling-pdf ps aux | grep soffice&lt;/code&gt;, and restart the container rather than debugging the document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing fonts corrupt layout silently:&lt;/strong&gt; the container ships a limited font set. A document written in Calibri or Cambria gets substituted glyphs, so line breaks move, tables overflow and page counts change. The output is a valid PDF that does not match the original, and nothing in the log calls this an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix fonts by mounting them, not by hoping:&lt;/strong&gt; bind mount your TTF files into a directory under &lt;code&gt;/usr/share/fonts&lt;/code&gt;, then confirm with &lt;code&gt;docker exec stirling-pdf fc-list | wc -l&lt;/code&gt;. Carlito and Caladea are the metric-compatible stand-ins for Calibri and Cambria, and Liberation Sans covers Arial.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why does a large upload fail before Stirling-PDF ever sees the file?
&lt;/h2&gt;

&lt;p&gt;A 413 error, or a progress bar that reaches 100 percent and then dies, is almost never the application. Every layer between your browser and the container can refuse a body, and each one has a different default. The container logs stay empty, which is the clue: if Stirling-PDF had rejected the file, it would have said so.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Default body limit&lt;/th&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nginx or nginx proxy manager&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;client_max_body_size&lt;/code&gt; defaults to 1 MB&lt;/td&gt;
&lt;td&gt;HTTP 413 within seconds, no entry in the Stirling-PDF log at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caddy or Traefik&lt;/td&gt;
&lt;td&gt;No request body limit by default&lt;/td&gt;
&lt;td&gt;Nothing, these two are rarely the culprit unless you added a buffering middleware yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apache httpd&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LimitRequestBody&lt;/code&gt; defaults to unlimited&lt;/td&gt;
&lt;td&gt;Nothing, unless a distribution config or a hardening guide set it for you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare proxied hostname&lt;/td&gt;
&lt;td&gt;100 MB per request on the free plan&lt;/td&gt;
&lt;td&gt;HTTP 413 from Cloudflare's edge, with a Cloudflare branded error page rather than your own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stirling-PDF itself&lt;/td&gt;
&lt;td&gt;A multipart upload limit in &lt;code&gt;/configs/settings.yml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A clean application-level rejection that does appear in the log&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Diagnose it by bypassing the chain. Send the same file directly to the container port with &lt;code&gt;curl -F "fileInput=@big.pdf" http://localhost:8080/...&lt;/code&gt; from the host. If that works and the browser does not, the problem is in front of the app, and no amount of container tuning will fix it.&lt;/p&gt;

&lt;p&gt;Raise the limits in order, from the outermost layer inward, and raise them to the same value. A proxy that accepts 500 MB in front of an app that accepts 50 MB just moves the failure one hop later, after the user has already spent the upload time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anti-pattern 12: reverse proxy timeouts shorter than the job you just started
&lt;/h2&gt;

&lt;p&gt;A 504 after roughly a minute, on a job you know takes longer, is a proxy default and nothing else. The container never stopped working. It is still rasterising pages while your browser shows an error page.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;nginx gives up after 60 seconds:&lt;/strong&gt; &lt;code&gt;proxy_read_timeout&lt;/code&gt; and &lt;code&gt;proxy_send_timeout&lt;/code&gt; both default to 60s, which is shorter than almost any real OCR or large office conversion. Raise them to 300s or 600s in the location block for Stirling-PDF, not globally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare cuts the origin off at 100 seconds:&lt;/strong&gt; a proxied hostname on the free plan returns error 524 when the origin has not responded in time, and you cannot raise that on the free plan. Long jobs behind an orange cloud need a different path to the app, such as a VPN or a direct hostname.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caddy and Traefik do not impose a short default here:&lt;/strong&gt; if you use either and still see a timeout, look at the client, at Cloudflare, or at an explicit middleware you configured yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The retry is what actually kills the container:&lt;/strong&gt; the abandoned job keeps running and keeps its memory. Clicking the button again starts a second copy of the same work, so peak memory doubles, and the container that was surviving one job gets killed at exit 137 running two. This is why "it broke worse when I tried again" is such a common report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A timeout is not a substitute for batching:&lt;/strong&gt; raising the ceiling to 600s makes a long job possible, but a job that needs 600 seconds should be split. Confirm the real duration by timing it against the container port directly, with the proxy out of the picture, before you decide which number to set.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Anti-pattern 13: compression and repair settings that return a valid file with wrong content
&lt;/h2&gt;

&lt;p&gt;This is the failure class with no error message. The request returns 200, the PDF opens, and the damage only surfaces weeks later when someone needs the original. Compression, flattening, repair and sanitisation all rewrite the document structure, and several of them discard data by design.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Aggressive compression downsamples images, permanently:&lt;/strong&gt; the optimisation levels run from light restructuring to heavy image resampling. On a scanned invoice, a high level can drop resolution below the point where the text is legible, and there is no undo. Test one representative document at each level before you apply anything to a batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Targeting an output size lets the tool decide how much to destroy:&lt;/strong&gt; asking for a specific final size hands the algorithm permission to resample as far as it needs to. Set a level you have tested instead of a size you hope for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flattening converts form fields into static content:&lt;/strong&gt; an interactive form becomes a picture of a form. Entered data is preserved visually, but the fields are gone, and any workflow that reads field values downstream stops working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repair rewrites the file structure and can drop what it does not understand:&lt;/strong&gt; bookmarks, annotations, attachments and tagging are the usual casualties. Repair is for files that genuinely fail to open, not a routine step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any rewrite invalidates a digital signature:&lt;/strong&gt; compress, repair, flatten, sanitise or even a metadata edit breaks the cryptographic seal. If a document is signed, it leaves the pipeline untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify instead of trusting the 200:&lt;/strong&gt; compare page counts and extracted text between input and output with &lt;code&gt;pdfinfo&lt;/code&gt; and &lt;code&gt;pdftotext&lt;/code&gt; from poppler-utils, and run &lt;code&gt;qpdf --check output.pdf&lt;/code&gt;. Keep the original file until that comparison passes.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Anti-pattern 14: volume permissions, custom fonts and language packs mounted incorrectly
&lt;/h2&gt;

&lt;p&gt;Two mount mistakes account for most of the "I followed the guide and it still does not work" reports. Both look correct in the compose file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mounting an empty host directory over a populated container path hides what was already there:&lt;/strong&gt; bind mount an empty folder at &lt;code&gt;/usr/share/tessdata&lt;/code&gt; and the traineddata files shipped in the image vanish, including English. OCR that worked before your fix stops working after it. Copy the existing contents out first with &lt;code&gt;docker cp stirling-pdf:/usr/share/tessdata ./tessdata&lt;/code&gt;, then mount that directory back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same trap applies to fonts:&lt;/strong&gt; mounting your font collection at &lt;code&gt;/usr/share/fonts&lt;/code&gt; replaces the container's entire font tree. Mount a subdirectory such as &lt;code&gt;/usr/share/fonts/truetype/custom&lt;/code&gt; so the bundled families survive alongside yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker creates missing bind mount sources as root:&lt;/strong&gt; if the host path does not exist when the container starts, Docker makes it owned by &lt;code&gt;root:root&lt;/code&gt;. An app running under a non-root user then fails to write, and configuration changes made in the UI silently do not persist across a restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the identity before you chase the permission:&lt;/strong&gt; run &lt;code&gt;docker exec stirling-pdf id&lt;/code&gt; to see which UID the process actually uses, then &lt;code&gt;docker exec stirling-pdf ls -ln /configs&lt;/code&gt; to compare it against the ownership of the mounted directory. The numbers either match or they do not, and that answers the question in five seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;PUID&lt;/code&gt; and &lt;code&gt;PGID&lt;/code&gt; to your own account, not to 0:&lt;/strong&gt; running as root to make a permission error go away leaves every file the app writes owned by root on your host, which becomes your problem the first time you try to back up or move the data directory.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>pdf</category>
    </item>
    <item>
      <title>Hubs Community Edition backup and restore: what npm run backup captures, what it misses, and how long recovery takes</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 22 Aug 2026 07:06:26 +0000</pubDate>
      <link>https://dev.to/john_182319291/hubs-community-edition-backup-and-restore-what-npm-run-backup-captures-what-it-misses-and-how-13of</link>
      <guid>https://dev.to/john_182319291/hubs-community-edition-backup-and-restore-what-npm-run-backup-captures-what-it-misses-and-how-13of</guid>
      <description>&lt;p&gt;The bundled scripts in Hubs Community Edition back up two things: the PostgreSQL database and the Reticulum file store that holds uploaded GLB scenes, avatars, thumbnails and room media. They do not back up your &lt;code&gt;hcce.yaml&lt;/code&gt;, your TLS material, your DNS records, your Kubernetes cluster or your object storage credentials, and if you point Hubs at an external database instead of the bundled Postgres pod, the scripts fall back to backing up the Reticulum files only. That means &lt;code&gt;npm run restore-backup&lt;/code&gt; is not a disaster recovery plan on its own: it is the last step of one, and everything before it is a rebuild you have to be able to perform from your own notes. Plan for a restore that is a fresh install plus a data load, and time it once so you know the real number instead of a hoped-for one.&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;Small agency hosting client worlds&lt;/strong&gt; (a four person studio running six separate client instances): keep the scripts, but wrap them in per instance archives with your &lt;code&gt;hcce.yaml&lt;/code&gt; and secrets stored beside each archive, because a client asking for their world back is a per instance restore and not a cluster restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solo operator running one instance&lt;/strong&gt; (a single lecturer hosting seminar rooms on one VPS): the built in backup plus a nightly filesystem snapshot of the node is enough, because your recovery target is the whole box and not a selective extraction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team using an external managed Postgres&lt;/strong&gt; (a studio on a hosted database with automated point in time recovery): treat the two halves separately, because the scripts stop covering the database the moment it leaves the bundled pod and your restore must line up two independent timelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team with heavy scene libraries&lt;/strong&gt; (a studio with hundreds of client uploaded GLB files): budget your restore window from file store size and disk throughput, because the database restores quickly and the assets dominate the clock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team facing a domain or host migration&lt;/strong&gt; (a move to a new provider or a new client domain): rehearse the restore on a scratch host first, because room URLs, the Reticulum host configuration and certificate issuance all have to be corrected before anyone can join a room.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff: the built in scripts are simple and cover the data that actually matters, but they assume you can rebuild the surrounding installation by hand, so the effort you save at backup time is effort you pay back, under pressure, at restore time.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;What does npm run backup actually capture in Hubs Community Edition?&lt;/li&gt;
&lt;li&gt;What does the Hubs backup script silently miss?&lt;/li&gt;
&lt;li&gt;Where does Hubs store rooms, avatars and uploaded GLB scenes on disk?&lt;/li&gt;
&lt;li&gt;How long does a full Hubs restore actually take?&lt;/li&gt;
&lt;li&gt;How do you run npm run restore-backup without breaking existing room URLs?&lt;/li&gt;
&lt;li&gt;Should you back up the Hubs Postgres database separately from the Reticulum file store?&lt;/li&gt;
&lt;li&gt;What changes when you run Hubs against an external database instead of the bundled pgsql pod?&lt;/li&gt;
&lt;li&gt;Which Hubs secrets, certificates and configuration files must be saved outside the backup archive?&lt;/li&gt;
&lt;li&gt;How much disk space does a year of Hubs backups need?&lt;/li&gt;
&lt;li&gt;Snapshots, scripts or object storage: which backup strategy fits a Hubs instance?&lt;/li&gt;
&lt;li&gt;How do you test a Hubs restore before you actually need it?&lt;/li&gt;
&lt;li&gt;What breaks when you restore Hubs onto a different domain or a different host?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What does npm run backup actually capture in Hubs Community Edition?
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;npm run backup&lt;/code&gt; from your Hubs Community Edition checkout and you get a single timestamped archive, named in the &lt;code&gt;data_backup_1234567890123&lt;/code&gt; pattern, containing two payloads and nothing else.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The PostgreSQL database, dumped from the bundled pgsql pod&lt;/strong&gt;: this is the record of every room, its title, its permissions, its owner account, its scene assignment and the metadata rows that point at uploaded files. Lose it and your GLB files still exist on disk but no longer belong to any room.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Reticulum file store&lt;/strong&gt;: the uploaded assets themselves, meaning GLB scenes exported from Spoke, custom avatars, room thumbnails, images, audio and video that people dropped into rooms. This is the part that grows without limit and dominates the archive size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing about your cluster or your configuration&lt;/strong&gt;: &lt;code&gt;hcce.yaml&lt;/code&gt;, your Kubernetes state, your certificates and your DNS live outside the archive and are your responsibility to version elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing about the state of the services at the moment of the dump&lt;/strong&gt;: the archive is a data snapshot, not a running system image, so a restore always lands on a freshly installed instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The matching command is &lt;code&gt;npm run restore-backup data_backup_1234567890123&lt;/code&gt;, and calling &lt;code&gt;npm run restore-backup&lt;/code&gt; with no argument restores the most recent archive it finds. Where the instance runs shapes the rest of the plan: a self managed VPS, a home server, a NAS and Yundera are all viable homes for the surrounding stack. 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, the archive stays the same two payloads.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does the Hubs backup script silently miss?
&lt;/h2&gt;

&lt;p&gt;The word to notice is silently. The script does not warn you about the gaps, it simply finishes, prints a path and leaves you feeling covered. Six things are outside it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your &lt;code&gt;hcce.yaml&lt;/code&gt;&lt;/strong&gt;: this file carries your domain, your admin email, your subdomain layout and the deployment options that make the instance yours. Restore the data onto a default configuration and you get a working Hubs that is not your Hubs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets and keys&lt;/strong&gt;: the Reticulum secret key base, the Perms keypair used to sign room join tokens, the cookie signing salt and any OAuth or email credentials. Regenerating the Perms keypair invalidates tokens that clients already hold, so sessions and pending invites break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS certificates and the cert-manager state&lt;/strong&gt;: a fresh install reissues through Let's Encrypt, which means the restore is gated on DNS pointing at the new host and on rate limits, not on your archive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The k3s cluster itself&lt;/strong&gt;: node configuration, storage class, persistent volume definitions and any manual &lt;code&gt;kubectl&lt;/code&gt; edits you made months ago and forgot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External object storage and CDN configuration&lt;/strong&gt;: if you moved uploads off local disk, the bucket credentials and the bucket contents are not in the archive at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email, TURN and third party settings&lt;/strong&gt;: SMTP relay credentials and Coturn configuration decide whether people can receive invites and connect through restrictive networks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Test the gap cheaply. Run &lt;code&gt;npm run backup&lt;/code&gt;, extract the archive to a scratch directory and list what came out. Everything you expected but cannot find is now an item on a second backup job, ideally a git repository holding &lt;code&gt;hcce.yaml&lt;/code&gt; and an encrypted secrets file.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where does Hubs store rooms, avatars and uploaded GLB scenes on disk?
&lt;/h2&gt;

&lt;p&gt;Two places, and knowing which is which decides what you can recover selectively later.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL holds the structure, not the bytes&lt;/strong&gt;: the Reticulum schema carries tables such as &lt;code&gt;hubs&lt;/code&gt; for rooms, &lt;code&gt;accounts&lt;/code&gt; for identities, &lt;code&gt;scenes&lt;/code&gt; and &lt;code&gt;avatars&lt;/code&gt; for published assets, and &lt;code&gt;owned_files&lt;/code&gt; for every upload. A row in &lt;code&gt;owned_files&lt;/code&gt; records the owner, the content type and the identifier, while the actual file sits elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Reticulum storage volume holds the bytes&lt;/strong&gt;: GLB scenes exported from Spoke, avatar GLBs, room thumbnails, images, PDFs, audio and video are written into a fanned out directory tree, each upload stored as a data file paired with a small metadata file. The names are identifiers, not human readable titles, which is why a file only means something when the database row that points at it survives too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derived media is cached, not authored&lt;/strong&gt;: image resizing and video transcoding produce output that can be regenerated, so it is worth excluding from a tight recovery target and rebuilding after the fact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The volume is a Kubernetes persistent volume claim&lt;/strong&gt;: run &lt;code&gt;kubectl get pvc&lt;/code&gt; in your Hubs namespace to see the claim and its size, and &lt;code&gt;kubectl exec&lt;/code&gt; into the Reticulum pod to measure the tree with &lt;code&gt;du -sh&lt;/code&gt; before you plan any window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where that volume physically lives depends on the host you chose, whether a self managed VPS, a home server, a NAS or Yundera. The split matters because the database is small and fast to move, while the storage volume is the part that grows every time a client uploads a scene.&lt;/p&gt;




&lt;h2&gt;
  
  
  How long does a full Hubs restore actually take?
&lt;/h2&gt;

&lt;p&gt;Nobody can hand you a single number, because the archive is dominated by whatever your clients uploaded. What you can do is decompose the clock into five stages, measure each once on your own hardware, and turn the total into a promise you can actually keep.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Restore stage&lt;/th&gt;
&lt;th&gt;What dominates the clock&lt;/th&gt;
&lt;th&gt;How to shorten it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Provision the host and install k3s&lt;/td&gt;
&lt;td&gt;Provider provisioning time plus package downloads&lt;/td&gt;
&lt;td&gt;Keep a prebuilt image or a scripted node setup instead of typing it fresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy Hubs from &lt;code&gt;hcce.yaml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Container image pulls for Reticulum, Dialog, the client and Postgres&lt;/td&gt;
&lt;td&gt;Pre pull images on the standby host, or host them in a local registry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issue TLS and point DNS&lt;/td&gt;
&lt;td&gt;DNS propagation plus Let's Encrypt issuance, both outside your control&lt;/td&gt;
&lt;td&gt;Lower the record TTL before a planned migration, not during an incident&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load the database dump&lt;/td&gt;
&lt;td&gt;Row count in &lt;code&gt;hubs&lt;/code&gt;, &lt;code&gt;accounts&lt;/code&gt; and &lt;code&gt;owned_files&lt;/code&gt;, usually the smallest stage&lt;/td&gt;
&lt;td&gt;Nothing needed, this is rarely the bottleneck&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copy back the Reticulum file store&lt;/td&gt;
&lt;td&gt;Archive size divided by disk and network throughput, the largest stage by far&lt;/td&gt;
&lt;td&gt;Restore assets from storage that is already close to the new host&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Measure it with &lt;code&gt;time npm run restore-backup data_backup_1234567890123&lt;/code&gt; on a scratch instance, then add the stages the script does not cover. Two rules follow. First, the file store sets your recovery time objective, so track its growth with &lt;code&gt;du -sh&lt;/code&gt; monthly. Second, quote clients a window built from a real rehearsal, not from the script's runtime alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you run npm run restore-backup without breaking existing room URLs?
&lt;/h2&gt;

&lt;p&gt;A Hubs room URL is built from the room identifier stored in the &lt;code&gt;hubs&lt;/code&gt; table, so the URLs survive a restore for free as long as the database rows come back unchanged and the domain in front of them stays the same. What breaks them is everything you do around the restore.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restore onto a fresh install, never onto a used one&lt;/strong&gt;: bring up Hubs from &lt;code&gt;hcce.yaml&lt;/code&gt;, then restore before anyone creates a room or publishes a scene. New activity writes rows that the incoming dump will collide with or overwrite, and the reconciliation is manual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the domain identical at restore time&lt;/strong&gt;: put the same value in &lt;code&gt;hcce.yaml&lt;/code&gt; that the archive was taken under, restore, verify, and only then perform a domain change as a separate, deliberate step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quiesce the services first&lt;/strong&gt;: scale the Reticulum deployment to zero with &lt;code&gt;kubectl scale&lt;/code&gt;, run the restore, then scale back. Restoring underneath a running Reticulum means live processes hold state that no longer matches the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name the archive explicitly&lt;/strong&gt;: run &lt;code&gt;npm run restore-backup data_backup_1234567890123&lt;/code&gt; rather than the bare &lt;code&gt;npm run restore-backup&lt;/code&gt;, because the no argument form picks the most recent archive it finds and that is rarely what you want during an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify before announcing&lt;/strong&gt;: open one known room URL, one published scene and one custom avatar. Three checks catch the common failure, which is a database restored without its matching files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The host underneath, a self managed VPS, a home server, a NAS or Yundera, does not change this sequence, only how quickly you can stand up the replacement instance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Should you back up the Hubs Postgres database separately from the Reticulum file store?
&lt;/h2&gt;

&lt;p&gt;For a single instance you can run &lt;code&gt;npm run backup&lt;/code&gt; on a schedule and stop thinking about it. For anything larger, split the two, because they have opposite shapes: the database is small and changes every time someone creates a room, while the file store is large and mostly append only.&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;One combined archive&lt;/th&gt;
&lt;th&gt;Database and files backed up separately&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sensible cadence&lt;/td&gt;
&lt;td&gt;Whatever the slowest half tolerates, usually nightly&lt;/td&gt;
&lt;td&gt;Database hourly, files daily or on change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage growth&lt;/td&gt;
&lt;td&gt;Every run copies the full file store again&lt;/td&gt;
&lt;td&gt;Files deduplicate through incremental tooling, dumps stay small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tooling&lt;/td&gt;
&lt;td&gt;The bundled scripts only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_dump&lt;/code&gt; plus restic, borg or rsync against the storage volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recovery granularity&lt;/td&gt;
&lt;td&gt;All or nothing, back to the archive time&lt;/td&gt;
&lt;td&gt;Roll the database to one point, the files to another&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational cost&lt;/td&gt;
&lt;td&gt;One command, one cron entry, one thing to forget&lt;/td&gt;
&lt;td&gt;Two jobs, two retention policies, two things to monitor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical rule is the loss window. If your clients publish scenes weekly but create rooms daily, an archive that repeats 40 GB of unchanged GLB files every night buys you nothing except a bigger bill. Dump the database hourly, back the storage volume up incrementally, and keep one full combined archive weekly as the known good fallback that needs no tooling to read.&lt;/p&gt;

&lt;p&gt;Split backups carry one obligation: always restore the file store first and the database second, so no row can reference an asset that is not on disk yet. Write that ordering into the runbook, because it is the step people improvise wrongly under pressure.&lt;/p&gt;




&lt;h2&gt;
  
  
  What changes when you run Hubs against an external database instead of the bundled pgsql pod?
&lt;/h2&gt;

&lt;p&gt;One thing changes and it is the important one: the backup scripts stop covering half your data. With an external database configured, &lt;code&gt;npm run backup&lt;/code&gt; and &lt;code&gt;npm run restore-backup&lt;/code&gt; handle the Reticulum files only. Nobody prints a warning. You simply get a smaller archive.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You now own the database backup outright&lt;/strong&gt;: either your provider's automated snapshots and point in time recovery, or your own scheduled &lt;code&gt;pg_dump&lt;/code&gt; against the Hubs database. Whichever you pick, it belongs in a runbook next to the file archive, not in your head.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have two timelines to reconcile&lt;/strong&gt;: a database recovered to 02:00 and a file archive taken at 03:00 leaves an hour of &lt;code&gt;owned_files&lt;/code&gt; rows pointing at assets the file store does not have. Restore the files first, then roll the database forward to a point at or before the file archive, never after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials become a recovery dependency&lt;/strong&gt;: the connection string, user and password live in your &lt;code&gt;hcce.yaml&lt;/code&gt; and secrets, so losing those means the data survives while access to it does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations still run on boot&lt;/strong&gt;: Reticulum applies its schema migrations when it starts, so a database restored from an older instance can be upgraded by a newer deployment without you asking. Restore onto the same version you backed up from and upgrade deliberately afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bundled pod stays simpler for small instances&lt;/strong&gt;: one archive, one timeline, one restore command, at the cost of the operational features a managed database gives you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choose the split deliberately, then write down which system owns which half.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which Hubs secrets, certificates and configuration files must be saved outside the backup archive?
&lt;/h2&gt;

&lt;p&gt;Sort every item into two buckets before you build the vault: things that can be regenerated on the new host, and things that change behaviour for users if they are regenerated. Only the second bucket is a real backup obligation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Preserve or regenerate&lt;/th&gt;
&lt;th&gt;Where it belongs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hcce.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Preserve, it defines your domain, subdomains and admin email&lt;/td&gt;
&lt;td&gt;A private git repository, committed on every change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perms keypair and Reticulum secret key base&lt;/td&gt;
&lt;td&gt;Preserve, regenerating invalidates issued room tokens and signed cookies&lt;/td&gt;
&lt;td&gt;Encrypted secrets file, sops with age or your password manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External database credentials&lt;/td&gt;
&lt;td&gt;Preserve, the data is useless without access to it&lt;/td&gt;
&lt;td&gt;Same encrypted store as the keys, never in the same archive as the dump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMTP, OAuth and Coturn credentials&lt;/td&gt;
&lt;td&gt;Preserve, they come from third parties and cannot be recreated locally&lt;/td&gt;
&lt;td&gt;Encrypted store, with the provider account noted beside them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS certificates&lt;/td&gt;
&lt;td&gt;Regenerate, cert-manager reissues after DNS points at the new host&lt;/td&gt;
&lt;td&gt;Nothing to keep, but record the issuer and DNS provider steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Derived Kubernetes secrets&lt;/td&gt;
&lt;td&gt;Regenerate from the two entries above during deployment&lt;/td&gt;
&lt;td&gt;Nothing to keep, they are outputs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Capture the live state once with &lt;code&gt;kubectl get secret -n hubs -o yaml&lt;/code&gt; so you can see exactly what your instance holds rather than trusting this list. Encrypt that export immediately, because it contains plaintext values.&lt;/p&gt;

&lt;p&gt;One discipline makes the whole thing work: the encrypted secrets store must be recoverable without the Hubs instance. If your only copy of the vault key lives on the server you are restoring, you do not have a backup, you have a circular dependency.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much disk space does a year of Hubs backups need?
&lt;/h2&gt;

&lt;p&gt;Work it out from two measurements rather than a guess. Get the current storage volume size with &lt;code&gt;du -sh&lt;/code&gt; inside the Reticulum pod, then take two archives a month apart and subtract to get your monthly growth. Everything else is arithmetic on those numbers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full copies multiply, so count them first&lt;/strong&gt;: a grandfather, father, son policy of 7 daily, 4 weekly and 12 monthly archives is 23 retained copies. With the bundled script, each one contains the whole file store, so the year costs roughly 23 times your current storage size plus the accumulated growth. This is the number that surprises agencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental tooling changes the multiplier, not the base&lt;/strong&gt;: because GLB scenes and avatars are written once and rarely modified, deduplicating backups with restic or borg store close to one copy plus the new uploads, so 23 restore points cost far less than 23 full archives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database dumps are rounding error&lt;/strong&gt;: &lt;code&gt;pg_dump&lt;/code&gt; output for room, account and &lt;code&gt;owned_files&lt;/code&gt; rows compresses well and stays small next to the assets, which is why hourly database backups are affordable while hourly file archives are not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derived media inflates the total without adding value&lt;/strong&gt;: transcoded video and resized images can be regenerated, so excluding them from the archive shrinks every copy for the whole year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Off site copies double whatever you decided&lt;/strong&gt;: the 3-2-1 rule means one primary, one local secondary and one remote, so budget the total twice, not once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set retention against a real number. A client that publishes a scene weekly needs different depth than one that uploaded once and never returned.&lt;/p&gt;




&lt;h2&gt;
  
  
  Snapshots, scripts or object storage: which backup strategy fits a Hubs instance?
&lt;/h2&gt;

&lt;p&gt;Three approaches exist and they solve different failures. Pick by asking what you expect to lose: the whole machine, the data inside it, or the provider account holding both.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provider or filesystem snapshots of the node&lt;/strong&gt;: these capture the entire k3s host, so recovery is a rollback rather than a rebuild, and your &lt;code&gt;hcce.yaml&lt;/code&gt;, secrets and certificates come back with it. They fail you in two ways: they usually live in the same provider account as the server, and they restore everything or nothing, which is useless when one client wants one room recovered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bundled scripts alone&lt;/strong&gt;: &lt;code&gt;npm run backup&lt;/code&gt; is portable, understandable and independent of any provider feature, which makes it the archive you can still read in three years. It covers the two data payloads and nothing around them, so it needs a companion job for configuration and keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental backups to object storage&lt;/strong&gt;: restic or borg pushing the Reticulum storage volume and &lt;code&gt;pg_dump&lt;/code&gt; output to an S3 compatible target such as MinIO or Backblaze B2 gives deduplication, encryption at rest and an off site copy in one tool. The cost is a second system to monitor, plus a restore that now depends on network throughput.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hybrid most agencies land on&lt;/strong&gt;: nightly snapshots for fast whole host rollback, weekly bundled archives as the provider independent fallback, and continuous incremental pushes off site for the 3-2-1 requirement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The choice is not about which tool is best. It is about which of the three failures you consider most likely, and whether your recovery needs to be selective.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you test a Hubs restore before you actually need it?
&lt;/h2&gt;

&lt;p&gt;Build a scratch instance on a throwaway subdomain, restore last night's archive into it, and check five things by hand. Repeat quarterly and after every version upgrade, because an untested archive is a hypothesis.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a separate domain, never the live one&lt;/strong&gt;: set a value such as &lt;code&gt;hubs-drtest.example.com&lt;/code&gt; in the scratch &lt;code&gt;hcce.yaml&lt;/code&gt;. Restoring production data onto production DNS during a drill is how a test becomes an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join a room and check presence works&lt;/strong&gt;: open a restored room URL in two browsers and confirm audio and video connect. This exercises Dialog and Coturn, which a database check alone never touches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load a published GLB scene, not just the room list&lt;/strong&gt;: rooms that open with a missing environment are the signature of a database restored without its matching file store, and the room index looks perfectly healthy in that state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load a custom avatar and a room thumbnail&lt;/strong&gt;: avatars and thumbnails are separate &lt;code&gt;owned_files&lt;/code&gt; rows, so they fail independently of scenes and prove the file tree copied completely rather than partially.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time each stage and write the number down&lt;/strong&gt;: run the drill with a stopwatch, record the total, and give clients that figure rather than an estimate. The number moves as the file store grows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finish by destroying the scratch instance and any DNS records it created, so nobody stumbles into stale rooms months later. Keep a one page log of every drill: the date, the archive name, the elapsed time and what broke. Three entries in that log tell you more about your recovery position than any amount of configuration review.&lt;/p&gt;




&lt;h2&gt;
  
  
  What breaks when you restore Hubs onto a different domain or a different host?
&lt;/h2&gt;

&lt;p&gt;A different host alone is uneventful: the archive is portable and the deployment is rebuilt from &lt;code&gt;hcce.yaml&lt;/code&gt;, so a move between a self managed VPS, a home server, a NAS or Yundera changes little beyond throughput. A different domain is the one that hurts, because the old hostname is written into places the restore script does not touch.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stored absolute URLs&lt;/strong&gt;: rows created under the old domain can carry fully qualified asset and scene URLs. After a domain change, query the database with &lt;code&gt;psql&lt;/code&gt; for the old hostname before you assume the restore was clean, and update what you find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Links your clients already shared&lt;/strong&gt;: room URLs printed in emails, calendar invites and slide decks point at the old host. Keep the old domain resolving with a redirect for at least one full booking cycle rather than a fixed number of days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth and email&lt;/strong&gt;: third party callback URLs and the sending domain in your SMTP configuration are registered against the old hostname and reject the new one until you update both providers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TURN and media&lt;/strong&gt;: Dialog and Coturn advertise addresses derived from your deployment configuration, so audio and video can fail while the room list looks perfectly healthy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate issuance order&lt;/strong&gt;: DNS must point at the new host before cert-manager can complete a challenge, so the sequence is DNS first, deploy second, restore third.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do the domain change as its own step, after a same domain restore has been verified. Two changes at once means any failure gives you two suspects instead of one.&lt;/p&gt;

</description>
      <category>hubs</category>
      <category>selfhosting</category>
      <category>devops</category>
      <category>backup</category>
    </item>
    <item>
      <title>Duplicati Block Size, Remote Volume Size and Retention: How to Choose Them for Your Data Shape</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 20 Aug 2026 07:06:24 +0000</pubDate>
      <link>https://dev.to/john_182319291/duplicati-block-size-remote-volume-size-and-retention-how-to-choose-them-for-your-data-shape-93c</link>
      <guid>https://dev.to/john_182319291/duplicati-block-size-remote-volume-size-and-retention-how-to-choose-them-for-your-data-shape-93c</guid>
      <description>&lt;p&gt;Pick your Duplicati block size from the total size of the source data, not from the number of files in it, and pick your remote volume size from your upload link and your storage provider's request pricing. The 100 KB block size and 50 MB remote volume defaults are sized for a laptop with a few hundred gigabytes, and they are the single most common reason a large job ends up with a multi gigabyte local database and a database recreate that runs for hours. Block size is fixed at job creation and cannot be changed later without starting a new backup chain, so this is the one decision you get exactly one chance at. Retention is the setting you can change freely at any time, which is why it should be the last thing you tune, not the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by data shape:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Laptop and document sets (Maya, 80 GB of text, code and PDFs):&lt;/strong&gt; keep the 100 KB default block size and the 50 MB remote volume, because the block count stays low enough that the local database and the recreate never become a problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Millions of small files (a Nextcloud data directory, 600 GB across roughly 4 million files):&lt;/strong&gt; raise the block size to 1 MB and the remote volume to 200 MB, because per file overhead and block count, not raw bytes, are what inflate the database here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photo and video libraries (a 2 TB Immich library of HEIC, JPEG and MP4):&lt;/strong&gt; use 1 MB blocks and 200 MB to 250 MB volumes with a version count retention, because the files are already compressed, almost never change after import and deduplicate poorly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi gigabyte VM images (six qcow2 files, 900 GB total, backed up nightly):&lt;/strong&gt; use 1 MB or larger blocks and 500 MB volumes with a time based or smart retention, because the churn inside large files is what drives both upload volume and compaction traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow or metered upstream (a 10 Mbit/s home link, or a provider that bills per request):&lt;/strong&gt; shrink the remote volume back toward 50 MB for restart tolerance, or grow it to cut request counts, and accept that you cannot optimise for both at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore time is the priority (a business file server with an agreed recovery window):&lt;/strong&gt; favour smaller remote volumes and fewer retained versions, and back up the local database itself, because a recreate from remote data is the slowest path back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is simple: larger blocks and larger remote volumes keep the local database small and the recreate survivable, while smaller blocks and smaller volumes save upload bandwidth on small changes and make single file restores cheaper.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;What do Duplicati's block size, remote volume size and retention policy actually control?&lt;/li&gt;
&lt;li&gt;Why block size is locked in on the first backup run&lt;/li&gt;
&lt;li&gt;How many blocks can the local database carry before it starts to hurt?&lt;/li&gt;
&lt;li&gt;Millions of small files: which block size and remote volume size to choose&lt;/li&gt;
&lt;li&gt;What should you change for a photo and video library?&lt;/li&gt;
&lt;li&gt;A handful of multi gigabyte VM images: the settings that matter most&lt;/li&gt;
&lt;li&gt;How big should the remote volume size be for your upload link and your provider?&lt;/li&gt;
&lt;li&gt;What does the local SQLite database cost you in disk, RAM and backup time?&lt;/li&gt;
&lt;li&gt;Keep all, keep versions, keep time or smart retention: which one fits your data shape?&lt;/li&gt;
&lt;li&gt;How much post backup verification should you actually pay for?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What do Duplicati's block size, remote volume size and retention policy actually control?
&lt;/h2&gt;

&lt;p&gt;These three settings sit at different layers of the same pipeline, and confusing them is why so many jobs end up misconfigured.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Block size (&lt;code&gt;--blocksize&lt;/code&gt;, default 100 KB):&lt;/strong&gt; the unit of deduplication. Duplicati splits every source file into blocks of this size, hashes each one with SHA-256, and stores the hash in the local database. A 600 GB source at 100 KB produces roughly 6 million blocks, and every one of them becomes a row you carry forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote volume size (&lt;code&gt;--dblock-size&lt;/code&gt;, default 50 MB):&lt;/strong&gt; the unit of upload and download. Blocks are packed into compressed, AES-256 encrypted &lt;code&gt;.dblock.zip.aes&lt;/code&gt; files of this size before they leave the machine. It has no effect on deduplication quality, only on how many objects your provider stores and how much you must re-download to reach one block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention policy (&lt;code&gt;--retention-policy&lt;/code&gt;, &lt;code&gt;--keep-versions&lt;/code&gt;, &lt;code&gt;--keep-time&lt;/code&gt;):&lt;/strong&gt; which restore points survive. It deletes &lt;code&gt;.dlist&lt;/code&gt; files, the per version file lists, and marks the blocks they alone referenced as wasted space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compaction threshold (&lt;code&gt;--threshold&lt;/code&gt;, default 25%):&lt;/strong&gt; the link between the two. When wasted space in the remote volumes passes 25%, Duplicati downloads partially obsolete volumes, repacks the live blocks and uploads new ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Block size is permanent per job. Remote volume size applies to volumes created after you change it, so old and new sizes coexist. Retention and threshold can be edited on any run with no penalty. That ordering, permanent to free, is the order in which you should think about them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why block size is locked in on the first backup run
&lt;/h2&gt;

&lt;p&gt;The block size is written into the job when the first backup completes, and every &lt;code&gt;.dblock&lt;/code&gt; and &lt;code&gt;.dindex&lt;/code&gt; file in the destination assumes it. Change it later and Duplicati will not re-chunk what is already stored, because a 100 KB block and a 1 MB block of the same file share no hash and can never deduplicate against each other.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What actually happens if you edit it:&lt;/strong&gt; newer versions of Duplicati refuse the change on an existing job, and older ones let you save a value that is silently ignored for the existing chain. Either way you do not get the setting you asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The only real fix:&lt;/strong&gt; create a new job pointing at an empty destination folder, run a full initial backup, then retire the old job once you are satisfied with a test restore from it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What that costs you:&lt;/strong&gt; a second full upload of the entire source, a second full copy of storage held during the overlap, and the wall clock time of your slowest link. On a 20 Mbit/s upstream, 1 TB takes over four days of continuous transfer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where the decision lives:&lt;/strong&gt; on the machine running Duplicati, since the local job database and the &lt;code&gt;--blocksize&lt;/code&gt; value belong to that installation, not to the destination.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is why the hosting choice matters before the first run and not after. A self-managed VPS, a home NAS, an always on mini PC and Yundera are all reasonable places to run it. 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. Pick the host, then set the block size once.&lt;/p&gt;




&lt;h2&gt;
  
  
  How many blocks can the local database carry before it starts to hurt?
&lt;/h2&gt;

&lt;p&gt;Block count, not gigabytes, is what the SQLite database tracks. Every block contributes a hash row, and every version contributes references to those rows. The arithmetic is fixed: source size divided by block size.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source size and block size&lt;/th&gt;
&lt;th&gt;Approximate block count&lt;/th&gt;
&lt;th&gt;What it feels like in practice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 GB at 100 KB default&lt;/td&gt;
&lt;td&gt;1.0 million&lt;/td&gt;
&lt;td&gt;Defaults are fine, recreate finishes in minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 GB at 100 KB default&lt;/td&gt;
&lt;td&gt;5.2 million&lt;/td&gt;
&lt;td&gt;Backups still work, recreate becomes an evening job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;900 GB at 100 KB default&lt;/td&gt;
&lt;td&gt;9.4 million&lt;/td&gt;
&lt;td&gt;Compaction and deletion queries slow noticeably&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 TB at 100 KB default&lt;/td&gt;
&lt;td&gt;21 million&lt;/td&gt;
&lt;td&gt;The common failure case, recreate can run for many hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 TB at 1 MB&lt;/td&gt;
&lt;td&gt;2.1 million&lt;/td&gt;
&lt;td&gt;Same data, one tenth the rows, back in comfortable territory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical target most Duplicati operators settle on is keeping a job under roughly 5 million blocks, and treating anything past 10 million as a warning sign. That is a heuristic, not an enforced limit. Nothing breaks at a threshold. What happens instead is that every operation touching the block table gets slower in proportion: the delete step after a retention rule, the compaction pass, the verification query at the end of a run, and above all the recreate.&lt;/p&gt;

&lt;p&gt;Two extra multipliers catch people out. Retained versions add rows for changed blocks, so a job holding 60 versions carries far more than a job holding 10 of the same source. And a single job covering several unrelated directories pools all of that into one &lt;code&gt;.sqlite&lt;/code&gt; file. Splitting one 2 TB job into three smaller jobs gives you three databases that each fail and rebuild independently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Millions of small files: which block size and remote volume size to choose
&lt;/h2&gt;

&lt;p&gt;This is the shape where raising the block size helps least and still needs doing. A 20 KB file is one block whether your block size is 100 KB or 1 MB, so a Nextcloud data directory of 4 million files across 600 GB does not shed 90% of its rows the way a VM job does. The database cost here comes from per file bookkeeping: each file carries an entry plus a separate metadata block for its permissions and timestamps.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;--blocksize&lt;/code&gt; to 1 MB anyway:&lt;/strong&gt; the large minority of files in a mixed user directory, the videos, the archives, the mail stores, is where the block count actually accumulates. Cutting those by a factor of ten is worth it, and it costs nothing on the files that were already single block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;--dblock-size&lt;/code&gt; to 200 MB:&lt;/strong&gt; at 50 MB, 600 GB of source becomes over 12,000 remote objects before any versioning. At 200 MB it is closer to 3,000, which cuts list and request operations on providers that bill per request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter aggressively before tuning anything:&lt;/strong&gt; thumbnail caches, &lt;code&gt;.thumbnails&lt;/code&gt;, preview directories, &lt;code&gt;node_modules&lt;/code&gt; and application temp folders often account for a third of the file count and none of the restore value. Exclusion filters remove rows permanently, which no block size can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split the job by directory:&lt;/strong&gt; one job per top level user folder gives you separate &lt;code&gt;.sqlite&lt;/code&gt; databases, separate schedules and separate recreates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave &lt;code&gt;--small-file-max-count&lt;/code&gt; at 20:&lt;/strong&gt; raising it makes compaction merge more undersized volumes per run, which adds download traffic on a job that already has plenty to do.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What should you change for a photo and video library?
&lt;/h2&gt;

&lt;p&gt;A photo library is write once data made of already compressed bytes, and both facts change the tuning. HEIC, JPEG, MP4 and MOV files are entropy dense, so deduplication finds almost nothing and the zip step spends CPU to save single digit percentages.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use 1 MB blocks and 200 MB to 250 MB remote volumes:&lt;/strong&gt; a 2 TB library at the 100 KB default generates tens of millions of blocks for data that will never deduplicate against itself, which is pure database cost with no return.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop &lt;code&gt;--zip-compression-level&lt;/code&gt; from the default 6 to 0 or 1:&lt;/strong&gt; on a directory of JPEG and MP4 you are paying full compression CPU on every upload for a ratio close to 1.0. Setting it to 0 stores the blocks and lets the AES-256 encryption still apply normally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude the regenerable directories:&lt;/strong&gt; in an Immich deployment, &lt;code&gt;thumbs&lt;/code&gt; and &lt;code&gt;encoded-video&lt;/code&gt; are derived from the originals in &lt;code&gt;upload&lt;/code&gt; and can be rebuilt by the application. In a Photoprism setup, the &lt;code&gt;sidecar&lt;/code&gt; and &lt;code&gt;cache&lt;/code&gt; folders are the equivalent. These often hold 15% to 30% of the total bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep few versions, not many:&lt;/strong&gt; photos are added, not edited. A &lt;code&gt;--keep-versions&lt;/code&gt; of 3 or 5 covers accidental deletion and a bad import, and holding 30 versions of a library that never changes retains almost nothing new while adding &lt;code&gt;.dlist&lt;/code&gt; files to every operation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect compaction to stay idle:&lt;/strong&gt; because old blocks are rarely superseded, wasted space climbs slowly and the 25% threshold may never trigger for years. That is the desired outcome here, not a sign something is broken.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  A handful of multi gigabyte VM images: the settings that matter most
&lt;/h2&gt;

&lt;p&gt;Large images invert the problem. File count is trivial, block count is enormous, and every nightly run rewrites regions scattered through a file that never changes size. Block size here is a direct bandwidth decision.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Block size on 900 GB of qcow2&lt;/th&gt;
&lt;th&gt;Blocks in the job&lt;/th&gt;
&lt;th&gt;Upload when 1 GB is rewritten in scattered 100 KB regions, worst case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 KB default&lt;/td&gt;
&lt;td&gt;9.4 million&lt;/td&gt;
&lt;td&gt;About 1 GB, finest possible granularity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 KB&lt;/td&gt;
&lt;td&gt;1.9 million&lt;/td&gt;
&lt;td&gt;Up to 5 GB, five times the changed bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 MB&lt;/td&gt;
&lt;td&gt;940,000&lt;/td&gt;
&lt;td&gt;Up to 10 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 MB&lt;/td&gt;
&lt;td&gt;470,000&lt;/td&gt;
&lt;td&gt;Up to 20 GB, database is smallest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Real guest workloads cluster their writes, so the true figure sits well below those worst cases. Still, the shape is clear: past 1 MB you buy database comfort with upload volume, every single night.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start at 1 MB and only go higher if recreate time is your actual pain:&lt;/strong&gt; 940,000 blocks is comfortable, and going to 2 MB halves rows you were already happy with while doubling the worst case transfer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;--dblock-size&lt;/code&gt; to 500 MB:&lt;/strong&gt; with images this size the object count matters more than restart granularity, and a failed upload retries one volume, not the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot before reading:&lt;/strong&gt; set &lt;code&gt;--snapshot-policy=required&lt;/code&gt; so Duplicati uses VSS on Windows or LVM on Linux. Backing up a running qcow2 file without a snapshot produces a crash consistent image at best.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude swap and scratch disks:&lt;/strong&gt; a 16 GB swap file changes completely every day and restores no value.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How big should the remote volume size be for your upload link and your provider?
&lt;/h2&gt;

&lt;p&gt;Remote volume size is the one setting you can revise later, so tune it to the link and the bill rather than to the data shape. Volumes created after the change use the new size, and old ones keep theirs until compaction repacks them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match the volume to a tolerable retry:&lt;/strong&gt; an interrupted upload restarts that whole volume, and Duplicati retries 5 times with a 10 second delay by default. On a 10 Mbit/s upstream, roughly 1.25 MB/s, a 50 MB volume takes about 40 seconds and a 500 MB volume takes about 6.7 minutes. On a flaky link, the shorter unit wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count objects when your provider bills per request:&lt;/strong&gt; Backblaze B2 class C transactions and S3 PUT and LIST calls are priced per operation, not per byte. Moving from 50 MB to 500 MB cuts object count by a factor of ten for the same data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget temporary disk space:&lt;/strong&gt; with &lt;code&gt;--asynchronous-upload-limit&lt;/code&gt; at its default of 4, Duplicati can hold five volumes on local disk at once. At 500 MB that is 2.5 GB in the temp directory, which matters on a small VPS root filesystem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remember what a single file restore costs:&lt;/strong&gt; to recover one 100 KB block, Duplicati downloads the entire volume containing it. Large volumes make targeted restores expensive in egress and in time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep block size at least 100 times smaller than volume size:&lt;/strong&gt; 1 MB blocks in 200 MB volumes is a healthy ratio. 1 MB blocks in 10 MB volumes wastes the packing entirely.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What does the local SQLite database cost you in disk, RAM and backup time?
&lt;/h2&gt;

&lt;p&gt;Each job keeps one database, named with a random string such as &lt;code&gt;KJHGFDSAQW.sqlite&lt;/code&gt;, in the Duplicati config directory: &lt;code&gt;%LOCALAPPDATA%\Duplicati&lt;/code&gt; on Windows, or the mapped &lt;code&gt;/config&lt;/code&gt; volume in a container deployment. You can relocate it with &lt;code&gt;--dbpath&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disk grows with blocks multiplied by versions:&lt;/strong&gt; every block row stores a base64 SHA-256 hash of 44 characters plus its size and volume reference, so the floor is tens of bytes per block before indexes. A job at 21 million blocks lands in a different order of magnitude to one at 940,000, which is the whole argument for block size in one line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deletions do not shrink the file:&lt;/strong&gt; retention removes rows, but SQLite keeps the freed pages. Run a vacuum, or enable &lt;code&gt;--auto-vacuum&lt;/code&gt; with &lt;code&gt;--auto-vacuum-interval&lt;/code&gt;, otherwise the file stays at its historical high water mark forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RAM matters as page cache, not as a fixed requirement:&lt;/strong&gt; if the database and its indexes fit comfortably in free memory, lookups stay in cache. If they do not, every hash check becomes a random read, and that is where a 2 GB container memory limit quietly turns a 40 minute backup into a multi hour one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage medium is the biggest single lever:&lt;/strong&gt; the workload is random reads and writes against indexes. Put the database on SSD or NVMe. Never put it on a spinning disk you also back up, and never on an SMB or NFS share, where SQLite locking behaviour causes corruption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temp space is separate:&lt;/strong&gt; journal files and the sorting done during compaction land in the temp directory, not next to the database.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Keep all, keep versions, keep time or smart retention: which one fits your data shape?
&lt;/h2&gt;

&lt;p&gt;Retention is the free setting. Change it whenever you like, and the next run applies it. Duplicati never deletes the most recent backup, whatever you configure.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep all backups:&lt;/strong&gt; the right answer only for small, slow changing sources, such as a 5 GB configuration and document set. On anything with daily churn it means &lt;code&gt;.dlist&lt;/code&gt; files accumulate forever, which is exactly what lengthens the first stage of a recreate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a specific number of backups (&lt;code&gt;--keep-versions&lt;/code&gt;):&lt;/strong&gt; predictable and shallow. A photo library at &lt;code&gt;--keep-versions=5&lt;/code&gt; holds five restore points regardless of when they were made, which suits data that is added rather than edited. The weakness is a burst: five backups in one busy afternoon give you five restore points covering four hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete backups older than a time (&lt;code&gt;--keep-time&lt;/code&gt;):&lt;/strong&gt; a straight window, for example &lt;code&gt;--keep-time=1Y&lt;/code&gt; on a document set. Good when the requirement is stated in months, poor when your schedule is irregular, since a quiet quarter can leave the window nearly empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart backup retention:&lt;/strong&gt; the built in preset, equivalent to &lt;code&gt;--retention-policy=1W:1D,4W:1W,12M:1M&lt;/code&gt;. It keeps one backup per day for a week, one per week for a month, one per month for a year. This is the sane default for nightly VM images and for mixed user data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A custom policy string:&lt;/strong&gt; the same syntax, tuned. &lt;code&gt;7D:1D,4W:1W,12M:1M,10Y:1Y&lt;/code&gt; extends the tail to a decade at one point per year, and &lt;code&gt;U&lt;/code&gt; in place of a duration means unlimited. Write the shape you would actually restore from, not the shape that feels generous.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How much post backup verification should you actually pay for?
&lt;/h2&gt;

&lt;p&gt;After every run, Duplicati downloads a sample from the destination and checks it. The default &lt;code&gt;--backup-test-samples=1&lt;/code&gt; means one volume set per run: one &lt;code&gt;.dblock&lt;/code&gt;, its &lt;code&gt;.dindex&lt;/code&gt;, and a &lt;code&gt;.dlist&lt;/code&gt;. That default is cheap on 50 MB volumes and not on 500 MB ones.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do the egress arithmetic before raising it:&lt;/strong&gt; at 500 MB volumes and one sample per nightly run, verification alone pulls roughly 15 GB per month back down from the provider. Set &lt;code&gt;--backup-test-samples=5&lt;/code&gt; and it becomes about 75 GB per month, billed as egress on most object stores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer &lt;code&gt;--backup-test-percentage&lt;/code&gt; on large sets:&lt;/strong&gt; sampling a fixed 1% of volumes scales with the backup instead of staying flat, which is what you want on a job that grows from 200 GB to 2 TB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know what the default sample proves:&lt;/strong&gt; it verifies the file is present, the right size and hashes correctly. Add &lt;code&gt;--full-remote-verification&lt;/code&gt; to also decrypt and inspect the contents, at the cost of CPU on every checked volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a full test on a schedule, not on every backup:&lt;/strong&gt; &lt;code&gt;duplicati-cli test &amp;lt;destination-url&amp;gt; all --full-remote-verification&lt;/code&gt; checks the entire set. Monthly or quarterly is a reasonable cadence, and it is the only thing that catches silent rot in volumes the sampler never picked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on &lt;code&gt;--upload-verification-file&lt;/code&gt;:&lt;/strong&gt; it writes &lt;code&gt;duplicati-verification.json&lt;/code&gt; beside the backup, which the bundled DuplicatiVerify script can check at the destination without downloading anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never set &lt;code&gt;--no-backend-verification&lt;/code&gt; to save money:&lt;/strong&gt; you are then trusting a destination you have not read from since the day you wrote to it.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>duplicati</category>
      <category>backup</category>
      <category>storage</category>
      <category>homelab</category>
    </item>
    <item>
      <title>How Much Transcoding Headroom Does a Plex Server Really Need? Quick Sync vs NVENC vs CPU for 4K HDR</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 18 Aug 2026 07:06:16 +0000</pubDate>
      <link>https://dev.to/john_182319291/how-much-transcoding-headroom-does-a-plex-server-really-need-quick-sync-vs-nvenc-vs-cpu-for-4k-hdr-52cd</link>
      <guid>https://dev.to/john_182319291/how-much-transcoding-headroom-does-a-plex-server-really-need-quick-sync-vs-nvenc-vs-cpu-for-4k-hdr-52cd</guid>
      <description>&lt;p&gt;Size your Plex server for the streams that actually transcode, not for the number of people you host. A single Intel CPU with a recent Quick Sync engine, paired with a Plex Pass subscription so hardware transcoding and HDR tone mapping are unlocked, covers most small client rosters, because the majority of playback sessions on modern clients direct play and never touch the encoder at all. The expensive case is narrow and predictable: 4K HDR source, a client that cannot handle HEVC or HDR, and a bandwidth cap that forces a downscale, which is where tone mapping turns one session into the cost of several. If every client household runs an Apple TV, a recent Nvidia Shield or a current smart TV app, the GPU question mostly disappears and your budget belongs in storage and upload bandwidth instead.&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 agency with 6 to 10 client households on mixed hardware (you support whatever TV the client already owns):&lt;/strong&gt; buy a CPU with an integrated Quick Sync engine and enable hardware transcoding plus HDR tone mapping, because mixed client hardware guarantees a steady trickle of forced transcodes you cannot predict per household.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The studio serving one or two clients on standardised players (every site got the same Apple TV):&lt;/strong&gt; skip the discrete GPU entirely and spend on disk and upload, because standardised clients direct play almost everything and leave the encoder idle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The archive-heavy shop with a 4K HDR remux library (Dolby Vision and HDR10 sources, 60 GB files):&lt;/strong&gt; plan for GPU tone mapping from day one, because CPU only tone mapping collapses to roughly one usable session on hardware that handles several 1080p transcodes comfortably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The team already running on rented infrastructure (a VPS or a managed personal cloud instance with no GPU passthrough):&lt;/strong&gt; enforce direct play through client standards and library encoding rules, because software transcoding on shared vCPUs is the one configuration that fails loudest under concurrency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost-controlled operator replacing a per-seat SaaS bill (you need a three year number before you buy):&lt;/strong&gt; price the Plex Pass licence, the drives and the upload link together, because the server hardware is rarely the largest line in the total.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The remote-first roster (clients watching from outside your network on 20 Mbps uplinks):&lt;/strong&gt; treat upload bandwidth as the real ceiling and configure per user stream limits, because a server that can encode 6 streams cannot deliver 6 streams through a link that only carries 2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff: money spent on transcoding hardware buys tolerance for client devices you do not control, while money spent on standardising those client devices removes the need for the hardware in the first place.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;What Plex is actually doing when a stream will not direct play&lt;/li&gt;
&lt;li&gt;How much transcoding headroom does a client roster really need?&lt;/li&gt;
&lt;li&gt;Direct play, direct stream and transcode: what each one costs your server&lt;/li&gt;
&lt;li&gt;Why does 4K HDR tone mapping cost so much more than a plain 1080p transcode?&lt;/li&gt;
&lt;li&gt;Quick Sync, NVENC or CPU only: which one fits your roster?&lt;/li&gt;
&lt;li&gt;What does Plex Pass unlock, and what does the licence cost over three years?&lt;/li&gt;
&lt;li&gt;How many concurrent 4K HDR sessions can one encoder realistically hold?&lt;/li&gt;
&lt;li&gt;Where the bottleneck moves once the GPU is fast enough: disk, RAM and the transcoder temp directory&lt;/li&gt;
&lt;li&gt;How do you make direct play the default and leave the encoder almost idle?&lt;/li&gt;
&lt;li&gt;Subtitles, audio tracks and the other hidden transcode triggers&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Plex is actually doing when a stream will not direct play
&lt;/h2&gt;

&lt;p&gt;Plex Media Server compares the file against what the client reports it can handle. Container, video codec, audio codec, resolution, bit depth, subtitle format and the bandwidth setting all get checked. If everything matches, the file is sent as bytes and the server does almost nothing. If one attribute fails, Plex rewrites the stream in real time, and the cost depends entirely on which attribute failed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container mismatch:&lt;/strong&gt; the video and audio are copied into a new container without re-encoding, which is cheap and shows in the dashboard as direct stream rather than transcode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video codec or bit depth mismatch:&lt;/strong&gt; a client that cannot decode HEVC Main 10 forces a full decode and re-encode to H.264, which is the expensive path and the one your hardware sizing exists to cover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio codec mismatch:&lt;/strong&gt; a TrueHD or DTS-HD track played on a stereo client is converted to AAC, costing a fraction of a video transcode but still occupying a session slot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bandwidth limit below the source bitrate:&lt;/strong&gt; a client set to 8 Mbps against a 60 Mbps remux triggers a downscale even when the codec was supported, which is the most common surprise on remote sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Burned-in subtitles:&lt;/strong&gt; image based PGS or VOBSUB tracks cannot be passed through, so Plex renders them into every frame and forces a video transcode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open the Plex dashboard at &lt;code&gt;http://your-server:32400/web&lt;/code&gt; during playback and read the session label. It states the reason in plain text, for example "Transcode (hw)" or "Direct Play", and that single string tells you whether your encoder is being used at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much transcoding headroom does a client roster really need?
&lt;/h2&gt;

&lt;p&gt;Count concurrent transcodes, not accounts. Ten client households sharing one server rarely produce ten simultaneous sessions, and the sessions that do overlap are mostly direct play. The number worth sizing against is peak simultaneous forced transcodes, which for a small roster usually sits between 2 and 4 on a weekday evening.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start from peak concurrency, not library size:&lt;/strong&gt; a 40 TB library serves the same load as a 4 TB one, because only the active sessions consume CPU or encoder time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply the published Plex guidance for software transcoding:&lt;/strong&gt; Plex documents roughly 2000 PassMark points per 1080p transcode and around 17000 points for a single 4K transcode, so a CPU scoring 12000 handles about 6 concurrent 1080p sessions and not one 4K session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume a forced transcode rate rather than a worst case:&lt;/strong&gt; if 20 percent of your sessions transcode, a peak of 10 sessions needs headroom for 2, and sizing for 10 buys hardware that idles every night of the year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add one slot of reserve for scheduled tasks:&lt;/strong&gt; Plex runs library scans, thumbnail generation and intro detection in the background, and video preview thumbnails will saturate every core they are given.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track the ceiling per client household, not per person:&lt;/strong&gt; a family watching two rooms at once counts as two sessions, so a roster of 8 households can peak well above 8.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set a hard cap in Settings, Transcoder, using the transcoder quality and maximum simultaneous video transcode fields. A refused stream is a support ticket. A server thrashing at 100 percent CPU is ten of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Direct play, direct stream and transcode: what each one costs your server
&lt;/h2&gt;

&lt;p&gt;The three playback modes differ by an order of magnitude in what they demand from the machine, so knowing which one your roster lands in decides the hardware.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Playback mode&lt;/th&gt;
&lt;th&gt;What the server does&lt;/th&gt;
&lt;th&gt;Practical cost per session&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct Play&lt;/td&gt;
&lt;td&gt;Reads the file and sends the bytes unchanged&lt;/td&gt;
&lt;td&gt;Disk read plus network, a few percent of one core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct Stream&lt;/td&gt;
&lt;td&gt;Repackages into a new container, copies both codecs&lt;/td&gt;
&lt;td&gt;Roughly one tenth of a video transcode, still cheap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio transcode only&lt;/td&gt;
&lt;td&gt;Copies video, re-encodes TrueHD or DTS to AAC&lt;/td&gt;
&lt;td&gt;A fraction of a core, but it holds a session slot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software video transcode&lt;/td&gt;
&lt;td&gt;Full decode and re-encode on CPU&lt;/td&gt;
&lt;td&gt;Around 2000 PassMark points at 1080p, near 17000 at 4K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardware video transcode&lt;/td&gt;
&lt;td&gt;Decode and encode on Quick Sync or NVENC&lt;/td&gt;
&lt;td&gt;Low CPU, bounded instead by encoder engine capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This ordering matters when you choose where the server lives. A home server or a NAS can expose an integrated GPU to the container. A rented VPS usually cannot, which pins you to the software row and its PassMark arithmetic. 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 alongside a self managed VPS, a home server or a NAS as one of the places a Plex instance can run.&lt;/p&gt;

&lt;p&gt;Whichever you pick, verify the mode in practice rather than on paper. A roster that direct plays 90 percent of sessions makes the bottom two rows almost irrelevant, and a roster that never does makes them the entire budget.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why does 4K HDR tone mapping cost so much more than a plain 1080p transcode?
&lt;/h2&gt;

&lt;p&gt;Three separate costs stack on top of each other in the same session, which is why one 4K HDR stream is not simply twice the work of a 1080p one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Four times the pixels:&lt;/strong&gt; 3840x2160 carries exactly 4 times the pixel count of 1920x1080, so decode and encode both scale up before any colour work starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 bit instead of 8 bit:&lt;/strong&gt; HDR sources are HEVC Main 10, so every frame moves through the pipeline at higher bit depth, which raises memory bandwidth and rules out some older fixed function decoders entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tone mapping pass itself:&lt;/strong&gt; converting HDR10 or HLG to SDR is an extra per pixel colour transform inserted between decode and encode, and on CPU it runs in software with no fixed function silicon to fall back on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A downscale on top:&lt;/strong&gt; the client that could not handle HDR usually cannot handle 4K either, so the same session also resizes 2160p to 1080p, adding a scaling filter to the chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dolby Vision profile handling:&lt;/strong&gt; profile 5 and profile 7 sources are not plain HDR10, and depending on the source and client the result can be a fallback to the base layer or visibly incorrect colour, which is a correctness problem rather than a performance one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tone mapping in Plex is a Plex Pass feature, enabled in Settings, Transcoder, and it is worth confirming it is switched on before you blame the hardware. A washed out grey picture on a client is the signature of tone mapping being unavailable, not of an underpowered encoder.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Sync, NVENC or CPU only: which one fits your roster?
&lt;/h2&gt;

&lt;p&gt;All three paths need a Plex Pass licence before hardware acceleration appears in the settings at all. What separates them is capacity per watt, session limits and how easily the device reaches a container.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Encoder path&lt;/th&gt;
&lt;th&gt;Fits which roster&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;Intel Quick Sync, integrated&lt;/td&gt;
&lt;td&gt;Mixed client hardware, 2 to 4 concurrent transcodes&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;/dev/dri/renderD128&lt;/code&gt; passed into the container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nvidia NVENC, GeForce card&lt;/td&gt;
&lt;td&gt;4K HDR heavy libraries, several tone mapped sessions&lt;/td&gt;
&lt;td&gt;Driver imposed concurrent encode session cap, historically 3 on consumer cards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nvidia NVENC, professional card&lt;/td&gt;
&lt;td&gt;Large rosters where the cap is the blocker&lt;/td&gt;
&lt;td&gt;Card cost dwarfs the rest of the build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU only, software&lt;/td&gt;
&lt;td&gt;Standardised clients that direct play nearly everything&lt;/td&gt;
&lt;td&gt;2000 PassMark per 1080p session, near 17000 for one 4K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple VideoToolbox on macOS&lt;/td&gt;
&lt;td&gt;An existing Mac already acting as the server&lt;/td&gt;
&lt;td&gt;Tied to hardware you are unlikely to buy for this purpose&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Quick Sync is the default answer for a small agency because it arrives free with a CPU you were buying anyway, and it handles HEVC Main 10 decode plus tone mapping on recent generations. NVENC earns its place when the session cap matters less than raw throughput, or when the server has no integrated graphics at all.&lt;/p&gt;

&lt;p&gt;CPU only is not a failure state. It is the correct choice when your clients direct play, and it is the only choice on most rented virtual machines. The mistake is buying a card first and discovering afterwards that the encoder sits at zero percent every evening.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does Plex Pass unlock, and what does the licence cost over three years?
&lt;/h2&gt;

&lt;p&gt;The licence is attached to the server owner's account, not to each viewer, so one subscription covers every household you host. That single fact changes the arithmetic for an agency: the per client cost of the licence falls as the roster grows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hardware transcoding:&lt;/strong&gt; Quick Sync, NVENC and VideoToolbox acceleration are gated behind Plex Pass, so without it every forced transcode falls back to the PassMark arithmetic on CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HDR tone mapping:&lt;/strong&gt; the colour conversion pass covered earlier is part of the same gate, which means a Pass-less server plays 4K HDR to an SDR client washed out or not at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote playback for your users:&lt;/strong&gt; playback from outside the server's network is tied to a paid plan, either the owner's Plex Pass or a Remote Watch Pass held by the viewer, which matters when every client household is remote by definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational extras:&lt;/strong&gt; hardware accelerated video preview thumbnails, skip intro and credits detection, and download for offline viewing all sit on the same licence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plex sells the Pass as a monthly tier, an annual tier and a one time lifetime tier. Rather than quoting figures that shift, do the division yourself: divide the current lifetime price by the current annual price to get the breakeven in years. If that number lands below the 3 year horizon you are planning against, and the server is not an experiment you expect to abandon, the lifetime tier is the cheaper line. Check the current numbers at &lt;a href="https://www.plex.tv" rel="noopener noreferrer"&gt;plex.tv&lt;/a&gt; before committing, because the tiers have been repriced before.&lt;/p&gt;




&lt;h2&gt;
  
  
  How many concurrent 4K HDR sessions can one encoder realistically hold?
&lt;/h2&gt;

&lt;p&gt;There is no honest fixed number, because the answer moves with the source bitrate, the encoder generation, the number of encode engines on the die and whether tone mapping and scaling are both active. What there is, instead, is a threshold you can measure in an afternoon.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The speed multiplier is the real limit:&lt;/strong&gt; the Plex dashboard shows each session's transcode speed. Anything above 1.0 means the server is producing video faster than it is being watched. A session sitting at 1.0 or below is buffering, and that is your ceiling regardless of what the CPU graph says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add sessions one at a time:&lt;/strong&gt; start the heaviest 4K HDR file you host, note the speed, then start a second on another client, then a third. The session count at which any stream drops under roughly 2.0 is your safe working number, because that margin absorbs seeks and background scans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode engines cap you before the shaders do:&lt;/strong&gt; the tone mapping pass runs on the GPU's compute units, but the encode itself runs on fixed function silicon, and a card with one encode engine saturates that engine while the rest of the GPU idles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumer driver caps apply before capacity does:&lt;/strong&gt; on GeForce hardware the concurrent encode session limit can stop you at a count the silicon would otherwise handle comfortably.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do this measurement on your own files, not on someone else's chart. A 60 Mbps Dolby Vision remux and a 25 Mbps HDR10 web release place very different loads on the same encoder, and your library only contains one of them in quantity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the bottleneck moves once the GPU is fast enough: disk, RAM and the transcoder temp directory
&lt;/h2&gt;

&lt;p&gt;Fix the encoder and the queue simply forms somewhere else. On a small roster the next three constraints are storage seeks, the metadata database and the scratch space Plex writes segments into.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The transcoder temporary directory:&lt;/strong&gt; every transcode writes HLS segments to the path set in Settings, Transcoder, which defaults inside the container to &lt;code&gt;/transcode&lt;/code&gt;. Point it at RAM with a Docker &lt;code&gt;--tmpfs /transcode&lt;/code&gt; mount and budget memory per concurrent session, then watch actual usage before fixing the size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The library database on spinning disk:&lt;/strong&gt; &lt;code&gt;com.plexapp.plugins.library.db&lt;/code&gt; handles constant small reads during browsing, and a 5400 rpm drive shared with playback makes the client UI feel broken while the video itself streams fine. Keep the config volume on an SSD and the media on bulk disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seek contention across concurrent reads:&lt;/strong&gt; 4 sessions pulling from one mechanical drive turn sequential reads into random ones, which shows up as buffering on direct play sessions that use almost no CPU at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RAM for background tasks:&lt;/strong&gt; scans, thumbnail generation and tone mapped sessions all want memory at once, and a tmpfs scratch directory competes with them directly for the same pool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where you run the server decides how much of this you control. A NAS or home server lets you split SSD and bulk storage yourself, a VPS usually gives you one storage class, and a Yundera instance is another option in that same set, alongside a self managed VPS, a home server or a NAS.&lt;/p&gt;

&lt;p&gt;Measure with &lt;code&gt;docker stats&lt;/code&gt; and &lt;code&gt;iostat&lt;/code&gt; during a real evening peak. Guessing at this layer wastes more money than the GPU choice did.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you make direct play the default and leave the encoder almost idle?
&lt;/h2&gt;

&lt;p&gt;Every forced transcode is a client capability gap you can close once and stop paying for nightly. Four moves cover most of it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standardise the player, not the file:&lt;/strong&gt; an Apple TV 4K or an Nvidia Shield TV Pro handles HEVC Main 10, HDR and most audio formats natively. Issuing the same box to each client household converts a permanent transcoding cost into a one time hardware cost, and it is the single change with the largest effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set quality to Original on each client:&lt;/strong&gt; in the Plex client under Settings, Video Quality, leaving remote quality at a bitrate cap forces a downscale even when the codec matched. Setting it to Original or Maximum removes the most common avoidable transcode, provided the connection can carry it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a compatibility encode for the awkward files:&lt;/strong&gt; where a source is unusual, store an H.264 High profile 8 bit version alongside the original with an AAC stereo track. Plex offers multiple versions of the same item, and a browser or old smart TV picks the one it can play instead of asking the encoder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use SRT sidecar subtitles wherever possible:&lt;/strong&gt; text subtitles are passed through, while image based tracks are burned in and force a full video transcode, so converting a library's subtitle strategy removes a whole class of load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit per user quality limits:&lt;/strong&gt; the server side setting under Settings, Users, caps a household globally. Check that no leftover 4 Mbps limit is quietly transcoding every session for a client on a fast link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Direct play is a configuration outcome, not a hardware feature. Most rosters get there with client choices alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Subtitles, audio tracks and the other hidden transcode triggers
&lt;/h2&gt;

&lt;p&gt;Video codec mismatches are the obvious cause. The awkward cases are the ones where the video was perfectly compatible and something small next to it forced a full re-encode anyway.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hidden trigger&lt;/th&gt;
&lt;th&gt;What Plex is forced to do&lt;/th&gt;
&lt;th&gt;Cheapest fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PGS, VOBSUB or styled ASS subtitles&lt;/td&gt;
&lt;td&gt;Render text into every frame, forcing video transcode&lt;/td&gt;
&lt;td&gt;Ship an SRT sidecar next to the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7.1 TrueHD or DTS-HD MA on a stereo client&lt;/td&gt;
&lt;td&gt;Downmix and re-encode audio, holding a session slot&lt;/td&gt;
&lt;td&gt;Add a second AAC 2.0 track to the file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dolby Atmos in EAC3 to an older receiver&lt;/td&gt;
&lt;td&gt;Convert audio, sometimes drag video along on strict clients&lt;/td&gt;
&lt;td&gt;Keep a plain AC3 5.1 track as fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interlaced 1080i broadcast recordings&lt;/td&gt;
&lt;td&gt;Deinterlace on top of the encode, raising cost per session&lt;/td&gt;
&lt;td&gt;Deinterlace once during ingest, store progressive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H.264 above level 4.1 on constrained devices&lt;/td&gt;
&lt;td&gt;Re-encode video that the codec name suggested was fine&lt;/td&gt;
&lt;td&gt;Encode compatibility versions at level 4.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MKV container on some browser clients&lt;/td&gt;
&lt;td&gt;Remux to MP4, cheap but still a pipeline&lt;/td&gt;
&lt;td&gt;Accept it, or store an MP4 version&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is consistent. A file is not compatible or incompatible as a whole. It is a bundle of streams, and any one of them can force the expensive path while the other four were fine.&lt;/p&gt;

&lt;p&gt;Audit this once per library rather than per file. Sort by subtitle format and audio codec, fix the two or three patterns that dominate, and a measurable share of your nightly transcodes disappears without touching the hardware budget.&lt;/p&gt;

</description>
      <category>plex</category>
      <category>selfhosted</category>
      <category>hardware</category>
      <category>devops</category>
    </item>
    <item>
      <title>Private Docusaurus: What Actually Gates the Site, and What Still Leaks Through sitemap.xml, Search and Source Maps</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 15 Aug 2026 07:05:34 +0000</pubDate>
      <link>https://dev.to/john_182319291/private-docusaurus-what-actually-gates-the-site-and-what-still-leaks-through-sitemapxml-search-2ka0</link>
      <guid>https://dev.to/john_182319291/private-docusaurus-what-actually-gates-the-site-and-what-still-leaks-through-sitemapxml-search-2ka0</guid>
      <description>&lt;p&gt;Docusaurus has no login, no user table and no server-side session, because a production build is a folder of static HTML, JavaScript and JSON that any web server can hand out. Every gate you add lives in front of it: HTTP basic auth, a forward-auth proxy, or an access rule on the static host. That gate is genuinely effective, since an unauthenticated request never reaches a single file. The real risk is not the gate failing, it is the build itself, because &lt;code&gt;sitemap.xml&lt;/code&gt;, the prebuilt search index and unstripped source maps each contain a readable copy of what you thought you were hiding, so one misrouted path or one cached response leaks the whole set at once.&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;Family running a shared household wiki&lt;/strong&gt; (passwords, router notes, insurance scans in a Docusaurus site on a home server): use a forward-auth proxy with real accounts, not one shared basic-auth password, because a single credential cannot be revoked for one person only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solo maintainer with a private notes site&lt;/strong&gt; (one person, one laptop, one VPS): HTTP basic auth over HTTPS is proportionate, provided you disable the sitemap plugin and ship no source maps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small team with an internal handbook&lt;/strong&gt; (8 to 20 people, staff turnover): forward auth against an identity provider, so account removal is one action and not a redeploy of a password file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consultancy under a client NDA&lt;/strong&gt; (contract requires access logs and named users): forward auth plus per-request logging, since basic auth gives you no reliable identity in the access log beyond a shared username.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open source project with a private staging site&lt;/strong&gt; (public docs plus an unreleased version branch): keep the private content in a separate build entirely, because &lt;code&gt;noIndex&lt;/code&gt; and unlisted pages hide pages from indexes, not from anyone who requests the URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff: basic auth costs one configuration block and no moving parts but gives you one shared secret and no revocation, while forward auth gives you named accounts, logout and audit trails at the cost of running an identity service that must stay up for anyone to read a single page.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;What does Docusaurus actually serve, and why is there nothing to log into?&lt;/li&gt;
&lt;li&gt;Where can you put the authentication gate in front of a Docusaurus build?&lt;/li&gt;
&lt;li&gt;Is HTTP basic auth enough for a private Docusaurus site?&lt;/li&gt;
&lt;li&gt;Basic auth against forward auth against static host rules: what each one costs&lt;/li&gt;
&lt;li&gt;What ends up in the build folder that you did not expect to publish?&lt;/li&gt;
&lt;li&gt;How much does sitemap.xml leak, and should you disable the plugin?&lt;/li&gt;
&lt;li&gt;Does the prebuilt local search index expose the full text of your docs?&lt;/li&gt;
&lt;li&gt;Are source maps in a Docusaurus production build a real risk?&lt;/li&gt;
&lt;li&gt;Should unauthenticated visitors get 401, 403 or 404?&lt;/li&gt;
&lt;li&gt;Which cache and CDN headers decide whether a private page gets stored publicly?&lt;/li&gt;
&lt;li&gt;How do you keep drafts, unlisted pages and old doc versions out of the build?&lt;/li&gt;
&lt;li&gt;What breaks in Docusaurus once it sits behind an auth proxy?&lt;/li&gt;
&lt;li&gt;Which gate should each profile choose?&lt;/li&gt;
&lt;li&gt;Self-hosting, hosting location and data sovereignty for a private Docusaurus site&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What does Docusaurus actually serve, and why is there nothing to log into?
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;npm run build&lt;/code&gt; and Docusaurus 3.x writes a &lt;code&gt;build/&lt;/code&gt; directory. That directory is the entire application. There is no PHP process, no database connection string, no session store and no user table, so there is no code path where a password could be checked. The React you wrote is prerendered to HTML at build time and rehydrated in the browser, which means the server's only job is to return files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the build directory contains:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prerendered HTML per route&lt;/strong&gt;: every doc page exists as a complete &lt;code&gt;index.html&lt;/code&gt; on disk, readable with &lt;code&gt;curl&lt;/code&gt; and no JavaScript engine at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A client JavaScript bundle&lt;/strong&gt;: the hashed &lt;code&gt;assets/js/&lt;/code&gt; files that hydrate the page, plus a runtime manifest listing every route the site knows about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static assets copied verbatim&lt;/strong&gt;: anything you dropped in &lt;code&gt;static/&lt;/code&gt; is served at the site root, including PDFs and images you may have forgotten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generated metadata files&lt;/strong&gt;: &lt;code&gt;sitemap.xml&lt;/code&gt; from &lt;code&gt;@docusaurus/plugin-sitemap&lt;/code&gt;, &lt;code&gt;robots.txt&lt;/code&gt;, RSS and Atom feeds from the blog plugin, and search index JSON if you use a local search plugin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No server-side configuration&lt;/strong&gt;: &lt;code&gt;docusaurus.config.js&lt;/code&gt; runs during the build, not at request time, so it cannot make an access decision about an incoming request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical consequence is blunt. &lt;code&gt;npx docusaurus serve&lt;/code&gt; and &lt;code&gt;python3 -m http.server&lt;/code&gt; are equally unauthenticated, and so is Nginx, Caddy or an S3 bucket pointed at the same folder. Every access control you get comes from the layer in front of those files, never from Docusaurus itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where can you put the authentication gate in front of a Docusaurus build?
&lt;/h2&gt;

&lt;p&gt;There are four layers where a request can be stopped, and you should pick exactly one as the authoritative gate. Two overlapping gates usually mean one of them is misconfigured and nobody notices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The web server that serves the files&lt;/strong&gt;: Nginx &lt;code&gt;auth_basic&lt;/code&gt; with an &lt;code&gt;htpasswd&lt;/code&gt; file, or a Caddy &lt;code&gt;basic_auth&lt;/code&gt; directive, checks credentials before it opens &lt;code&gt;build/index.html&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reverse proxy in front of that server&lt;/strong&gt;: Traefik, Caddy or Nginx running &lt;code&gt;auth_request&lt;/code&gt; against a forward-auth service such as Authelia or oauth2-proxy, which returns 200 or 401 for every path including &lt;code&gt;/sitemap.xml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The static hosting platform&lt;/strong&gt;: Netlify password protection, Cloudflare Access or an S3 bucket policy, decided outside your repository and outside your build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The network boundary&lt;/strong&gt;: a WireGuard or Tailscale tunnel, where the site listens on a private address and no gate exists at HTTP level at all.&lt;/p&gt;

&lt;p&gt;Where that stack physically runs is a separate decision: a rented VPS, a home server or NAS on your own connection, or a managed 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. On any of those, the gate still belongs to the proxy or web server, because Docusaurus contributes nothing.&lt;/p&gt;

&lt;p&gt;One warning about the development server. &lt;code&gt;npm start&lt;/code&gt; binds port 3000 with hot reload, no gate and no build step, and it happily serves your unfinished private pages to anything that can reach that port. Never expose it, even briefly, and never treat it as a preview environment for people outside the household or team.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is HTTP basic auth enough for a private Docusaurus site?
&lt;/h2&gt;

&lt;p&gt;For one person, yes. For a household or a team, it degrades badly, and the failure is social rather than cryptographic. Over HTTPS the credential is protected in transit, so the weaknesses are all about lifecycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The password is shared, so revocation is all or nothing&lt;/strong&gt;: removing one person's access means changing the single entry in &lt;code&gt;.htpasswd&lt;/code&gt; and telling everyone else the new password, which in practice nobody does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hashing depends on how you generated the file&lt;/strong&gt;: run &lt;code&gt;htpasswd -B -c /etc/nginx/.htpasswd alice&lt;/code&gt; to force bcrypt, because the default on older &lt;code&gt;htpasswd&lt;/code&gt; builds is MD5 based crypt, and Nginx will accept both without warning you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no logout&lt;/strong&gt;: the browser caches the credential for the origin until the tab or the browser is closed, so a family laptop left open stays authenticated for anyone who picks it up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access logs give you no real identity&lt;/strong&gt;: every request carries the same username, so a log line proves someone had the password, not who.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automation gets awkward&lt;/strong&gt;: any script, RSS reader or link checker now needs the credential embedded, and &lt;code&gt;curl -u user:pass https://docs.example.com/sitemap.xml&lt;/code&gt; in a shell history file is a common way for it to escape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompts confuse non-technical readers&lt;/strong&gt;: the native browser dialog offers no branding, no password reset and no explanation, which generates support requests from the exact people you set the site up for.&lt;/p&gt;

&lt;p&gt;Use basic auth when the reader count is 1 to 3 and stable. Above that, the shared secret becomes the weakest part of the system.&lt;/p&gt;




&lt;h2&gt;
  
  
  Basic auth against forward auth against static host rules: what each one costs
&lt;/h2&gt;

&lt;p&gt;The three approaches differ less in strength than in what they demand from you every month after setup. Compare them on operational cost, not on theoretical security.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it costs to run&lt;/th&gt;
&lt;th&gt;Where it breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web server basic auth (Nginx &lt;code&gt;auth_basic&lt;/code&gt;, Caddy &lt;code&gt;basic_auth&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;One config block and one &lt;code&gt;.htpasswd&lt;/code&gt; file, no extra process, no extra memory&lt;/td&gt;
&lt;td&gt;Shared secret, no logout, no per-person revocation, credential leaks into scripts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forward auth (Authelia or oauth2-proxy behind Traefik, Caddy or Nginx &lt;code&gt;auth_request&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;An extra container plus its config and session storage, and a second thing to upgrade&lt;/td&gt;
&lt;td&gt;If the auth service is down, every page returns 401 or 502, including your own recovery notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity provider SSO (Cloudflare Access, Google or GitHub OIDC)&lt;/td&gt;
&lt;td&gt;No local user database, but a hard dependency on a third party for every page load&lt;/td&gt;
&lt;td&gt;Your reader list lives outside your server, and access decisions are logged elsewhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network gate only (WireGuard, Tailscale)&lt;/td&gt;
&lt;td&gt;Client software on every device, including phones and a television browser&lt;/td&gt;
&lt;td&gt;No gate once a device is on the network, so a borrowed laptop reads everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static platform password (Netlify, Vercel)&lt;/td&gt;
&lt;td&gt;Zero server administration&lt;/td&gt;
&lt;td&gt;One password per site, and the build output sits on a shared multi-tenant platform&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your hosting choice cuts across this table rather than replacing it. A rented VPS, a home server, a NAS or a Yundera instance can each run any of the first four rows, since all of them are just containers and a proxy in front of a folder. Pick the row you will still maintain in twelve months.&lt;/p&gt;




&lt;h2&gt;
  
  
  What ends up in the build folder that you did not expect to publish?
&lt;/h2&gt;

&lt;p&gt;Before you trust the gate, audit what is behind it. The reliable method is to grep the output rather than to reason about the config. Run &lt;code&gt;grep -ri "your-secret-string" build/&lt;/code&gt; after every build, and add it to your deploy script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anything in &lt;code&gt;customFields&lt;/code&gt;&lt;/strong&gt;: values you put under &lt;code&gt;customFields&lt;/code&gt; in &lt;code&gt;docusaurus.config.js&lt;/code&gt; are serialised into the client bundle so &lt;code&gt;useDocusaurusContext()&lt;/code&gt; can read them, which means an internal API host or a support email is now a string in &lt;code&gt;assets/js/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment variables you inlined&lt;/strong&gt;: only variables the build actually references get baked in, but once referenced they are literal text in the bundle, and a &lt;code&gt;.env&lt;/code&gt; file loaded at build time is not a secret store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Author and contributor metadata&lt;/strong&gt;: &lt;code&gt;authors.yml&lt;/code&gt; addresses, plus last update author and timestamp when &lt;code&gt;showLastUpdateAuthor&lt;/code&gt; is enabled, expose real names and a work rhythm you may not want visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Files you forgot in &lt;code&gt;static/&lt;/code&gt;&lt;/strong&gt;: everything in that directory is copied to the site root untouched, so an old invoice PDF or a screenshot with a token in the URL bar ships with the site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Broken link and draft residue&lt;/strong&gt;: pages you unlinked from the sidebar are still built and still reachable at their route, because &lt;code&gt;onBrokenLinks&lt;/code&gt; only checks links, not orphan pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comments in your MDX&lt;/strong&gt;: HTML comments in Markdown survive into the prerendered HTML, so a note like &lt;code&gt;&amp;lt;!-- ask legal before publishing --&amp;gt;&lt;/code&gt; is served verbatim.&lt;/p&gt;

&lt;p&gt;None of this is a Docusaurus defect. It is a static site doing exactly what you told it to do, and the audit is yours to run.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much does sitemap.xml leak, and should you disable the plugin?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@docusaurus/plugin-sitemap&lt;/code&gt; ships inside &lt;code&gt;@docusaurus/preset-classic&lt;/code&gt;, so a default site publishes &lt;code&gt;/sitemap.xml&lt;/code&gt; whether or not you asked for it. That file is a complete route inventory in plain XML. If your gate ever misfires on one path, an attacker does not have to guess URLs, because you handed over the full list in one request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It enumerates every route, not every page you linked&lt;/strong&gt;: orphan pages that no sidebar points at still appear, which is exactly the content people assume is hidden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Route names carry meaning on their own&lt;/strong&gt;: &lt;code&gt;/docs/finance/mortgage-2029-refinance&lt;/code&gt; tells a reader most of what they wanted before they read a word of the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is the first path any scanner requests&lt;/strong&gt;: &lt;code&gt;sitemap.xml&lt;/code&gt; and &lt;code&gt;robots.txt&lt;/code&gt; sit at the top of every wordlist, so this is not an obscure corner of the site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blog feeds duplicate the problem&lt;/strong&gt;: &lt;code&gt;/blog/rss.xml&lt;/code&gt; and &lt;code&gt;/blog/atom.xml&lt;/code&gt; include post titles and, depending on your feed options, the full body text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disabling it is one line&lt;/strong&gt;: set &lt;code&gt;sitemap: false&lt;/code&gt; in the &lt;code&gt;preset-classic&lt;/code&gt; options in &lt;code&gt;docusaurus.config.js&lt;/code&gt;, or use the plugin's &lt;code&gt;ignorePatterns&lt;/code&gt; to exclude specific route globs while keeping a sitemap for a public subset.&lt;/p&gt;

&lt;p&gt;For a fully private site, disable the sitemap and the blog feeds outright. You gain nothing from them, since no search engine should be crawling a gated site anyway. For a mixed site with public and private areas, do not rely on &lt;code&gt;ignorePatterns&lt;/code&gt; as a security control, because it hides routes from a file, not from anyone who requests them directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Does the prebuilt local search index expose the full text of your docs?
&lt;/h2&gt;

&lt;p&gt;Yes, and this is the leak people underestimate. Offline search plugins such as &lt;code&gt;@easyops-cn/docusaurus-search-local&lt;/code&gt; build an index at compile time and ship it as static JSON in the &lt;code&gt;build/&lt;/code&gt; output, because the search runs in the reader's browser with no server involved. That index is not a list of keywords. Depending on the plugin's configuration it contains headings, section titles and enough surrounding body text to render a preview snippet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One request returns the corpus&lt;/strong&gt;: fetching the index file gives a reader the searchable content of every indexed page without visiting a single page URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It survives route level rules&lt;/strong&gt;: if you blocked &lt;code&gt;/docs/private/&lt;/code&gt; at the proxy but left the index file open, the content of those pages is still in the JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check it yourself&lt;/strong&gt;: run &lt;code&gt;ls -la build/search-index*.json&lt;/code&gt; or grep the build output for a phrase that only appears on a sensitive page, then confirm which path serves it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosted search moves the copy, it does not remove it&lt;/strong&gt;: Algolia DocSearch keeps your content on Algolia's infrastructure, and the search-only API key travels in the client bundle, so a gated site with hosted search has published its text to a third party by design.&lt;/p&gt;

&lt;p&gt;The safe configurations are narrow. Either gate the whole origin so the index file is behind the same 401 as everything else, or drop search entirely on the private portion and keep it for the public one. Splitting a mixed site into two builds is usually less work than proving that one index contains nothing sensitive, and it stays true after the next twenty pages get written.&lt;/p&gt;




&lt;h2&gt;
  
  
  Are source maps in a Docusaurus production build a real risk?
&lt;/h2&gt;

&lt;p&gt;Lower than the search index, higher than zero, and the honest answer is that you should check rather than assume. Verify with &lt;code&gt;grep -r sourceMappingURL build/&lt;/code&gt; and &lt;code&gt;find build -name "*.map"&lt;/code&gt;. If both come back empty, this section costs you nothing. If they do not, decide what those files contain before you ship them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A docs site leaks source, not documents&lt;/strong&gt;: your page content is already in the HTML behind the gate, so a map mostly reveals JSX, not text a reader could not otherwise reach once authenticated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swizzled components are the exception&lt;/strong&gt;: ejected theme components under &lt;code&gt;src/theme/&lt;/code&gt; often carry internal hostnames, feature flags and comments like a note about which client the layout was copied from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom React pages carry logic&lt;/strong&gt;: a &lt;code&gt;src/pages/&lt;/code&gt; component that calls an internal API embeds that endpoint, and the map restores the original variable names that minification had obscured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maps enlarge the transfer&lt;/strong&gt;: &lt;code&gt;.map&lt;/code&gt; files are typically several times the size of the minified bundle, which matters on a home connection serving a family from a residential upload link.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third party maps come along for free&lt;/strong&gt;: dependencies sometimes publish their own maps, so a clean project can still emit files you never wrote.&lt;/p&gt;

&lt;p&gt;The control is the same either way. Configure &lt;code&gt;configureWebpack&lt;/code&gt; in a small local plugin to set &lt;code&gt;devtool: false&lt;/code&gt; for production, then re-run the two commands above to confirm. Do not rely on the proxy to block &lt;code&gt;*.map&lt;/code&gt; by pattern, because a rule that lists extensions is one new file type away from being wrong, and generating nothing is simpler than filtering everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Should unauthenticated visitors get 401, 403 or 404?
&lt;/h2&gt;

&lt;p&gt;The status code decides how much a stranger learns from a single request. It also decides whether your own tooling behaves, so pick deliberately rather than accepting the proxy default.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;What it tells an unauthenticated visitor&lt;/th&gt;
&lt;th&gt;When it is the right choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;401 with &lt;code&gt;WWW-Authenticate: Basic&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;A site exists here and it wants a password, and the browser shows the native dialog&lt;/td&gt;
&lt;td&gt;Basic auth for 1 to 3 known readers who expect the prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;302 to an identity provider&lt;/td&gt;
&lt;td&gt;A site exists, plus the name of your login service in the redirect target&lt;/td&gt;
&lt;td&gt;Forward auth, where readers need a real login form and a logout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403 with a static body&lt;/td&gt;
&lt;td&gt;Something exists and you are not welcome, with no way to authenticate from the browser&lt;/td&gt;
&lt;td&gt;Blocking a subset of paths for readers who are already logged in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;404 for every gated path&lt;/td&gt;
&lt;td&gt;Nothing here, which hides the site from casual scanners&lt;/td&gt;
&lt;td&gt;Sites you want absent from view, at the cost of confusing legitimate readers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One Docusaurus specific detail matters. Your build contains &lt;code&gt;build/404.html&lt;/code&gt;, a branded page with your site title and navbar. If the proxy passes unauthenticated requests through and lets Docusaurus answer, a stranger sees your site name and menu structure on what you thought was a blank refusal. Configure the error page at the proxy instead, and test with &lt;code&gt;curl -i https://docs.example.com/nonexistent&lt;/code&gt; to see which layer answered.&lt;/p&gt;

&lt;p&gt;Prefer 401 or a redirect for a household site. Silent 404s generate the phone call that starts with a family member insisting the link is broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which cache and CDN headers decide whether a private page gets stored publicly?
&lt;/h2&gt;

&lt;p&gt;A gate that returns the right page to the right person still fails if something between you and the reader keeps a copy. Docusaurus produces content-hashed filenames under &lt;code&gt;assets/&lt;/code&gt;, which invites aggressive caching, and that default is written for public documentation sites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Cache-Control: private&lt;/code&gt;&lt;/strong&gt;: permits the browser to store the response but forbids any shared cache from doing so, which is the minimum for every HTML route on a gated site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Cache-Control: no-store&lt;/code&gt; for HTML&lt;/strong&gt;: prevents the response being written to disk at all, worth using on a shared family laptop where the browser profile is not per person.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Vary: Cookie&lt;/code&gt; or &lt;code&gt;Vary: Authorization&lt;/code&gt;&lt;/strong&gt;: tells intermediaries that the response depends on credentials, so a cached copy is not served to the next requester who lacks them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CDN cache rules override your headers&lt;/strong&gt;: Cloudflare and similar platforms can cache by file extension regardless of origin headers, so check the rule set rather than trusting &lt;code&gt;Cache-Control&lt;/code&gt; alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hashed assets are still content&lt;/strong&gt;: an &lt;code&gt;assets/js/&lt;/code&gt; chunk contains prerendered strings from your pages, so caching it publicly leaks text even when the HTML route is protected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search engine caches are permanent enough&lt;/strong&gt;: anything crawled while the gate was off can persist in third party copies long after you fix the configuration.&lt;/p&gt;

&lt;p&gt;Verify with one command per route class: &lt;code&gt;curl -sI https://docs.example.com/docs/intro | grep -i "cache-control\|vary"&lt;/code&gt;, then repeat against a hashed asset path. If the two disagree, the asset is usually the one set to a long public lifetime, and that is the copy that outlives your mistake.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you keep drafts, unlisted pages and old doc versions out of the build?
&lt;/h2&gt;

&lt;p&gt;The distinction that trips people up is between not built and not listed. Only one of them removes content from the folder a stranger could reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;draft: true&lt;/code&gt; removes the page from production&lt;/strong&gt;: a Markdown file with that front matter is skipped by &lt;code&gt;npm run build&lt;/code&gt; while still appearing in &lt;code&gt;npm start&lt;/code&gt;, so the route does not exist in the output at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;unlisted: true&lt;/code&gt; keeps the page and hides the signposts&lt;/strong&gt;: the page is built and served at its URL, excluded from the sidebar, sitemap and search index, and marked with a banner, which is obscurity rather than access control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Underscore prefixes exclude by convention&lt;/strong&gt;: the docs plugin ignores files and folders starting with &lt;code&gt;_&lt;/code&gt; by default, which is how partials stay out, and it works for a &lt;code&gt;_scratch/&lt;/code&gt; directory too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;exclude&lt;/code&gt; patterns give you explicit globs&lt;/strong&gt;: set them in the docs or blog plugin options in &lt;code&gt;docusaurus.config.js&lt;/code&gt; when a naming convention is not enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;onlyIncludeVersions&lt;/code&gt; and &lt;code&gt;includeCurrentVersion: false&lt;/code&gt;&lt;/strong&gt;: versioned sites otherwise build every folder under &lt;code&gt;versioned_docs/&lt;/code&gt;, so an internal draft of the next version ships alongside the released one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleted content lives on in Git&lt;/strong&gt;: removing a page from &lt;code&gt;docs/&lt;/code&gt; does not remove it from history, so anyone with repository access can read it even after the site stops serving it.&lt;/p&gt;

&lt;p&gt;Audit the result rather than the intent. Run &lt;code&gt;find build -name "*.html" | wc -l&lt;/code&gt; and compare it against the pages you expect, then spot check the paths you assumed were gone. A count that exceeds your mental model by twenty files usually means a versioned folder you forgot.&lt;/p&gt;




&lt;h2&gt;
  
  
  What breaks in Docusaurus once it sits behind an auth proxy?
&lt;/h2&gt;

&lt;p&gt;A static site behind a gate hits problems no public docs site ever sees, and most of them appear only after a session expires rather than on the day you set it up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client side navigation fails silently&lt;/strong&gt;: after the first page load, Docusaurus fetches JSON and JavaScript chunks for the next route, so an expired session returns a login redirect where a chunk was expected and the reader gets a blank pane or a chunk load error instead of a prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;baseUrl&lt;/code&gt; and proxy paths must agree&lt;/strong&gt;: if the proxy mounts the site at &lt;code&gt;/docs/&lt;/code&gt; but &lt;code&gt;docusaurus.config.js&lt;/code&gt; still sets &lt;code&gt;baseUrl: '/'&lt;/code&gt;, every asset resolves one level wrong and the page renders unstyled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;trailingSlash&lt;/code&gt; causes redirect loops&lt;/strong&gt;: a proxy rule that appends a slash while the config strips it, or the reverse, produces a cycle that ends in a browser error rather than a 401.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hosted search stops working&lt;/strong&gt;: the Algolia crawler cannot reach a gated site, so DocSearch returns nothing and you must move to a local index or drop search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Link previews disappear&lt;/strong&gt;: Slack, Discord and Signal unfurl by fetching the URL unauthenticated, so a shared link shows the login page title or nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PWA plugin can cache the wrong response&lt;/strong&gt;: a service worker that stores a 302 or a login page will keep serving it after you fix the session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External CI link checkers fail&lt;/strong&gt;: any pipeline step that crawls the published site needs credentials, and embedding them there recreates the leak you closed.&lt;/p&gt;

&lt;p&gt;Test the expiry path deliberately: log in, wait for the session to lapse, then click an internal link.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which gate should each profile choose?
&lt;/h2&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;Family of 4 sharing household records&lt;/td&gt;
&lt;td&gt;Forward auth with one account per adult&lt;/td&gt;
&lt;td&gt;Revoke one person without changing everyone's password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Solo maintainer, private notes&lt;/td&gt;
&lt;td&gt;Basic auth with a bcrypt &lt;code&gt;.htpasswd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One reader, no lifecycle problem to solve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team of 8 to 20 with staff turnover&lt;/td&gt;
&lt;td&gt;Forward auth against an existing identity provider&lt;/td&gt;
&lt;td&gt;Offboarding is a directory change, not a redeploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consultancy under an NDA&lt;/td&gt;
&lt;td&gt;Forward auth plus per-request access logs&lt;/td&gt;
&lt;td&gt;The contract needs named users, not a shared secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed public and private docs&lt;/td&gt;
&lt;td&gt;Two separate builds on two hostnames&lt;/td&gt;
&lt;td&gt;One index and one sitemap per audience, no path rules to get wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Technical household, all devices managed&lt;/td&gt;
&lt;td&gt;Network gate via WireGuard or Tailscale&lt;/td&gt;
&lt;td&gt;No HTTP credential to leak, at the cost of client setup on every device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Site with an existing CDN in front&lt;/td&gt;
&lt;td&gt;Gate at the CDN, then lock the origin&lt;/td&gt;
&lt;td&gt;Two gates on different layers drift apart and one goes stale&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Choosing basic auth&lt;/strong&gt;: generate the file with &lt;code&gt;htpasswd -B&lt;/code&gt;, set &lt;code&gt;Cache-Control: private&lt;/code&gt; on HTML, disable the sitemap, confirm with &lt;code&gt;curl -i&lt;/code&gt; that an unauthenticated request returns 401.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choosing forward auth&lt;/strong&gt;: stand up the auth service first, protect a throwaway path, verify the expired session path in a browser, only then point it at the docs host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choosing two builds&lt;/strong&gt;: split the content directories, give each its own &lt;code&gt;docusaurus.config.js&lt;/code&gt;, and grep the private build for a phrase that must never appear in the public one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choosing a network gate&lt;/strong&gt;: install clients on every device that will read the site, including phones, before you remove any HTTP level gate.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Self-hosting, hosting location and data sovereignty for a private Docusaurus site
&lt;/h2&gt;

&lt;p&gt;A private wiki is a pile of plain files, so the question of who else can read them is decided by where the disk sits and who administers it. A static build makes this unusually clear: there is no vendor database holding your content, only a directory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Advantages of keeping the build on your own server:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No third party copy of the text&lt;/strong&gt;: the content never leaves your disk, unlike hosted search or a platform preview environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The access log belongs to you&lt;/strong&gt;: you can see who requested &lt;code&gt;/docs/finance/&lt;/code&gt; and keep or delete that record on your own schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jurisdiction follows the hardware&lt;/strong&gt;: a box in the country you live in avoids questions about cross border data transfer for family documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No platform build step&lt;/strong&gt;: nothing needs read access to your Git repository, which is where the unreleased and deleted pages live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gate is yours to configure&lt;/strong&gt;: proxy rules, session lifetime and status codes are all under your control rather than a provider's defaults.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can run this on a rented VPS, a home server, a NAS, or a managed box. Yundera is a managed Personal Cloud Server, built on CasaOS, where each app is reachable on a public HTTPS subdomain via NSL.SH mesh routing, so no static IP, no port forwarding and no manual TLS certificate setup are required. A public hostname makes the gate more important, not less, since anyone can reach the door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-publication checklist:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Confirm the origin is not reachable directly&lt;/strong&gt;, bypassing the proxy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify TLS covers every hostname&lt;/strong&gt; the site answers on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up &lt;code&gt;build/&lt;/code&gt; and the content repository separately&lt;/strong&gt;, and test one restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record who holds administrative access&lt;/strong&gt; to the server and to the auth service.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docusaurus</category>
      <category>selfhosted</category>
      <category>security</category>
      <category>react</category>
    </item>
    <item>
      <title>Jellyfin Buffering With 3 or 4 Family Streams: The Setup Mistakes That Cause It, and How to Fix Each One</title>
      <dc:creator>John</dc:creator>
      <pubDate>Thu, 13 Aug 2026 07:06:33 +0000</pubDate>
      <link>https://dev.to/john_182319291/jellyfin-buffering-with-3-or-4-family-streams-the-setup-mistakes-that-cause-it-and-how-to-fix-1d62</link>
      <guid>https://dev.to/john_182319291/jellyfin-buffering-with-3-or-4-family-streams-the-setup-mistakes-that-cause-it-and-how-to-fix-1d62</guid>
      <description>&lt;p&gt;Almost every "Jellyfin cannot handle four people at once" problem is a transcoding problem, not a bandwidth or server power problem. If your CPU pins at 100% with three or four viewers, the server is re-encoding video in software because hardware acceleration is off, the GPU is not visible inside the container, a subtitle track is being burned in, or a remote client asked for a lower bitrate than the file. Fix the transcode path and the same hardware that choked on two streams will usually carry four or more. The work is configuration, not new hardware, and you can verify each fix from the Jellyfin dashboard in minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR by household setup:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One 4K TV in the living room, everything else on phones (a couple with an Apple TV and two iPhones):&lt;/strong&gt; enable hardware acceleration and keep a second 1080p copy of your most watched films, because the phones will never direct play a 4K HDR remux.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two parents plus two teenagers streaming to different rooms at the same time (a four person house on one server):&lt;/strong&gt; set per user bitrate limits and enable hardware decoding and encoding first, since concurrent software transcodes are what saturates the CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grandparents watching remotely over a home upload link (one remote viewer, one local):&lt;/strong&gt; cap the remote user's bitrate in the user policy and accept a deliberate transcode, because unlimited remote playback of a 40 Mbps file will stall on most residential upload speeds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mini PC or NAS with Intel integrated graphics (an N100 or a recent Synology or Beelink box):&lt;/strong&gt; pass /dev/dri into the container and turn on Intel QuickSync, because that single change moves the encode off the CPU entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An old desktop with a dedicated NVIDIA card (a repurposed tower in the basement):&lt;/strong&gt; use NVENC and check the concurrent session limit on consumer cards, since driver level caps, not the GPU itself, decide how many streams you get.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A family that mostly watches on browsers and smart TV apps (mixed Chromecast, Fire TV and laptop viewing):&lt;/strong&gt; standardise on H.264 or widely supported HEVC with AAC audio, because container and audio mismatches trigger transcodes even when the video codec is fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is simple: every gigabyte of disk you spend on client friendly copies of your media is CPU and GPU work you never have to do while four people are watching.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Why does Jellyfin hit 100% CPU when only three or four people are watching?&lt;/li&gt;
&lt;li&gt;How do you tell whether a Jellyfin stream is direct playing, remuxing or transcoding?&lt;/li&gt;
&lt;li&gt;Anti-pattern 1: hardware acceleration left off, or switched on but never verified&lt;/li&gt;
&lt;li&gt;Which transcoding path fits your server: Intel QuickSync, NVIDIA NVENC, AMD VAAPI or software?&lt;/li&gt;
&lt;li&gt;Why does the GPU never reach your Jellyfin Docker container?&lt;/li&gt;
&lt;li&gt;Anti-pattern 2: keeping one 4K HDR file as the only copy for every screen in the house&lt;/li&gt;
&lt;li&gt;Why do subtitles turn a smooth Jellyfin stream into a full video re-encode?&lt;/li&gt;
&lt;li&gt;Anti-pattern 3: leaving remote family accounts with no bitrate ceiling&lt;/li&gt;
&lt;li&gt;Anti-pattern 4: pointing the Jellyfin transcode cache at the wrong disk&lt;/li&gt;
&lt;li&gt;Which audio codecs and containers quietly force your family's clients to transcode?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why does Jellyfin hit 100% CPU when only three or four people are watching?
&lt;/h2&gt;

&lt;p&gt;Serving a file costs almost nothing. Re-encoding it costs everything. When Jellyfin direct plays, the server reads bytes off disk and pushes them to the client, which is disk and network work that a ten year old CPU handles for a dozen viewers at once. When Jellyfin transcodes in software, it decodes every frame and re-encodes it with &lt;code&gt;libx264&lt;/code&gt; or &lt;code&gt;libx265&lt;/code&gt;, and that single stream will use every core you give it. Three concurrent software transcodes on a four core CPU is not a load problem, it is an impossible request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What actually consumes the CPU:&lt;/strong&gt; the software encoder, not the number of sessions. One 4K HEVC to 1080p H.264 software transcode can occupy a modern desktop CPU on its own, which is why the second viewer arrives to buffering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it looks like a bandwidth problem:&lt;/strong&gt; transcoding cannot keep ahead of playback, so the client empties its buffer and pauses, exactly the symptom a slow network produces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it starts at three streams and not one:&lt;/strong&gt; the first transcode uses spare headroom, the second consumes it, and the third queues behind both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why your hardware is probably fine:&lt;/strong&gt; an Intel CPU with QuickSync, or any GPU with a dedicated encoder block, offloads the work to fixed function silicon and leaves the general purpose cores idle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the trigger is often invisible:&lt;/strong&gt; a subtitle track, an audio codec or a client bitrate cap flips a session from direct play to transcode without anyone changing a setting.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;top&lt;/code&gt; or &lt;code&gt;htop&lt;/code&gt; during playback. If &lt;code&gt;ffmpeg&lt;/code&gt; sits at the top, you have found your answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you tell whether a Jellyfin stream is direct playing, remuxing or transcoding?
&lt;/h2&gt;

&lt;p&gt;Stop guessing and read the session. Open Dashboard while someone is watching: each active device shows the playing method under the title, along with source and output bitrates. On the client side, the Jellyfin web player exposes the same data through the Playback Info entry in the player menu. That one screen tells you whether the server is copying bytes or rebuilding frames, and Jellyfin also prints the trigger, in plain language, such as "The audio codec is not supported".&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Playing method&lt;/th&gt;
&lt;th&gt;What the server does&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;Direct Play&lt;/td&gt;
&lt;td&gt;Sends the original file untouched, container and all streams intact&lt;/td&gt;
&lt;td&gt;Disk reads and network only, negligible CPU, scales to many viewers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct Stream (remux)&lt;/td&gt;
&lt;td&gt;Repackages the same video into another container, video bitstream copied&lt;/td&gt;
&lt;td&gt;Low CPU, a few percent, output bitrate stays equal to the source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio transcode only&lt;/td&gt;
&lt;td&gt;Copies the video, re-encodes the audio track to AAC or similar&lt;/td&gt;
&lt;td&gt;Small and constant CPU cost, safe to run several at once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video transcode&lt;/td&gt;
&lt;td&gt;Decodes and re-encodes every frame at a new resolution or bitrate&lt;/td&gt;
&lt;td&gt;The expensive case, and the one that pins cores when several run together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two habits make this permanent. First, check the source and output bitrate pair in the session card: identical numbers mean no video re-encode, a drop from 38 Mbps to 8 Mbps means one is happening. Second, keep &lt;code&gt;jellyfin.log&lt;/code&gt; open in a second window during a test playback, because the transcode reason is recorded there for clients that hide the overlay.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anti-pattern 1: hardware acceleration left off, or switched on but never verified
&lt;/h2&gt;

&lt;p&gt;The default install has hardware acceleration set to &lt;code&gt;None&lt;/code&gt;. Every transcode is software until you change it, so a brand new Jellyfin server on capable hardware behaves exactly like a server with no GPU at all. The second mistake is worse, because it looks solved: you pick an acceleration mode in Dashboard, Playback, Transcoding, save, and never confirm that a single frame was actually decoded on the GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn it on in the right place:&lt;/strong&gt; Dashboard, Playback, Transcoding, then choose the mode that matches your chip, not the first entry in the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tick the decode codecs you actually own:&lt;/strong&gt; enabling H.264 and HEVC decoding is separate from choosing the mode, and leaving those boxes empty means the GPU decodes nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enable hardware encoding as well as decoding:&lt;/strong&gt; decode only acceleration still leaves &lt;code&gt;libx264&lt;/code&gt; on the CPU, which is the expensive half of the job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify with the transcode log:&lt;/strong&gt; open a transcoding session, then read the newest file in &lt;code&gt;/config/log/&lt;/code&gt;, where a working setup shows the hardware device being initialised rather than a plain software encoder line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify with the server itself:&lt;/strong&gt; run &lt;code&gt;nvidia-smi&lt;/code&gt; on an NVIDIA host or &lt;code&gt;vainfo&lt;/code&gt; inside the container on an Intel or AMD host, during playback, and check the encoder is busy.&lt;/p&gt;

&lt;p&gt;Where you run Jellyfin decides which of these options exists at all. A home NAS, a self managed VPS, a mini PC under the TV and Yundera are all viable hosts. 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. Rented virtual machines frequently expose no GPU, so confirm before you buy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which transcoding path fits your server: Intel QuickSync, NVIDIA NVENC, AMD VAAPI or software?
&lt;/h2&gt;

&lt;p&gt;Pick the path your silicon already has, not the one with the best reputation. Jellyfin lists the modes in one dropdown, and choosing a mode your hardware does not implement produces failed playback rather than a fallback you will notice. Check the chip first, then set the matching entry.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Fits which server&lt;/th&gt;
&lt;th&gt;What to check before you commit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Intel QuickSync (QSV)&lt;/td&gt;
&lt;td&gt;Mini PCs, most NAS units and any Intel CPU with integrated graphics enabled in the BIOS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/dev/dri/renderD128&lt;/code&gt; must exist on the host, and the iGPU must not be disabled when a discrete card is installed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NVIDIA NVENC&lt;/td&gt;
&lt;td&gt;A tower with a GeForce or Quadro card already in it&lt;/td&gt;
&lt;td&gt;Consumer drivers have historically capped concurrent encode sessions, so read NVIDIA's current encode support matrix before planning four streams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AMD VAAPI or AMF&lt;/td&gt;
&lt;td&gt;AMD APUs and Radeon cards on Linux hosts&lt;/td&gt;
&lt;td&gt;Mesa driver version and container packaging decide what works, and HEVC support varies more than on Intel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple VideoToolbox&lt;/td&gt;
&lt;td&gt;A Mac mini running Jellyfin natively&lt;/td&gt;
&lt;td&gt;Not available to a Docker container, so this rules out the containerised setup entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Software (&lt;code&gt;libx264&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Any machine, as the fallback when no encoder block exists&lt;/td&gt;
&lt;td&gt;Predictable quality, unpredictable capacity, and the reason most family servers stall at two viewers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hosting choice constrains this list. A NAS, a home mini PC, a self managed VPS and Yundera are all places Jellyfin runs. 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, installed from an app store in one click. Whichever you choose, confirm GPU access before you build a library around 4K files.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why does the GPU never reach your Jellyfin Docker container?
&lt;/h2&gt;

&lt;p&gt;A container sees only the devices you hand it. The host can have a perfectly working iGPU, &lt;code&gt;vainfo&lt;/code&gt; can succeed on the host, and the container still has nothing to accelerate with. This is the single most common reason a correctly configured Jellyfin setting produces software transcodes anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No device mapping in the compose file:&lt;/strong&gt; Intel and AMD hosts need &lt;code&gt;devices: - /dev/dri:/dev/dri&lt;/code&gt; in the service definition, and adding it requires recreating the container, not restarting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong group membership inside the container:&lt;/strong&gt; the render node is owned by a group whose numeric GID differs between distributions, so read it with &lt;code&gt;ls -l /dev/dri&lt;/code&gt; on the host and pass the same value through &lt;code&gt;group_add&lt;/code&gt; in compose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NVIDIA cards need the toolkit, not a device line:&lt;/strong&gt; install the NVIDIA Container Toolkit on the host and reserve the GPU in compose, or set &lt;code&gt;NVIDIA_VISIBLE_DEVICES=all&lt;/code&gt; and &lt;code&gt;NVIDIA_DRIVER_CAPABILITIES=all&lt;/code&gt; for the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hypervisor layer in the way:&lt;/strong&gt; Jellyfin inside a Proxmox LXC or a virtual machine only sees the GPU if the host was configured to pass it through first, and an unprivileged LXC needs explicit cgroup device rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A driver mismatch between host and image:&lt;/strong&gt; the container ships its own userspace drivers, so an old host kernel or a very new GPU can leave you with a device node that opens but exposes no encode profiles.&lt;/p&gt;

&lt;p&gt;Verify with one command: &lt;code&gt;docker exec -it jellyfin ls -l /dev/dri&lt;/code&gt;. If that returns nothing, no Jellyfin setting will help. If it lists &lt;code&gt;renderD128&lt;/code&gt;, run &lt;code&gt;vainfo&lt;/code&gt; in the same container and read which encode profiles are actually advertised.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anti-pattern 2: keeping one 4K HDR file as the only copy for every screen in the house
&lt;/h2&gt;

&lt;p&gt;One pristine UHD remux looks like good curation. It is actually a transcode generator. A UHD Blu-ray source can carry video bitrates near 100 Mbps, and no phone, tablet or older smart TV in the house will direct play that. Every screen that is not the living room TV forces a downscale, and HDR makes it worse, because converting HDR10 to SDR requires tone mapping on top of the resize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tone mapping is a second workload:&lt;/strong&gt; Jellyfin does it on the GPU through OpenCL or vendor specific paths, and enabling hardware acceleration without enabling tone mapping leaves the colour conversion on the CPU or produces washed out, grey looking video.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dolby Vision profile 5 has no usable fallback:&lt;/strong&gt; clients that cannot decode it show green and purple frames, so those files always need re-encoding for the rest of the household.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk is the cheaper resource:&lt;/strong&gt; a second 1080p H.264 copy costs a few gigabytes per film once, while transcoding the 4K original costs GPU time on every single play.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jellyfin has a built in mechanism for this:&lt;/strong&gt; put both files in the same movie folder and name them &lt;code&gt;Film Name (2021) - 4K.mkv&lt;/code&gt; and &lt;code&gt;Film Name (2021) - 1080p.mkv&lt;/code&gt;, and Jellyfin presents them as selectable versions of one title.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Target the copy at your real clients:&lt;/strong&gt; H.264 in MP4 or MKV, 1080p, SDR, is the widest compatibility baseline across browsers, Chromecast, Fire TV and phones.&lt;/p&gt;

&lt;p&gt;Do this for the twenty titles your family rewatches, not the whole library. That handles most concurrent playback without touching the archive quality originals.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why do subtitles turn a smooth Jellyfin stream into a full video re-encode?
&lt;/h2&gt;

&lt;p&gt;Because some subtitles are pictures, not text. Text subtitles can be sent to the client as a separate stream and drawn by the player. Image subtitles have to be painted onto the video frames themselves, which means decoding and re-encoding every frame. A family member turning on subtitles is therefore enough to convert a zero cost direct play into the most expensive session on the server, and it happens mid playback with no warning.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subtitle type&lt;/th&gt;
&lt;th&gt;How Jellyfin delivers it&lt;/th&gt;
&lt;th&gt;Cost to the server&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SRT, external or embedded&lt;/td&gt;
&lt;td&gt;Sent to the client as a separate text track&lt;/td&gt;
&lt;td&gt;None, video keeps direct playing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebVTT&lt;/td&gt;
&lt;td&gt;Converted from text and streamed alongside the video&lt;/td&gt;
&lt;td&gt;Negligible, a text conversion only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ASS or SSA with styling&lt;/td&gt;
&lt;td&gt;Burned in when the client cannot render the styling&lt;/td&gt;
&lt;td&gt;Full video re-encode for the whole session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PGS, from Blu-ray sources&lt;/td&gt;
&lt;td&gt;Always burned in, since it is a bitmap format&lt;/td&gt;
&lt;td&gt;Full video re-encode, the usual hidden cause&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VobSub, from DVD sources&lt;/td&gt;
&lt;td&gt;Always burned in, same bitmap problem&lt;/td&gt;
&lt;td&gt;Full video re-encode&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three fixes, in order of effort. Set the burn in policy in Dashboard, Playback, under the subtitle burn in option, so text formats are never burned in unnecessarily. Convert bitmap tracks to text once with OCR, or fetch SRT sidecars with the OpenSubtitles plugin and drop them beside the video file as &lt;code&gt;Film Name (2021).en.srt&lt;/code&gt;. Then extract embedded text tracks to sidecars with &lt;code&gt;ffmpeg -i input.mkv -map 0:s:0 output.srt&lt;/code&gt;, so Jellyfin never has to pull them out of the container during playback.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anti-pattern 3: leaving remote family accounts with no bitrate ceiling
&lt;/h2&gt;

&lt;p&gt;A grandparent's tablet on the other side of the country asks for the same file the living room TV gets. Nothing in Jellyfin stops it by default. The client requests the source, the request exceeds your home upload capacity, playback stalls, and the server keeps working anyway. Worse, the client often reacts by asking for a transcode at some other bitrate, so you pay in CPU for a stream that still buffers. The arithmetic is unforgiving: a 40 Mbps file needs 40 Mbps of sustained upload, and residential connections are asymmetric by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set the limit per user, not globally:&lt;/strong&gt; each user's policy has an internet streaming bitrate limit in Mbps, so local viewers keep full quality while remote accounts are capped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set the server wide fallback too:&lt;/strong&gt; Dashboard, Playback holds an internet streaming bitrate limit that applies to anyone you have not configured individually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure your upload before choosing the number:&lt;/strong&gt; total concurrent remote streams must fit inside your real upload speed, not your download speed, and leave headroom for everything else in the house.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accept the transcode you just chose:&lt;/strong&gt; a capped remote stream is a deliberate, predictable transcode, which is exactly the workload hardware encoding handles well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teach the client side setting as well:&lt;/strong&gt; every Jellyfin app has a quality selector with preset steps, and a family member who picks the top entry overrides good intentions unless the server policy caps them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch for the wrong ceiling:&lt;/strong&gt; set it too low and remote viewers get soft video and a permanent transcode on files that could have direct played.&lt;/p&gt;

&lt;p&gt;One capped stream beats one unlimited stream that never plays.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anti-pattern 4: pointing the Jellyfin transcode cache at the wrong disk
&lt;/h2&gt;

&lt;p&gt;Every transcode writes HLS segments to disk continuously, while several viewers read them back at the same time. Put that directory on the same spinning drive your media lives on and you now have random writes competing with sequential reads for the same heads. The transcode falls behind, the client buffers, and the dashboard still shows the GPU barely working. It looks like a CPU problem. It is a storage problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know where it currently points:&lt;/strong&gt; Dashboard, Playback, Transcoding has a transcode path field, and the official container expects a &lt;code&gt;/cache&lt;/code&gt; mount for it, so an unmapped cache means the segments land inside the container filesystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep it off the media array:&lt;/strong&gt; an SSD, even a small one, isolates the write load from the drives serving direct play sessions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consider a tmpfs, with a size cap:&lt;/strong&gt; mounting the transcode directory as tmpfs puts segments in RAM, which is the fastest option, but it consumes real memory and must be given an explicit size limit or a long film can fill it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never use a network share:&lt;/strong&gt; SMB or NFS latency on thousands of small segment writes is the worst case, and a dropped mount kills every session at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not run it on the boot USB stick or SD card:&lt;/strong&gt; the write volume of routine family viewing wears cheap flash quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enable throttling and segment deletion:&lt;/strong&gt; both live in the same transcoding settings page, and together they stop a paused session from transcoding an entire film ahead of the viewer and filling the disk.&lt;/p&gt;

&lt;p&gt;Check free space with &lt;code&gt;df -h&lt;/code&gt; on the cache path before blaming anything else. A full cache disk fails playback silently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which audio codecs and containers quietly force your family's clients to transcode?
&lt;/h2&gt;

&lt;p&gt;Video gets the attention, but audio and container mismatches are what convert a direct play into something else on phones, browsers and cheap streaming sticks. The good news is that an audio only conversion is comparatively cheap. The bad news is that some clients respond to an unsupported audio track by requesting a full transcode, and lossless audio tracks are enormous, so remote sessions stall on bandwidth even when the video is untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lossless formats are the usual trigger:&lt;/strong&gt; Dolby TrueHD and DTS-HD Master Audio pass through to an AV receiver over HDMI, and almost nothing else in the house decodes them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browsers refuse Dolby Digital:&lt;/strong&gt; AC-3 and E-AC-3 are not decoded by desktop Chrome or Firefox, so laptop viewers get an audio conversion to AAC on nearly every film ripped from disc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MKV is a container problem, not a codec problem:&lt;/strong&gt; browsers play MP4 natively, so an MKV file is repackaged before it reaches them, which is cheap but only works if the streams inside are supported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phones and tablets downmix anyway:&lt;/strong&gt; a 5.1 or 7.1 track becomes stereo on a two speaker device, and that downmix is a re-encode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is a second audio track, not a second file:&lt;/strong&gt; add a compatible stereo AAC track with &lt;code&gt;ffmpeg -i input.mkv -map 0 -c copy -map 0:a:0 -c:a:1 aac -ac 2 -b:a 192k output.mkv&lt;/code&gt;, keeping the original for the receiver.&lt;/p&gt;

&lt;p&gt;Set the default audio track per user language preference so casual viewers land on the compatible track without choosing it. The living room keeps its lossless soundtrack, and everyone else stops generating work.&lt;/p&gt;

</description>
      <category>jellyfin</category>
      <category>selfhosted</category>
      <category>homelab</category>
      <category>docker</category>
    </item>
    <item>
      <title>Why Vaultwarden Feels Slow: KDF, SQLite vs MySQL vs PostgreSQL, Storage, WebSockets and Your Reverse Proxy</title>
      <dc:creator>John</dc:creator>
      <pubDate>Tue, 11 Aug 2026 07:05:52 +0000</pubDate>
      <link>https://dev.to/john_182319291/why-vaultwarden-feels-slow-kdf-sqlite-vs-mysql-vs-postgresql-storage-websockets-and-your-1cd</link>
      <guid>https://dev.to/john_182319291/why-vaultwarden-feels-slow-kdf-sqlite-vs-mysql-vs-postgresql-storage-websockets-and-your-1cd</guid>
      <description>&lt;p&gt;For a single self-hoster with a few hundred vault items, the database is almost never the bottleneck. Unlock time is dominated by client-side key derivation, which runs on your laptop or phone and never touches the server at all, and sync time is dominated by the size of the full vault payload plus the round trip through TLS and your reverse proxy. SQLite handles a personal Vaultwarden instance without breaking a sweat; the cases where MySQL, MariaDB or PostgreSQL earn their keep are about concurrency, replication and backup tooling, not raw speed. Fix the KDF settings and the storage medium first, and only then argue about backends.&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 one vault on a Raspberry Pi, say 400 logins and no attachments:&lt;/strong&gt; stay on SQLite and move the data directory off the SD card, because random write latency on flash cards is what you are actually feeling, not the query engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hoster who bumped PBKDF2 to 2,000,000 iterations after reading a hardening thread, then blamed the server:&lt;/strong&gt; lower the iteration count or switch to Argon2id with modest memory, because that delay is CPU work on the client and no server change will remove it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small team of 5 to 15 users sharing an organization, run on a NUC or small VPS:&lt;/strong&gt; keep SQLite unless you see write contention in the logs, and put effort into WAL mode, a real SSD and correct WebSocket upgrade headers instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homelabber who already runs PostgreSQL for other apps and wants one backup path:&lt;/strong&gt; move Vaultwarden to PostgreSQL for operational reasons, accepting an extra container and a migration, not because queries will get faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anyone whose vault is fine on the LAN and slow over the internet, for example on mobile data:&lt;/strong&gt; the problem lives in DNS, TLS handshakes, proxy buffering and connection reuse, so measure the request path before touching the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operator with a heavy vault, thousands of items plus attachments and icon fetching enabled:&lt;/strong&gt; budget for payload size and outbound icon requests, because those grow the sync response and the I/O profile far faster than item count alone suggests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff: every setting that makes your vault harder to crack offline makes it slower to open on your own devices, and every backend that makes operations easier adds a moving part that can itself become the slow thing.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;Is your Vaultwarden server slow, or is your client just busy?&lt;/li&gt;
&lt;li&gt;What happens between typing your master password and seeing your vault&lt;/li&gt;
&lt;li&gt;What makes the sync response grow, and when does that start to hurt?&lt;/li&gt;
&lt;li&gt;Is SQLite good enough for a self-hosted Vaultwarden vault?&lt;/li&gt;
&lt;li&gt;When does moving to MySQL or MariaDB actually help?&lt;/li&gt;
&lt;li&gt;Does PostgreSQL make Vaultwarden faster, or just harder to run?&lt;/li&gt;
&lt;li&gt;How much does your storage medium change Vaultwarden write latency?&lt;/li&gt;
&lt;li&gt;WAL mode, fsync and Docker volume layout: the settings that decide write cost&lt;/li&gt;
&lt;li&gt;Do WebSocket notifications reduce sync traffic or add load?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Is your Vaultwarden server slow, or is your client just busy?
&lt;/h2&gt;

&lt;p&gt;Start by splitting the problem in two. Everything that happens before a network request leaves your device is client work, and everything after it is server work. Vaultwarden, the Rust reimplementation of the Bitwarden server API, only ever sees the second half.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unlock is client work:&lt;/strong&gt; deriving your master key from your master password runs entirely in the browser extension, desktop app or mobile app, so a five second unlock on a laptop with the server powered off is still a five second unlock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sync is server work:&lt;/strong&gt; the client calls &lt;code&gt;GET /api/sync&lt;/code&gt;, the server reads your ciphers, folders and organization data, serialises them to JSON and sends them back, so this is where SQLite, storage and your reverse proxy appear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A quick test separates them:&lt;/strong&gt; run &lt;code&gt;curl -o /dev/null -s -w '%{time_total}\n' https://vault.example.com/alive&lt;/code&gt; from the same network as the client, because that endpoint touches the server without touching your vault contents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where you host changes the second half only:&lt;/strong&gt; a home server on a gigabit LAN, a NAS, a VPS in another country and a managed box all differ in round trip time and disk behaviour, never in key derivation cost. 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;p&gt;&lt;strong&gt;Docker adds its own floor:&lt;/strong&gt; container startup, health checks and cold page cache mean the first request after a restart is slower than the next hundred, so never benchmark a container in its first 30 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happens between typing your master password and seeing your vault
&lt;/h2&gt;

&lt;p&gt;The sequence is fixed, and only two of its five steps involve your server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key derivation runs locally:&lt;/strong&gt; the client feeds your master password and your email address as salt into the configured KDF, either PBKDF2-SHA256 or Argon2id, producing a 256 bit master key. Bitwarden clients default new accounts to 600,000 PBKDF2 iterations, while accounts created years ago may still sit at 100,000, and Argon2id defaults to 64 MiB of memory with 3 passes and 4 lanes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication is one small request:&lt;/strong&gt; the client derives a master password hash from that key and posts it to &lt;code&gt;/identity/connect/token&lt;/code&gt;. The body is a few hundred bytes, the response is a JWT access token plus a refresh token, and the server hashes the submitted value again before comparing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The symmetric key is unwrapped locally:&lt;/strong&gt; your account encryption key arrives wrapped and is decrypted with the master key on the device. Vaultwarden never sees it, which is exactly why no server tuning can shorten this part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the vault is fetched and decrypted:&lt;/strong&gt; the sync call returns the ciphers, and the client decrypts each one to build the list you scroll through. On a phone this per item decryption is real CPU work, separate from the download.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locking is not logging out:&lt;/strong&gt; with a vault timeout action of Lock, reopening only repeats key derivation and local decryption, with zero network traffic. With Log out, you repeat the whole sequence including authentication and a full sync.&lt;/p&gt;

&lt;p&gt;So a slow first open after a reboot and a slow open after a 15 minute timeout have different causes, and confusing the two sends you tuning the wrong layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  What makes the sync response grow, and when does that start to hurt?
&lt;/h2&gt;

&lt;p&gt;Vaultwarden has no incremental sync. Every &lt;code&gt;GET /api/sync&lt;/code&gt; returns the whole vault: profile, folders, collections, organization keys, policies, Sends and every cipher you can see. Add one login and the client re-downloads all of them. That is fine at 200 items and noticeable at several thousand, because the cost is linear in item count and in the size of each item.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Growth driver&lt;/th&gt;
&lt;th&gt;How it scales&lt;/th&gt;
&lt;th&gt;When you feel it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cipher count&lt;/td&gt;
&lt;td&gt;Linear: each login, card, note and identity is a separate encrypted object with its own key material&lt;/td&gt;
&lt;td&gt;Above roughly a few thousand items on mobile, where JSON parsing and decryption happen per object&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URIs and custom fields per item&lt;/td&gt;
&lt;td&gt;Multiplies the per-item payload, since each URI and field is separately encrypted&lt;/td&gt;
&lt;td&gt;Vaults built by importing browser passwords, which often carry several URIs per login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secure notes&lt;/td&gt;
&lt;td&gt;Grows with note body length, not item count, so 50 long notes can outweigh 500 logins&lt;/td&gt;
&lt;td&gt;When people paste config files or recovery codes into notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organization membership&lt;/td&gt;
&lt;td&gt;Adds collections, policies and every shared cipher to your personal sync response&lt;/td&gt;
&lt;td&gt;Joining a second or third organization on a shared instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attachment metadata&lt;/td&gt;
&lt;td&gt;Only filenames, sizes and keys ride in the sync; the bytes are fetched separately&lt;/td&gt;
&lt;td&gt;Rarely, unless &lt;code&gt;USER_ATTACHMENT_LIMIT&lt;/code&gt; is set high and items accumulate many files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two practical levers exist. Clients can call &lt;code&gt;GET /api/sync?excludeDomains=true&lt;/code&gt; to skip global equivalent domains, and enabling gzip or brotli compression at the reverse proxy shrinks a highly repetitive JSON body substantially on the wire without touching the database at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is SQLite good enough for a self-hosted Vaultwarden vault?
&lt;/h2&gt;

&lt;p&gt;For one person, one family or a small team, yes. Vaultwarden defaults to SQLite for a reason: a password vault is a read heavy workload with a tiny working set, and the whole database lives in a single file at &lt;code&gt;/data/db.sqlite3&lt;/code&gt; inside the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The write volume is genuinely small:&lt;/strong&gt; a vault write happens when you add or edit an item, log in on a device, rotate a token or create a Send. Nobody edits 40 passwords per minute, so the single writer limitation almost never bites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attachments never enter the database:&lt;/strong&gt; file bytes are stored under &lt;code&gt;/data/attachments&lt;/code&gt;, so a vault with 300 uploaded files keeps a database whose size tracks item metadata, not payloads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrency limits are about writers, not readers:&lt;/strong&gt; SQLite allows many concurrent readers with one writer at a time, and in WAL mode readers do not block that writer. Five people syncing at once is reads, not a write storm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups are a file copy, not a dump job:&lt;/strong&gt; &lt;code&gt;sqlite3 /data/db.sqlite3 ".backup '/data/backup.sqlite3'"&lt;/code&gt; produces a consistent snapshot while the server is running, and &lt;code&gt;PRAGMA integrity_check;&lt;/code&gt; verifies it afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failure mode is storage, not the engine:&lt;/strong&gt; SQLite on a network share or an SD card behaves badly because locking and fsync semantics degrade, which is a filesystem problem wearing a database costume.&lt;/p&gt;

&lt;p&gt;Where the file physically sits matters more than which engine reads it, whether that is a home server, a NAS, a VPS or a managed box such as Yundera. Move to a client server database when your operational needs change, not because item counts grew.&lt;/p&gt;




&lt;h2&gt;
  
  
  When does moving to MySQL or MariaDB actually help?
&lt;/h2&gt;

&lt;p&gt;Not for speed. Swapping SQLite for MariaDB adds a TCP hop, an authentication handshake and a connection pool between Vaultwarden and its rows. For a single user vault that makes each query slightly slower, not faster. The reasons to do it are operational.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You already run a MySQL or MariaDB server:&lt;/strong&gt; one backup path, one monitoring dashboard and one restore drill beats a second unrelated file to remember. Point Vaultwarden at it with &lt;code&gt;DATABASE_URL=mysql://vaultwarden:password@db:3306/vaultwarden&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need real replication or point in time recovery:&lt;/strong&gt; binary logs and a replica give you rollback to a moment, which a nightly file copy cannot. Vaults are exactly the data where restoring to five minutes before a bad bulk edit matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your storage layer cannot do file locking properly:&lt;/strong&gt; if the data directory must live on NFS, SMB or a clustered filesystem, moving state into a database server sidesteps the locking behaviour that makes SQLite unsafe there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You have dozens of concurrent writers:&lt;/strong&gt; an organization with automated user provisioning, frequent invites and constant item edits produces sustained write concurrency that InnoDB row level locking handles more gracefully.&lt;/p&gt;

&lt;p&gt;The costs are concrete. You need an image built with the mysql feature, a database created with &lt;code&gt;CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci&lt;/code&gt;, and a migration step that copies the SQLite contents across before first boot. You gain a second container to patch, plus its memory: InnoDB defaults its buffer pool to 128 MB, which is more RAM than SQLite ever asked for. Tune &lt;code&gt;DATABASE_MAX_CONNS&lt;/code&gt;, which defaults to 10, rather than leaving the pool to fight your server's own connection limit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Does PostgreSQL make Vaultwarden faster, or just harder to run?
&lt;/h2&gt;

&lt;p&gt;Harder to run, and no faster for a personal vault. The queries Vaultwarden issues are simple primary key and foreign key lookups over a handful of tables. No planner is going to shine there. PostgreSQL earns its place when you want its durability and tooling, not its throughput.&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;SQLite&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Per query overhead&lt;/td&gt;
&lt;td&gt;In process function call against a local file&lt;/td&gt;
&lt;td&gt;TCP round trip, protocol parsing and a pooled connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory floor&lt;/td&gt;
&lt;td&gt;Effectively the page cache Vaultwarden already uses&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;shared_buffers&lt;/code&gt; defaults to 128 MB, plus one backend process per connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup and restore&lt;/td&gt;
&lt;td&gt;Copy or snapshot one file&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_dump&lt;/code&gt; for logical dumps, WAL archiving for point in time recovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration effort&lt;/td&gt;
&lt;td&gt;None, it is the default&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;pgloader&lt;/code&gt; or an equivalent converter, then verify row counts before cutover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical failure mode&lt;/td&gt;
&lt;td&gt;Filesystem locking or a corrupted file on bad storage&lt;/td&gt;
&lt;td&gt;Connection exhaustion, version upgrade of the data directory, container start order&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use &lt;code&gt;DATABASE_URL=postgresql://vaultwarden:password@db:5432/vaultwarden&lt;/code&gt; and pin the image tag, for example &lt;code&gt;postgres:16-alpine&lt;/code&gt;, because a major version bump will not start against an old data directory without an explicit upgrade step. That last detail causes more Vaultwarden outages than any query ever will.&lt;/p&gt;

&lt;p&gt;The honest rule: choose PostgreSQL if you already operate it, standardise on it, or need WAL based recovery. Otherwise you have added a second container that must start first, stay patched and survive reboots, on whatever you run this on, a home server, a NAS, a VPS or a managed option such as Yundera. Complexity you do not need is itself a reliability cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much does your storage medium change Vaultwarden write latency?
&lt;/h2&gt;

&lt;p&gt;More than the database engine does. Every committed vault write ends in an &lt;code&gt;fsync()&lt;/code&gt;, and that call does not finish until the device says the data is durable. Throughput is irrelevant here. A drive that streams 500 MB/s can still take milliseconds to acknowledge a 4 KB flush, and that flush is what your client waits on when you save a password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SD cards and USB sticks are the worst case:&lt;/strong&gt; small random writes with a sync barrier hit the controller's slowest path, and the constant rewriting also consumes write cycles. This is the single most common cause of a Raspberry Pi vault that feels sluggish on save.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spinning disks pay physics on every flush:&lt;/strong&gt; a 7200 rpm drive averages 4.17 ms of rotational latency per revolution, before seek time. Ten flushes in a save path becomes tens of milliseconds you cannot tune away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SATA and NVMe SSDs collapse the problem:&lt;/strong&gt; sync acknowledgement drops by orders of magnitude, which is why moving &lt;code&gt;/data&lt;/code&gt; to an SSD usually ends the investigation before anyone touches &lt;code&gt;DATABASE_URL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network shares turn each flush into a round trip:&lt;/strong&gt; NFS and SMB add latency per sync and bring their own locking semantics, so this hurts both correctness and speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure before you argue:&lt;/strong&gt; run &lt;code&gt;fio --name=sync --rw=randwrite --bs=4k --fsync=1 --size=64m --filename=/data/testfile&lt;/code&gt; against the exact path your container writes to, then compare devices. &lt;code&gt;ioping -W /data&lt;/code&gt; gives a quicker approximation.&lt;/p&gt;

&lt;p&gt;Do this test inside the container, not on the host. Bind mounts, encrypted layers and virtual disks all sit between the two, and only the container's view reflects what Vaultwarden actually experiences.&lt;/p&gt;




&lt;h2&gt;
  
  
  WAL mode, fsync and Docker volume layout: the settings that decide write cost
&lt;/h2&gt;

&lt;p&gt;Vaultwarden turns on SQLite write ahead logging by default via &lt;code&gt;ENABLE_DB_WAL=true&lt;/code&gt;. That is the right default and you should leave it alone unless you have a specific reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WAL converts one random write into an append:&lt;/strong&gt; commits go to &lt;code&gt;db.sqlite3-wal&lt;/code&gt; sequentially instead of rewriting pages in place, which suits every storage medium and lets readers continue while a write is in flight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three files now hold your vault:&lt;/strong&gt; &lt;code&gt;db.sqlite3&lt;/code&gt;, &lt;code&gt;db.sqlite3-wal&lt;/code&gt; and &lt;code&gt;db.sqlite3-shm&lt;/code&gt; live together. Copying only the first one while the server runs can lose every commit still sitting in the WAL, which is why a snapshot taken with the SQLite backup API is safer than &lt;code&gt;cp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkpoints are the periodic cost:&lt;/strong&gt; SQLite checkpoints automatically at 1000 pages, so with the default 4096 byte page size the WAL grows to roughly 4 MB before its contents are folded back into the main file. That fold is the one moment you pay bulk write cost, and it happens in the background.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disable WAL only for network filesystems:&lt;/strong&gt; set &lt;code&gt;ENABLE_DB_WAL=false&lt;/code&gt; when &lt;code&gt;/data&lt;/code&gt; sits on NFS or SMB, because shared memory indexing does not work reliably there. Accept slower writes as the price of correctness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Volume choice changes fsync behaviour:&lt;/strong&gt; a bind mount from a Linux host to &lt;code&gt;/data&lt;/code&gt; is close to native. On Docker Desktop for macOS or Windows the same bind mount crosses a virtual machine boundary through a file sharing layer, and sync heavy workloads slow noticeably, while a named volume stays on the VM's own filesystem.&lt;/p&gt;

&lt;p&gt;Never leave &lt;code&gt;/data&lt;/code&gt; in the container's writable layer. Overlay filesystems are copy on write, and a &lt;code&gt;docker compose down&lt;/code&gt; destroys it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Do WebSocket notifications reduce sync traffic or add load?
&lt;/h2&gt;

&lt;p&gt;They reduce it. The notification channel exists so that a change made on your phone reaches your laptop without every client polling for it. One idle connection is cheaper than repeated full sync requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One connection replaces repeated polling:&lt;/strong&gt; clients open a persistent connection to &lt;code&gt;/notifications/hub&lt;/code&gt; and receive a small message telling them something changed. Without it, they only refresh on unlock, on focus and on their own timer, so an edit can sit unseen for a long while.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The port layout changed:&lt;/strong&gt; older Vaultwarden deployments ran a separate WebSocket listener on port 3012, while current versions serve it on the main HTTP port 8080 with &lt;code&gt;WEBSOCKET_ENABLED=true&lt;/code&gt;. Old compose files that still publish 3012 confuse people into thinking the feature is broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your proxy must pass the upgrade through:&lt;/strong&gt; the request needs &lt;code&gt;Upgrade&lt;/code&gt; and &lt;code&gt;Connection&lt;/code&gt; headers forwarded for that path, otherwise the handshake fails silently and clients degrade to polling with no visible error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idle timeouts create reconnect storms:&lt;/strong&gt; nginx defaults &lt;code&gt;proxy_read_timeout&lt;/code&gt; to 60 seconds, so a connection with no traffic gets closed and every client reconnects on a loop. Raise it for the notifications path, or you have swapped occasional syncs for constant handshakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mobile apps are a separate mechanism:&lt;/strong&gt; the Bitwarden mobile clients rely on push notifications rather than this channel, which requires &lt;code&gt;PUSH_ENABLED&lt;/code&gt; plus an installation ID and key registered with the upstream relay. Enabling WebSockets alone will not make your phone update instantly.&lt;/p&gt;

&lt;p&gt;The load is a held file descriptor and a keepalive per connected device. For a household with 10 devices that is noise. The real cost is misconfiguration, not connections.&lt;/p&gt;

</description>
      <category>vaultwarden</category>
      <category>sqlite</category>
      <category>selfhosted</category>
      <category>docker</category>
    </item>
    <item>
      <title>Notion to Docmost: what survives the export, and what the databases-to-pages downgrade really costs you</title>
      <dc:creator>John</dc:creator>
      <pubDate>Sat, 08 Aug 2026 07:06:07 +0000</pubDate>
      <link>https://dev.to/john_182319291/notion-to-docmost-what-survives-the-export-and-what-the-databases-to-pages-downgrade-really-costs-458m</link>
      <guid>https://dev.to/john_182319291/notion-to-docmost-what-survives-the-export-and-what-the-databases-to-pages-downgrade-really-costs-458m</guid>
      <description>&lt;p&gt;Your prose survives the move to Docmost almost intact. Your databases do not. Docmost ships an importer that reads a Notion HTML or Markdown export and rebuilds pages, hierarchy and attachments, but it has no database concept at all, so every Notion table, board, calendar, relation, rollup and formula arrives as flat page content that you maintain by hand from then on. Decide by asking what share of your workspace is written pages versus structured records, because that ratio, not the import quality, decides whether this migration is a one evening job or a rebuild.&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;Documentation-heavy solo dev (a Rust developer with 400 pages of design notes, runbooks and meeting notes):&lt;/strong&gt; migrate to Docmost, the export-import handles long prose, code blocks and nesting with almost nothing to fix afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database-driven organiser (someone running a CRM, a habit tracker and a reading list as Notion databases):&lt;/strong&gt; stay on Notion for those specific pages, Docmost gives you no filtered views, no relations and no rollups to replace them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homelab operator already running Postgres (a person with Docker Compose, a NAS and nightly pg_dump in place):&lt;/strong&gt; migrate now, Docmost adds only a Postgres database, a Redis instance and a file store to infrastructure you already back up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team of one with occasional guests (a freelancer sharing specs with two clients):&lt;/strong&gt; migrate, Docmost spaces, groups and per-space permissions cover guest sharing without a per-seat bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile-first note taker (someone capturing notes on a phone all day):&lt;/strong&gt; stay on Notion or run both, Docmost is a browser-first wiki and your capture workflow is the thing that degrades, not your archive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real tradeoff is that you exchange Notion's structured databases and polished mobile capture for a self-hosted wiki where the pages, the Postgres rows and the attachment directory are entirely yours.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;What actually survives a Notion export into Docmost&lt;/li&gt;
&lt;li&gt;Markdown or HTML: which Notion export should you feed Docmost?&lt;/li&gt;
&lt;li&gt;Block by block: what lands intact and what flattens&lt;/li&gt;
&lt;li&gt;What exactly do you lose when Notion databases become Docmost pages?&lt;/li&gt;
&lt;li&gt;How do you rebuild a Notion database view inside Docmost?&lt;/li&gt;
&lt;li&gt;Do internal links, backlinks and the page tree survive the import?&lt;/li&gt;
&lt;li&gt;Attachments, images and embeds after the move&lt;/li&gt;
&lt;li&gt;Comments, version history and page metadata: the silent losses&lt;/li&gt;
&lt;li&gt;How long does the import take for a workspace of a few thousand pages?&lt;/li&gt;
&lt;li&gt;What Docmost gives you that Notion never did&lt;/li&gt;
&lt;li&gt;What does running Docmost require, and where should you run it?&lt;/li&gt;
&lt;li&gt;What does the switch cost over three years, subscription against self-hosting?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What actually survives a Notion export into Docmost
&lt;/h2&gt;

&lt;p&gt;Start with the export itself. In Notion, open the workspace settings, choose the export with subpages, and pick either &lt;strong&gt;Markdown &amp;amp; CSV&lt;/strong&gt; or &lt;strong&gt;HTML&lt;/strong&gt;. You receive a &lt;code&gt;.zip&lt;/code&gt; archive containing one file per page, nested folders that mirror your page tree, and an &lt;code&gt;images&lt;/code&gt; or attachment folder per page. Docmost reads that archive directly through its import screen, alongside its separate importers for Confluence XML exports, DOCX and PDF files.&lt;/p&gt;

&lt;p&gt;Here is what comes through the round trip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Page text and formatting:&lt;/strong&gt; headings, bold, italic, strikethrough, inline code, blockquotes and links arrive as normal Docmost editor content, since both sides speak standard Markdown or HTML underneath.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page hierarchy:&lt;/strong&gt; the nested folder structure of the export becomes a nested page tree in a Docmost space, so a 5 level deep archive stays 5 levels deep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code blocks:&lt;/strong&gt; fenced blocks survive with their language hint, which matters if half your workspace is snippets and runbooks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lists and checkboxes:&lt;/strong&gt; bulleted lists, numbered lists and to-do checkboxes map cleanly to the equivalent Docmost editor nodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attachments:&lt;/strong&gt; images and uploaded files referenced by the export land in Docmost's own file storage rather than staying linked to Notion's CDN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple inline tables:&lt;/strong&gt; a plain Notion table with no filters or relations arrives as a static table you can still edit in place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything above is prose infrastructure. What does not survive is the structured layer: databases, views, properties, relations, rollups and formulas. That distinction, prose versus records, is the single most useful lens for the rest of this migration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Markdown or HTML: which Notion export should you feed Docmost?
&lt;/h2&gt;

&lt;p&gt;Both formats import, but they fail differently. Markdown is cleaner and lossier. HTML is uglier and keeps more visual structure. Pick based on how much of your workspace leans on callouts, toggles and coloured text.&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;Markdown &amp;amp; CSV export&lt;/th&gt;
&lt;th&gt;HTML export&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Page body&lt;/td&gt;
&lt;td&gt;Plain &lt;code&gt;.md&lt;/code&gt; files, minimal noise, easiest to inspect or fix with &lt;code&gt;sed&lt;/code&gt; before import&lt;/td&gt;
&lt;td&gt;Rendered &lt;code&gt;.html&lt;/code&gt; with Notion's own wrapper markup and inline styles, more to strip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databases&lt;/td&gt;
&lt;td&gt;Written out as separate &lt;code&gt;.csv&lt;/code&gt; files that Docmost does not turn into anything structured&lt;/td&gt;
&lt;td&gt;Rendered as a static HTML table inside the page, so the data is at least visible in Docmost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Callouts and toggles&lt;/td&gt;
&lt;td&gt;Flattened to plain paragraphs or blockquotes, the collapse behaviour is gone&lt;/td&gt;
&lt;td&gt;Closer to the original layout, toggles and callout boxes keep their shape more often&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coloured text and backgrounds&lt;/td&gt;
&lt;td&gt;Dropped, Markdown has no syntax for it&lt;/td&gt;
&lt;td&gt;Carried as inline style attributes, which may or may not render in the Docmost editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File naming&lt;/td&gt;
&lt;td&gt;Page title plus a 32 character hex ID, for example &lt;code&gt;Runbook 8f3c...c21.md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same naming scheme with an &lt;code&gt;.html&lt;/code&gt; extension&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical answer for a mostly written workspace is Markdown, because what lands in Docmost is what you would have typed yourself, with no leftover span tags to clean up later. Choose HTML when a meaningful share of your pages are database views you want to read as tables rather than lose entirely.&lt;/p&gt;

&lt;p&gt;Run both exports before you commit. They are free to generate, and importing each into a throwaway Docmost space tells you more in 20 minutes than any comparison table can.&lt;/p&gt;




&lt;h2&gt;
  
  
  Block by block: what lands intact and what flattens
&lt;/h2&gt;

&lt;p&gt;Beyond the basics, Notion has roughly two dozen block types with no exact Docmost equivalent. Here is how the awkward ones behave.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Column layouts:&lt;/strong&gt; a 3 column page becomes a single vertical stack, because the Markdown export has no column syntax and Docmost's editor does not reconstruct one. Wide dashboards read very differently afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Toggle lists:&lt;/strong&gt; in the Markdown export they collapse to plain nested content, so the hiding is gone. In the HTML export they arrive as &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; markup and may keep the fold, which is worth testing on 2 or 3 sample pages before you commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Callouts:&lt;/strong&gt; the emoji plus tinted box becomes an ordinary paragraph or blockquote. If you use callouts as warnings in runbooks, plan a find and replace pass to turn them into blockquotes with a bold &lt;strong&gt;Warning:&lt;/strong&gt; prefix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equations:&lt;/strong&gt; inline and block LaTeX comes across as &lt;code&gt;$$ ... $$&lt;/code&gt; text. Verify how your Docmost instance renders it rather than assuming, and fix the wrappers once across all files if it does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synced blocks:&lt;/strong&gt; each copy exports as its own independent content. After import you have duplicates that no longer update together, which is the single most dangerous silent change in the whole migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mentions and dates:&lt;/strong&gt; person mentions and date mentions degrade to plain text. A line reading "due &lt;a class="mentioned-user" href="https://dev.to/march"&gt;@march&lt;/a&gt; 3" keeps the words and loses the meaning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table of contents and breadcrumb blocks:&lt;/strong&gt; these are generated at render time, so they either vanish or arrive as a frozen snapshot of the old hierarchy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Audit these seven types first. They account for most post-import cleanup.&lt;/p&gt;




&lt;h2&gt;
  
  
  What exactly do you lose when Notion databases become Docmost pages?
&lt;/h2&gt;

&lt;p&gt;A Notion database is a small application: a schema, a query engine and several rendered views over the same rows. Docmost is a wiki. It stores pages in a tree inside spaces, and it has no schema layer at all. Here is the before and after.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Notion capability&lt;/th&gt;
&lt;th&gt;What arrives in Docmost&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;Table, board, calendar and gallery views of one dataset&lt;/td&gt;
&lt;td&gt;One static snapshot, an HTML table or a &lt;code&gt;.csv&lt;/code&gt; file next to the page&lt;/td&gt;
&lt;td&gt;You maintain a single representation by hand instead of switching views&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filters and sorts, for example "Status is not Done, sorted by due date"&lt;/td&gt;
&lt;td&gt;Nothing, the query is not part of the exported data&lt;/td&gt;
&lt;td&gt;Every filtered list becomes a manual list you curate or a search you retype&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed properties: select, multi-select, status, date, number, checkbox&lt;/td&gt;
&lt;td&gt;Text in table cells, with no validation and no type&lt;/td&gt;
&lt;td&gt;Nothing stops you writing "In progres" and breaking your own convention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relations and rollups between two databases&lt;/td&gt;
&lt;td&gt;Plain text, both directions of the link are gone&lt;/td&gt;
&lt;td&gt;Cross references stop updating, so a renamed project no longer propagates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formulas and computed columns&lt;/td&gt;
&lt;td&gt;The last computed value, frozen as text&lt;/td&gt;
&lt;td&gt;Any total, days remaining or derived status stops recalculating on import day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Row pages with their own body content&lt;/td&gt;
&lt;td&gt;Individual pages, which is the one part that transfers well&lt;/td&gt;
&lt;td&gt;Little loss here, the content survives even when the container does not&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Count your databases before deciding. A workspace with 380 written pages and 4 small databases is a straightforward migration. A workspace built around 12 interlinked databases is a rewrite wearing a migration costume.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you rebuild a Notion database view inside Docmost?
&lt;/h2&gt;

&lt;p&gt;There is no direct replacement, so you pick a pattern per database rather than one strategy for all of them. Four patterns cover almost every case.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Index page plus child pages:&lt;/strong&gt; recreate the database as a parent page in a Docmost space, with one child page per row and a hand written table of links at the top. This works well up to roughly 30 rows, past that the index becomes the maintenance burden the database used to absorb.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One static table, no child pages:&lt;/strong&gt; paste the exported &lt;code&gt;.csv&lt;/code&gt; contents into a single Docmost table and delete the row pages. Correct for reference data you read often and edit rarely, for example a port allocation list or a hardware inventory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search instead of filters:&lt;/strong&gt; stop maintaining a filtered view and lean on Docmost's full text search plus a naming convention, for example prefixing every open item with &lt;code&gt;TODO&lt;/code&gt; in the page title. You trade a saved view for a query you retype, which is cheaper than it sounds for a single user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the structured data outside the wiki:&lt;/strong&gt; move genuinely relational data to a tool built for it, such as Grist, NocoDB or Baserow, and link to it from Docmost. This is the honest answer for a CRM or a time log, where losing typed columns and rollups is not acceptable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two Docmost features soften the blow. Page mentions and internal links let an index page stay clickable, and the built in Mermaid, Draw.io and Excalidraw blocks cover several dashboards that were really diagrams pretending to be boards.&lt;/p&gt;




&lt;h2&gt;
  
  
  Do internal links, backlinks and the page tree survive the import?
&lt;/h2&gt;

&lt;p&gt;The tree survives, the links partly survive, the backlinks do not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hierarchy:&lt;/strong&gt; the export's nested folders become nested Docmost pages inside whichever space you import into. Choose that target space deliberately, because a top level Notion page with 200 descendants lands as one page with 200 descendants, not as 200 loose pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Links to pages inside the export:&lt;/strong&gt; these are written as relative file references with URL encoding, for example &lt;code&gt;Runbook%208f3c...c21.md&lt;/code&gt;. The importer's job is to resolve them to the new Docmost page IDs, and it handles the common cases. Spot check 10 links across different depths straight after import rather than trusting the whole set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Links to pages outside the export:&lt;/strong&gt; anything you excluded, or anything in another workspace, stays as an absolute &lt;code&gt;notion.so&lt;/code&gt; URL and keeps pointing at Notion. Count them before you cancel the subscription with &lt;code&gt;grep -roh "notion.so[^)\"]*" . | wc -l&lt;/code&gt; run against the unzipped export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block level anchors:&lt;/strong&gt; a link ending in a 32 character hex fragment targets a specific block. Docmost generates its own page URLs, so those deep links land on the right page at best and break at worst.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backlinks:&lt;/strong&gt; Notion computes its backlink panel live from the graph. Nothing about it is in the export, and Docmost does not rebuild it. The reverse direction of every reference is gone on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public share links:&lt;/strong&gt; any URL you gave someone else dies with the workspace. Inventory them first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The safe order is import everything at once, then fix links, never page by page.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attachments, images and embeds after the move
&lt;/h2&gt;

&lt;p&gt;Uploaded files travel with the export. Embedded third party content mostly does not.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Images and uploaded files:&lt;/strong&gt; anything you dragged into a page sits in the export archive next to the page file, and the import copies it into Docmost's own file storage. After that your screenshots are served by your server, not by Notion's CDN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notion CDN URLs pasted as links:&lt;/strong&gt; signed &lt;code&gt;prod-files-secure&lt;/code&gt; style URLs expire. Any page where you pasted such a link rather than embedding the file will break once the workspace is gone, so search the export for that hostname and re-upload those files by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third party embeds:&lt;/strong&gt; Figma boards, Google Docs, Loom recordings, YouTube videos and tweets are references to someone else's service. Expect them to arrive as plain links. The content still loads when you click, the inline preview does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large media:&lt;/strong&gt; Docmost applies a file upload size limit through its environment configuration. Check that value before importing an export containing a 200 MB screen recording, otherwise those uploads fail silently while every 2 MB screenshot succeeds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage backend:&lt;/strong&gt; Docmost can keep files on local disk or on S3 compatible object storage. Decide before the import, because moving 40,000 attachments afterwards means rewriting paths as well as copying bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups:&lt;/strong&gt; this is the change people miss. &lt;code&gt;pg_dump&lt;/code&gt; captures your pages, not your attachments. Your backup job now needs the database and the file store, and restoring only one of them gives you a wiki full of broken image icons.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run &lt;code&gt;du -sh&lt;/code&gt; on the unzipped export first. That number is your new storage baseline.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comments, version history and page metadata: the silent losses
&lt;/h2&gt;

&lt;p&gt;Nothing here throws an error. You simply notice, three weeks later, that context you relied on is gone.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Comments:&lt;/strong&gt; inline comments and page level discussions are not part of a Notion export in any format. If a decision was argued out in a comment thread rather than written into the page, copy that thread into the page body before you export. Docmost has its own inline comments, so this is a one time reset, not a permanent gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version history:&lt;/strong&gt; Notion's revision timeline stays behind, and its retention window depends on your plan anyway. Docmost starts a fresh history from import day, which means your first 24 hours of cleanup edits are the oldest versions you will ever be able to restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Created and edited timestamps:&lt;/strong&gt; the export carries page content, not page provenance. Every imported page tends to share a single creation date, so a workspace of 400 pages looks like it was written in one afternoon. Sorting by recency is meaningless until you have used the wiki for a month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Author attribution:&lt;/strong&gt; created by and last edited by collapse to whichever Docmost account ran the import. For a solo workspace that costs nothing. For a workspace with two collaborators it erases who wrote what.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Icons and covers:&lt;/strong&gt; page emoji icons and cover images are presentation, not content, and they do not survive. Pages that you navigate by icon get noticeably harder to scan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trash and archived pages:&lt;/strong&gt; anything already deleted is not exported. Restore what you want to keep before exporting, because the Notion trash empties on its own schedule.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write down what matters. Then export.&lt;/p&gt;




&lt;h2&gt;
  
  
  How long does the import take for a workspace of a few thousand pages?
&lt;/h2&gt;

&lt;p&gt;Wall clock time is not the constraint. The machine work is minutes to hours, the human work is days. Plan around four separate costs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Export generation:&lt;/strong&gt; Notion builds the archive asynchronously and emails you a download link when it is ready. Large workspaces come back split across several &lt;code&gt;.zip&lt;/code&gt; parts rather than one file, and the link is time limited, so download it the same day it arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload path:&lt;/strong&gt; the archive has to reach Docmost through your browser and whatever sits in front of it. A reverse proxy will reject the upload long before the application does, so raise the body size limit first, for example &lt;code&gt;client_max_body_size 2048m&lt;/code&gt; in nginx, and confirm the equivalent in Caddy or Traefik if that is what you run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server side processing:&lt;/strong&gt; the import parses each file, writes rows to Postgres and copies attachments into the file store. It scales with page count and total attachment bytes, and it is disk bound more than CPU bound. Run &lt;code&gt;unzip -l export.zip | tail -1&lt;/code&gt; to get the file count before you start, so you know whether you are importing 300 items or 30,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human cleanup:&lt;/strong&gt; this dominates every other cost combined. Column layouts, callouts, synced block duplicates, leftover &lt;code&gt;notion.so&lt;/code&gt; links and every rebuilt database are manual work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do a pilot. Import one representative subtree of about 50 pages, time the machine part, then fix that subtree completely and time yourself. Multiply the second number by your total page count divided by 50. That estimate will be far closer than any general figure.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Docmost gives you that Notion never did
&lt;/h2&gt;

&lt;p&gt;The migration is not purely subtractive. Six things arrive that a hosted workspace cannot offer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Diagrams as first class blocks:&lt;/strong&gt; Draw.io, Excalidraw and Mermaid are built into the editor. In Notion those are embeds pointing at someone else's service, which is why they broke on export. Here the diagram source lives in your page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL access to your own content:&lt;/strong&gt; your pages sit in Postgres tables you can query. A &lt;code&gt;psql&lt;/code&gt; one liner finds every page mentioning a hostname, across the whole instance, including pages a search index has not caught up with yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spaces, groups and per space permissions:&lt;/strong&gt; structure access by area rather than by page. A solo user gets a clean split between a private space and one shared with two clients, without a per seat bill for the guests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An import path that keeps working:&lt;/strong&gt; the same importer handles Confluence XML exports, DOCX and PDF alongside Notion archives, so consolidating older documentation into the same wiki does not need a second tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AGPL licensed code:&lt;/strong&gt; you can read the importer to understand exactly what it does with your files, and you can patch it. When a block type flattens in a way you dislike, that is a fixable bug rather than a support ticket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No external dependency at runtime:&lt;/strong&gt; Docmost runs against your own Postgres and Redis, including in air gapped environments. Your wiki stays readable during an internet outage, which matters when the wiki holds the runbook for fixing the outage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this replaces a database view. All of it is unavailable at any Notion price.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does running Docmost require, and where should you run it?
&lt;/h2&gt;

&lt;p&gt;Three moving parts: the Docmost application container, a PostgreSQL database and a Redis instance. Nothing else is mandatory. A Docker Compose file with those three services and a named volume for the file store is the whole deployment.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Baseline resources:&lt;/strong&gt; this is a Node application with a collaboration server holding editor state in memory, plus Postgres and Redis alongside it. A single user instance is small, but treat 2 GB of RAM as the floor for the stack and give Postgres real disk rather than a network share.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A domain and TLS:&lt;/strong&gt; Docmost needs a proper origin URL for links, invites and the collaborative editing websocket. Plan for a subdomain and a certificate before the import, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A VPS you manage yourself:&lt;/strong&gt; you install Docker, write the compose file, configure a reverse proxy such as Caddy or nginx, and own the upgrades. Maximum control, maximum maintenance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A home server or NAS:&lt;/strong&gt; the hardware is already running and the electricity is already paid for. The work moves to reaching it from outside, which usually means port forwarding, dynamic DNS or a mesh VPN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A managed personal server:&lt;/strong&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. Apps install 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, no port forwarding and no manual certificate setup are involved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you pick, size the disk for your attachment total plus growth, and set up the backup job on day one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does the switch cost over three years, subscription against self-hosting?
&lt;/h2&gt;

&lt;p&gt;Do not compare a monthly fee against zero. Self-hosting moves the cost from a line item to your calendar. Take Notion's published per seat monthly rate, multiply by 36, and put that number next to the column on the right.&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;Notion subscription&lt;/th&gt;
&lt;th&gt;Self-hosted Docmost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Software&lt;/td&gt;
&lt;td&gt;Per seat, per month, for 36 months, plus any mid term price changes you do not control&lt;/td&gt;
&lt;td&gt;AGPL licensed, no fee, the self-hosted edition is the whole product for a solo user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;td&gt;A VPS bill, a NAS or home server you already own and power, or a managed personal server such as Yundera&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage growth&lt;/td&gt;
&lt;td&gt;Included until you hit a plan limit, then you upgrade the plan&lt;/td&gt;
&lt;td&gt;Disk you buy once, sized from the attachment total you measured earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backups&lt;/td&gt;
&lt;td&gt;Included and invisible, restore is a support flow&lt;/td&gt;
&lt;td&gt;Your job: a Postgres dump plus the file store, tested restores, offsite copy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upgrades&lt;/td&gt;
&lt;td&gt;Automatic, no action, no choice about timing&lt;/td&gt;
&lt;td&gt;Pull a new image, read the release notes, roll back if needed, on your schedule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One time migration&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;The cleanup days from the pilot estimate, paid once, in hours rather than currency&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest framing is that self-hosting wins on cash and loses on attention. If your hourly rate is meaningful and you dislike maintenance, three years of subscription may be cheaper than three years of patching. If the server exists anyway and Docmost is container number 12 on it, the marginal cost is close to the disk it consumes.&lt;/p&gt;

</description>
      <category>docmost</category>
      <category>selfhosted</category>
      <category>notion</category>
      <category>homelab</category>
    </item>
    <item>
      <title>How Many People Fit in a Self-Hosted Hubs Room? Dialog SFU Bandwidth and CPU Costs Explained</title>
      <dc:creator>John</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:11:34 +0000</pubDate>
      <link>https://dev.to/john_182319291/how-many-people-fit-in-a-self-hosted-hubs-room-dialog-sfu-bandwidth-and-cpu-costs-explained-4l4f</link>
      <guid>https://dev.to/john_182319291/how-many-people-fit-in-a-self-hosted-hubs-room-dialog-sfu-bandwidth-and-cpu-costs-explained-4l4f</guid>
      <description>&lt;p&gt;A single self-hosted Hubs server on a 4 vCPU, 8 GB VPS comfortably carries one room of roughly 15 to 25 participants with audio and avatars, and drops to single digits once most of them switch cameras on. The binding constraint is almost never Reticulum, the Elixir state server, which handles position and object sync at very low cost per user. It is Dialog, the mediasoup based SFU, which must decrypt, route and re encrypt every audio and video stream, and whose outbound bandwidth grows with the square of the participant count. Plan your capacity against Dialog's egress and CPU first, and treat everything else as secondary.&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 replacing a paid meeting room, for example a design studio doing weekly client walkthroughs:&lt;/strong&gt; one small VPS is enough, because a 4 person audio first room needs a fraction of a single core and well under 10 Mbit/s of egress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Community organiser running open public rooms, for example a monthly meetup with 30 drop in visitors:&lt;/strong&gt; budget for a dedicated Dialog host and cap video, because unrestricted cameras in a 30 person room push egress into hundreds of Mbit/s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Educator running parallel classrooms, for example six seminar rooms of 12 students each:&lt;/strong&gt; scale by adding Dialog instances rather than by growing one machine, since mediasoup workers are single threaded and bind to one core each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise team behind a corporate firewall, for example a distributed engineering group on locked down laptops:&lt;/strong&gt; provision TURN capacity explicitly, because relayed peers double the traffic path and can quietly triple your bandwidth bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hobbyist on a home server, for example a NAS in a cupboard on domestic fibre:&lt;/strong&gt; check upload speed before anything else, because a typical asymmetric connection saturates its uplink at around 8 to 12 video participants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event host planning a one off launch, for example 100 attendees in a showcase space:&lt;/strong&gt; shard into multiple rooms or use audio only broadcast patterns, since a single Hubs room is not designed as a webinar stage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is simple: every extra participant you allow with video multiplies Dialog's forwarding work and your egress bill, while every restriction you impose on cameras, resolution or room size buys back capacity you can spend on more concurrent rooms.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;What is the realistic concurrent user limit for one self-hosted Hubs server?&lt;/li&gt;
&lt;li&gt;Which Hubs component actually breaks first under load, Reticulum or Dialog?&lt;/li&gt;
&lt;li&gt;How does Dialog's SFU model differ from mesh and MCU, and why does it matter here?&lt;/li&gt;
&lt;li&gt;How much bandwidth does each Hubs participant consume, upstream and downstream?&lt;/li&gt;
&lt;li&gt;Why does room bandwidth grow quadratically, and where does the curve become unaffordable?&lt;/li&gt;
&lt;li&gt;How much CPU does a mediasoup worker use per audio stream and per video stream?&lt;/li&gt;
&lt;li&gt;What server specification do you need for 10, 25 and 50 concurrent participants?&lt;/li&gt;
&lt;li&gt;How much does Reticulum cost per user for position, rotation and object sync?&lt;/li&gt;
&lt;li&gt;When does TURN relay kick in, and what does it add to your bandwidth bill?&lt;/li&gt;
&lt;li&gt;How do simulcast, bitrate caps and audio only rooms change the maths?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What is the realistic concurrent user limit for one self-hosted Hubs server?
&lt;/h2&gt;

&lt;p&gt;Start from the room, not the server. A Hubs room is a single Reticulum channel plus a single Dialog routing context, so the numbers that matter are per room before they are per machine. On a 4 vCPU, 8 GB Linux host running the full Community Edition stack, plan around these ceilings.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Room style&lt;/th&gt;
&lt;th&gt;Realistic ceiling on 4 vCPU, 8 GB&lt;/th&gt;
&lt;th&gt;What stops you first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Audio only, avatars moving&lt;/td&gt;
&lt;td&gt;Roughly 25 to 30 participants&lt;/td&gt;
&lt;td&gt;Dialog egress, then mediasoup worker CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio plus a few shared cameras&lt;/td&gt;
&lt;td&gt;Roughly 15 to 20 participants&lt;/td&gt;
&lt;td&gt;Outbound bandwidth on the host uplink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everyone on camera&lt;/td&gt;
&lt;td&gt;Single digits, often 6 to 8&lt;/td&gt;
&lt;td&gt;Quadratic forwarding load in Dialog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio plus screen share at 1080p&lt;/td&gt;
&lt;td&gt;Roughly 12 to 15 participants&lt;/td&gt;
&lt;td&gt;Sustained high bitrate of the shared track&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several parallel small rooms&lt;/td&gt;
&lt;td&gt;Limited by core count, not room count&lt;/td&gt;
&lt;td&gt;One mediasoup worker binds to one core&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Treat those as planning defaults, not guarantees. Your scene complexity, bitrate caps and network path move them substantially, and the later sections quantify each factor.&lt;/p&gt;

&lt;p&gt;Where you run the stack sets the hard limit on the uplink figure. A VPS gives you symmetric bandwidth and a public IP. A home server or NAS gives you cheap storage but usually an asymmetric uplink. 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, measure the uplink with &lt;code&gt;iperf3&lt;/code&gt; before you size a room, because Dialog cannot forward what the link will not carry.&lt;/p&gt;




&lt;h2&gt;
  
  
  Which Hubs component actually breaks first under load, Reticulum or Dialog?
&lt;/h2&gt;

&lt;p&gt;Dialog breaks first, almost every time. The two services fail in completely different ways, and knowing which symptom belongs to which process saves hours of misdirected tuning.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reticulum scales the way Elixir services do:&lt;/strong&gt; it is a Phoenix application on the BEAM virtual machine, and each participant is a lightweight process handling position, rotation and object state over a WebSocket. Thousands of these coexist on one core. You will see memory grow gently and CPU stay flat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dialog scales the way media servers do:&lt;/strong&gt; it is built on mediasoup, and each worker process is single threaded and pinned to one core. When that core hits 100 percent, every stream it routes degrades at once, regardless of how idle the other cores look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failure signatures are distinct:&lt;/strong&gt; a saturated Dialog produces choppy audio, frozen video tiles and rising round trip time while avatars keep moving smoothly. A struggling Reticulum produces the opposite, teleporting avatars and stale object positions while voices stay clear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL rarely participates:&lt;/strong&gt; Reticulum writes room metadata, accounts and scene records, not per frame state, so database load stays roughly constant as a room fills.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The host uplink often beats both:&lt;/strong&gt; on a 1 Gbit/s link, sustained video forwarding can exhaust bandwidth while both processes still report headroom, which reads as network failure rather than server failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Diagnose with &lt;code&gt;top -H -p $(pgrep -f mediasoup)&lt;/code&gt; to see per thread CPU on Dialog workers, then compare against the BEAM scheduler load. If one thread sits at 100 percent and the rest idle, you have found your ceiling.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does Dialog's SFU model differ from mesh and MCU, and why does it matter here?
&lt;/h2&gt;

&lt;p&gt;Dialog is a selective forwarding unit built on mediasoup. It receives one upstream from each participant and forwards copies to everyone else without decoding or re encoding the video. That single design choice explains most of the capacity numbers in this article.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Architecture&lt;/th&gt;
&lt;th&gt;What the server does&lt;/th&gt;
&lt;th&gt;Cost at 10 participants with video&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mesh, peer to peer&lt;/td&gt;
&lt;td&gt;Nothing, clients connect directly to each other&lt;/td&gt;
&lt;td&gt;Each client uploads 9 copies and downloads 9, clients die first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SFU without simulcast, plain forwarding&lt;/td&gt;
&lt;td&gt;Copies each incoming stream to every other peer&lt;/td&gt;
&lt;td&gt;Each client uploads 1 and downloads 9, server egress is 90 stream copies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SFU with simulcast, Dialog's normal mode&lt;/td&gt;
&lt;td&gt;Picks a suitable quality layer per receiver&lt;/td&gt;
&lt;td&gt;Same stream count, lower total bitrate, no transcoding CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCU, server side mixing&lt;/td&gt;
&lt;td&gt;Decodes all streams, composites one, re encodes&lt;/td&gt;
&lt;td&gt;Each client uploads 1 and downloads 1, server CPU cost is severe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio only SFU&lt;/td&gt;
&lt;td&gt;Forwards Opus tracks, applies no video work&lt;/td&gt;
&lt;td&gt;Trivial CPU, bandwidth measured in tens of kbit/s per stream&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical consequence is that Dialog trades CPU for bandwidth. It never transcodes, so a mediasoup worker stays cheap in processor terms compared to an MCU like Jitsi Videobridge running in mixing mode. What it cannot escape is the copy count. Ten senders in one room mean 90 outbound stream copies leaving your network interface, and 20 senders mean 380.&lt;/p&gt;

&lt;p&gt;That is why you size a Hubs deployment by egress capacity and worker count rather than by raw CPU. It also means adding a faster processor rarely raises your ceiling.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much bandwidth does each Hubs participant consume, upstream and downstream?
&lt;/h2&gt;

&lt;p&gt;Bandwidth in a Hubs room comes from four separate streams, and only one of them is expensive. Measure each one before you multiply.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Voice, Opus over SRTP:&lt;/strong&gt; a single microphone track runs in the 24 to 40 kbit/s range at typical WebRTC settings, plus roughly 10 percent packet overhead. Thirty people talking is still under 1.5 Mbit/s of unique source material.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webcam video, VP8 or H.264 simulcast:&lt;/strong&gt; Dialog normally publishes several spatial layers, commonly around 150 kbit/s at 320x180, 500 kbit/s at 640x360 and 1.2 to 1.5 Mbit/s at 1280x720. Each receiver pulls the layer their connection and tile size justify, so downstream per stream varies widely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screen share at 1080p:&lt;/strong&gt; treat this as the heaviest single track in the room, typically 2 to 3 Mbit/s sustained for detailed content, and higher during rapid motion. One presenter sharing to 20 viewers costs more egress than 20 voice tracks combined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avatar and object state over Reticulum:&lt;/strong&gt; position, rotation and interaction messages travel on a WebSocket separate from the media path, measured in single digit kbit/s per user. This is noise against the media figures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scene and asset downloads:&lt;/strong&gt; a GLB scene, textures and audio files transfer once per join, so a 40 MB scene costs 40 MB per visitor at join time, not continuously.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Upstream per participant is simple: one voice track, optionally one camera, optionally one screen share. Downstream is where the count multiplies. Open &lt;code&gt;chrome://webrtc-internals&lt;/code&gt; during a live room and read &lt;code&gt;bytesSent&lt;/code&gt; and &lt;code&gt;bytesReceived&lt;/code&gt; per transport to confirm your own numbers rather than trusting defaults.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why does room bandwidth grow quadratically, and where does the curve become unaffordable?
&lt;/h2&gt;

&lt;p&gt;The arithmetic is unforgiving. With n people sending video, each of the other n minus 1 receives a copy, so Dialog emits n times n minus 1 stream copies. Doubling a room quadruples its egress.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Five senders at 500 kbit/s each:&lt;/strong&gt; 20 outbound copies, roughly 10 Mbit/s of egress. Any connection handles this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ten senders at the same bitrate:&lt;/strong&gt; 90 copies, roughly 45 Mbit/s. Still comfortable on a symmetric 100 Mbit/s link, already impossible on most domestic uplinks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twenty senders:&lt;/strong&gt; 380 copies, roughly 190 Mbit/s sustained. This is where a shared VPS network allowance starts to matter more than CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thirty senders:&lt;/strong&gt; 870 copies, roughly 435 Mbit/s. You are now consuming close to half a gigabit for one room.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fifty senders:&lt;/strong&gt; 2,450 copies, over 1.2 Gbit/s. A single 1 Gbit/s port cannot carry it, whatever the server specification says.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Convert that to metered cost before committing. Sustained 100 Mbit/s of egress transfers about 45 GB per hour, so a weekly two hour session at twenty senders moves several hundred gigabytes per month. Providers that meter egress will notice.&lt;/p&gt;

&lt;p&gt;The practical break point sits between 15 and 20 simultaneous video senders on a 1 Gbit/s host. Below it, you tune. Above it, you change the room design.&lt;/p&gt;

&lt;p&gt;Where you host decides which side of that line you sit on. A self managed VPS with a symmetric gigabit port, a home server or NAS on asymmetric fibre, and Yundera, a managed Personal Cloud Server built on CasaOS that runs self-hosted apps as Docker containers on a server dedicated to the user, each present a different uplink profile you should confirm before sizing rooms.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much CPU does a mediasoup worker use per audio stream and per video stream?
&lt;/h2&gt;

&lt;p&gt;Dialog never decodes media, so the CPU cost is packet handling, not pixel work. What a worker actually spends its cycles on is SRTP decryption of the incoming packet, per consumer bookkeeping, then SRTP encryption of each outgoing copy. Cost tracks packets per second, not bitrate alone.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audio is cheap because packet rates are low:&lt;/strong&gt; Opus at the standard 20 ms frame size produces 50 packets per second per stream. Forwarding one voice track to 29 listeners means 1,450 encryptions per second, which a single core absorbs without noticing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video is expensive because packets are many:&lt;/strong&gt; a 1.2 Mbit/s stream at a 1,200 byte payload runs at roughly 125 packets per second. The same 29 receivers turn that into 3,625 encryptions per second for one sender, and each additional sender adds another block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulcast multiplies incoming work, not outgoing:&lt;/strong&gt; the publisher sends several spatial layers, so the worker decrypts all of them but still forwards only the layer each consumer selected. Ingest cost rises modestly, egress cost does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retransmissions add unpredictable load:&lt;/strong&gt; NACK and PLI handling from lossy clients forces the worker to resend packets and request keyframes, and a handful of bad connections can cost more than a dozen clean ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker count is your only horizontal lever on one box:&lt;/strong&gt; mediasoup starts one worker process per detected CPU core by default, each single threaded, and a room is pinned to a single worker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Watch it live with &lt;code&gt;pidstat -u -p $(pgrep -d, -f mediasoup) 1&lt;/code&gt;. When one worker crosses roughly 80 percent of a core, stop adding video senders to rooms it owns.&lt;/p&gt;




&lt;h2&gt;
  
  
  What server specification do you need for 10, 25 and 50 concurrent participants?
&lt;/h2&gt;

&lt;p&gt;Size three things separately: cores for Dialog workers, RAM for Reticulum and PostgreSQL, and uplink for egress. The uplink is usually what you have to buy deliberately.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Target load&lt;/th&gt;
&lt;th&gt;Specification to plan for&lt;/th&gt;
&lt;th&gt;The binding constraint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4 to 6 people, one test room, audio first&lt;/td&gt;
&lt;td&gt;2 vCPU, 4 GB RAM, 100 Mbit/s symmetric, 40 GB disk&lt;/td&gt;
&lt;td&gt;Nothing, this runs the whole Community Edition stack on one host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 concurrent, mixed audio and occasional video&lt;/td&gt;
&lt;td&gt;4 vCPU, 8 GB RAM, 1 Gbit/s port&lt;/td&gt;
&lt;td&gt;RAM, once PostgreSQL, Reticulum and the asset hosts share a box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 concurrent, audio led with a few cameras&lt;/td&gt;
&lt;td&gt;8 vCPU, 16 GB RAM, 1 Gbit/s port, metered egress checked&lt;/td&gt;
&lt;td&gt;Sustained egress, not processor headroom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 concurrent, video restricted&lt;/td&gt;
&lt;td&gt;Dialog on its own 8 vCPU host, Reticulum and PostgreSQL on a second 4 vCPU host&lt;/td&gt;
&lt;td&gt;Worker to core pinning, plus port capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 plus, or several parallel rooms&lt;/td&gt;
&lt;td&gt;Multiple Dialog instances, one per host, rooms assigned across them&lt;/td&gt;
&lt;td&gt;Horizontal scaling, since a room cannot span workers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Allow 2 GB of RAM for PostgreSQL before tuning &lt;code&gt;shared_buffers&lt;/code&gt;, and keep 20 GB of disk free for scene assets and uploads, which grow quietly.&lt;/p&gt;

&lt;p&gt;Hosting choices split along the same lines: a self managed VPS gives you the symmetric port and root access to tune kernel networking, a home server or NAS gives you storage but rarely the uplink, and Yundera is a managed Personal Cloud Server built on CasaOS where apps are installed from an app store in one click rather than assembled from compose files by hand. Confirm the egress allowance in writing before you commit to a 25 person room.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much does Reticulum cost per user for position, rotation and object sync?
&lt;/h2&gt;

&lt;p&gt;Reticulum's cost per user is small enough that it rarely appears in capacity planning, but it is not zero, and it also fans out quadratically. The difference is payload size.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per user memory on the BEAM is measured in kilobytes:&lt;/strong&gt; each participant is an Erlang process holding channel state, so a 30 person room adds megabytes, not gigabytes. Reticulum's baseline footprint dominates, and that baseline is roughly constant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update payloads are tiny:&lt;/strong&gt; an avatar transform is a handful of floats for position, rotation and a few tracked bones. Even at 10 to 20 updates per second, one sender produces low tens of kilobits per second of unique data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fan out still squares:&lt;/strong&gt; with 30 people in a room, every update is copied 29 times. Assume 15 updates per second at 50 bytes and the room generates roughly 650 kB/s of broadcast traffic in total, which is under 6 Mbit/s. That is one twentieth of what the same room costs in video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object and media pins hit PostgreSQL, movement does not:&lt;/strong&gt; spawning, moving and pinning an object writes a row, so a room where people constantly pin content produces steady database writes while ordinary walking around produces none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join and leave events are the spiky part:&lt;/strong&gt; each join triggers presence updates to every client plus scene metadata queries, so 30 people arriving in the same 60 seconds costs more than the following hour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify on your own instance by attaching to the running node and reading scheduler utilisation with &lt;code&gt;:observer.start()&lt;/code&gt; or &lt;code&gt;recon:scheduler_usage/1&lt;/code&gt;. If Reticulum is your bottleneck, you have an unusual room, not a typical one.&lt;/p&gt;




&lt;h2&gt;
  
  
  When does TURN relay kick in, and what does it add to your bandwidth bill?
&lt;/h2&gt;

&lt;p&gt;Most Hubs clients connect straight to Dialog over UDP, because an SFU is already a public endpoint and needs no peer to peer hole punching. Relay only appears when the client side network refuses that path.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Symmetric NAT and strict firewalls force it:&lt;/strong&gt; corporate networks that permit outbound 443 only will block Dialog's UDP media range outright, and the ICE negotiation falls back rather than failing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ICE over TCP is the cheaper first fallback:&lt;/strong&gt; mediasoup can accept TCP candidates on the same host, which keeps media on your Dialog server and adds latency and head of line blocking instead of a second machine and a second bandwidth charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;coturn is the real TURN option:&lt;/strong&gt; it listens on 3478 for UDP and TCP and 5349 for TLS, with a media port range set by &lt;code&gt;min-port&lt;/code&gt; and &lt;code&gt;max-port&lt;/code&gt; in &lt;code&gt;/etc/turnserver.conf&lt;/code&gt;. Use &lt;code&gt;static-auth-secret&lt;/code&gt; with time limited credentials rather than long lived users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relayed traffic is counted twice:&lt;/strong&gt; every byte enters the TURN server and leaves it again, so a relayed participant pulling 2 Mbit/s costs 4 Mbit/s of measured transfer on that host, on top of the identical traffic Dialog already sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small relay share is a large bill:&lt;/strong&gt; in a 20 person video room, moving just 4 participants onto TURN adds their entire downstream a second time, which is a double digit percentage increase on total egress for a fifth of the audience.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Test before you need it with &lt;code&gt;turnutils_uclient -T -u user -w secret your.turn.host&lt;/code&gt;. If you skip TURN entirely, accept that some corporate and mobile users will join with avatars visible and no audio at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do simulcast, bitrate caps and audio only rooms change the maths?
&lt;/h2&gt;

&lt;p&gt;These three levers move your ceiling further than any hardware upgrade. Each one attacks the copy count or the bitrate per copy. Take the same 20 person room and compare.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;What actually changes&lt;/th&gt;
&lt;th&gt;Room egress at 20 participants&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Everyone on camera at 640x360, 500 kbit/s&lt;/td&gt;
&lt;td&gt;Nothing restricted, 380 outbound copies&lt;/td&gt;
&lt;td&gt;Roughly 190 Mbit/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitrate capped to 320x180, 150 kbit/s&lt;/td&gt;
&lt;td&gt;Same 380 copies, each one smaller&lt;/td&gt;
&lt;td&gt;Roughly 57 Mbit/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Simulcast selecting low layers for distant avatars&lt;/td&gt;
&lt;td&gt;Receivers pull the layer their tile size justifies&lt;/td&gt;
&lt;td&gt;Between the two figures above, typically nearer the lower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Camera rights limited to 4 speakers&lt;/td&gt;
&lt;td&gt;Copies fall to 4 senders times 19 receivers, 76 total&lt;/td&gt;
&lt;td&gt;Roughly 38 Mbit/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio only, avatars and screen share off&lt;/td&gt;
&lt;td&gt;380 Opus copies at about 32 kbit/s&lt;/td&gt;
&lt;td&gt;Roughly 12 Mbit/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three practical notes. Simulcast is the default worth keeping, because it costs the publisher extra upstream but saves every receiver downstream, and it degrades gracefully when one participant's connection collapses. Bitrate caps belong in Dialog's mediasoup settings rather than in client code, since anything enforced client side can be bypassed by a browser flag.&lt;/p&gt;

&lt;p&gt;Restricting camera rights is the blunt instrument that works. Hubs room settings let you decide who may share video, and moving from open cameras to four presenters cuts egress by 80 percent in the table above without touching resolution at all.&lt;/p&gt;

&lt;p&gt;Audio only is not a fallback, it is a valid room design. A 30 person social space with spatial audio, avatars and a good scene stays under 20 Mbit/s and runs on hardware that would collapse instantly with cameras enabled.&lt;/p&gt;

</description>
      <category>hubs</category>
      <category>webrtc</category>
      <category>selfhosted</category>
      <category>performance</category>
    </item>
    <item>
      <title>Duplicati on Backblaze B2 vs Backblaze Personal Backup: where the yearly cost actually crosses over</title>
      <dc:creator>John</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:05:07 +0000</pubDate>
      <link>https://dev.to/john_182319291/duplicati-on-backblaze-b2-vs-backblaze-personal-backup-where-the-yearly-cost-actually-crosses-over-2hp8</link>
      <guid>https://dev.to/john_182319291/duplicati-on-backblaze-b2-vs-backblaze-personal-backup-where-the-yearly-cost-actually-crosses-over-2hp8</guid>
      <description>&lt;p&gt;Duplicati backing up to Backblaze B2 costs less per year than a Backblaze Personal Backup subscription only below a dataset size you can calculate in one line, and that line is the whole decision. Personal Backup charges a flat annual fee per computer for unlimited data, so its cost curve is horizontal. B2 charges per gigabyte stored per month plus API calls and egress above the free allowance, so the Duplicati bill starts near zero and rises in a straight line. Divide the annual subscription fee by twelve times the B2 per gigabyte monthly rate, and you get the crossover in gigabytes: below it Duplicati wins every year, above it the subscription wins and keeps winning harder as you add data.&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 400 GB laptop owner, one MacBook holding documents, photos and a code folder:&lt;/strong&gt; run Duplicati to B2, because a few hundred gigabytes billed per gigabyte per month sits far under any flat annual fee, and Duplicati compresses and deduplicates before you are billed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 6 TB media keeper with a film and music library on external drives:&lt;/strong&gt; stay on Personal Backup, because unlimited flat pricing per computer is unbeatable at that volume and B2 charges for every terabyte forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two person household with three laptops totalling 1.5 TB:&lt;/strong&gt; run Duplicati into one B2 bucket, because the subscription is licensed per computer while a single bucket and three Duplicati jobs cover all of them under one bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The homelab operator with a NAS, a Proxmox host and two Linux VMs:&lt;/strong&gt; Duplicati is the only option of the two, because Personal Backup is a Windows and macOS desktop product that excludes network shares and server operating systems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The person who has never restored a file and wants zero maintenance:&lt;/strong&gt; stay subscribed, because Duplicati's real price includes your own hours on local database rebuilds, retention tuning, verification runs and failure monitoring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The person restoring large amounts often, for example a video editor pulling back 500 GB projects:&lt;/strong&gt; model the egress before you switch, because Personal Backup restores are included while B2 bills downloads once you pass the free egress allowance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The central tradeoff is a flat fee that ignores how much data you own against a metered bill that rewards you for owning less of it, with the difference paid in your own attention.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;What does Duplicati plus Backblaze B2 actually cost you per year?&lt;/li&gt;
&lt;li&gt;What does a Backblaze Personal Backup subscription actually cost per year?&lt;/li&gt;
&lt;li&gt;At what dataset size do the two cost curves cross?&lt;/li&gt;
&lt;li&gt;Deduplication, compression and the number B2 really bills you for&lt;/li&gt;
&lt;li&gt;How much do restores cost, and how often will you actually restore?&lt;/li&gt;
&lt;li&gt;How does retention depth change the arithmetic?&lt;/li&gt;
&lt;li&gt;Tuning Duplicati remote volume size, upload threads and API call classes&lt;/li&gt;
&lt;li&gt;Multiple computers, NAS shares and the per computer licence&lt;/li&gt;
&lt;li&gt;What each product simply refuses to back up&lt;/li&gt;
&lt;li&gt;Where should you run Duplicati, and what does the host add to the bill?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What does Duplicati plus Backblaze B2 actually cost you per year?
&lt;/h2&gt;

&lt;p&gt;Duplicati itself is free and open source, so every euro or dollar on your annual bill comes from B2 and from your own hardware. The bill has four moving parts, and only one of them is the obvious one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage per gigabyte per month:&lt;/strong&gt; B2 bills the average bytes stored across the month, so your annual cost is roughly your stored size multiplied by the published per gigabyte rate multiplied by twelve. This is the dominant term for almost everyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stored size, not source size:&lt;/strong&gt; Duplicati 2 deduplicates at a default block size of 100 KB and compresses before upload, so a 500 GB source folder rarely becomes 500 GB in the bucket. Documents and code shrink hard, video and photos barely move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaction calls:&lt;/strong&gt; B2 splits API calls into Class A, Class B and Class C, with Class A calls such as uploads free and the listing and download classes billed per thousand calls. Duplicati's default remote volume size of 50 MB means a 500 GB backup lands as roughly 10,000 objects, which keeps call counts modest but not zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress on restore:&lt;/strong&gt; B2 includes a free download allowance tied to your average stored data, and bills per gigabyte beyond it. Routine backups download nothing, so this term is zero in most years and spikes only when you restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version retention:&lt;/strong&gt; every kept version of a changed file is extra stored bytes, billed monthly like everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add those four, and you have a number that scales with what you actually own.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does a Backblaze Personal Backup subscription actually cost per year?
&lt;/h2&gt;

&lt;p&gt;Personal Backup is a flat fee per computer, billed monthly, yearly or on a two year term, with the longer terms carrying a lower effective annual rate. Unlimited means unlimited: 200 GB and 20 TB on the same machine cost the same. The complications are not in the headline price.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The licence covers one computer, not one household:&lt;/strong&gt; three laptops means three subscriptions, and the annual cost triples while the amount of data protected is irrelevant to the bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version history defaults to 30 days:&lt;/strong&gt; deleted or changed files fall out of the backup after 30 days unless you pay for an extended version history add on, sold as a one year option and a forever option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forever version history is not flat:&lt;/strong&gt; the forever tier adds a per gigabyte per month charge for data older than one year, which reintroduces exactly the metered pricing model you left B2 to avoid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External drives count, network drives do not:&lt;/strong&gt; USB attached disks are included at no extra cost, but a NAS share, a mapped network drive or a Linux server cannot be backed up at all, so protecting them means a second product on top.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attached drives must be reconnected every 30 days:&lt;/strong&gt; disconnect an external disk for longer and its backed up data is purged from the account, which is a silent cost if you rotate archive drives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restores are included:&lt;/strong&gt; web download and restore by mail both come with the plan, with the mailed drive refunded on return.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The predictable number on the invoice is the product's main feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  At what dataset size do the two cost curves cross?
&lt;/h2&gt;

&lt;p&gt;Work it out yourself in one line. Take the annual Personal Backup fee, divide it by twelve, then divide that by the current B2 per gigabyte monthly rate. The result is the crossover in gigabytes stored, and both numbers are published on Backblaze's own pricing pages, so the calculation stays correct as rates change. Call that crossover point C.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bytes stored in B2&lt;/th&gt;
&lt;th&gt;Duplicati annual cost versus one subscription&lt;/th&gt;
&lt;th&gt;What it means for you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.25 x C&lt;/td&gt;
&lt;td&gt;About 25 percent&lt;/td&gt;
&lt;td&gt;Duplicati is clearly cheaper, and the gap is large enough to absorb restores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5 x C&lt;/td&gt;
&lt;td&gt;About 50 percent&lt;/td&gt;
&lt;td&gt;Duplicati is cheaper, with room for retention growth before parity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 x C&lt;/td&gt;
&lt;td&gt;Parity&lt;/td&gt;
&lt;td&gt;Choose on features, not on price, because the invoices match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 x C&lt;/td&gt;
&lt;td&gt;About 200 percent&lt;/td&gt;
&lt;td&gt;The subscription is cheaper and the gap widens every month you add data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 x C&lt;/td&gt;
&lt;td&gt;About 400 percent&lt;/td&gt;
&lt;td&gt;Only non cost reasons justify Duplicati at this volume&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two adjustments move C in your favour. First, C scales linearly with the number of machines: three computers on Personal Backup means three fees, so your effective crossover is 3 x C for a single B2 bucket holding all three. Second, C applies to stored bytes, not source bytes, so Duplicati compression can put a 700 GB source folder comfortably under a 500 GB crossover.&lt;/p&gt;

&lt;p&gt;One adjustment moves it against you: unlimited retention on B2 grows stored bytes forever, while the flat fee never notices.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deduplication, compression and the number B2 really bills you for
&lt;/h2&gt;

&lt;p&gt;Your source folder size is not your invoice. Duplicati hashes every block with SHA-256, stores each unique block once, packs blocks into archive volumes and compresses them before upload. Four things decide how far that gets you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File type dominates the ratio:&lt;/strong&gt; text, code, spreadsheets, mailboxes and virtual disk images compress and deduplicate heavily. JPEG, MP4, MP3 and existing ZIP archives are already compressed, so expect close to no reduction. Duplicati ships a &lt;code&gt;default_compressed_extensions.txt&lt;/code&gt; list and skips compressing those types rather than burning CPU for nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplication is per backup job, not per bucket:&lt;/strong&gt; two jobs covering the same 40 GB photo folder store those blocks twice and you pay twice. Consolidating overlapping folders into one job is free money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleted data stays billable until compaction runs:&lt;/strong&gt; when retention removes an old version, its blocks remain inside archive volumes that still contain live blocks. Duplicati only rewrites a volume once wasted space passes the compact threshold, 25 percent by default, so stored bytes lag behind logical deletions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encryption adds a small constant, not a percentage:&lt;/strong&gt; AES-256 output is roughly the size of its compressed input plus per volume header overhead, so encryption costs you almost nothing on the bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The dashboard tells you the truth:&lt;/strong&gt; the job's reported backend size, not the source size, is the number to feed into the crossover calculation from the previous section.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Measure once with a real backup before committing. A 1 TB documents heavy dataset and a 1 TB video library produce completely different B2 bills.&lt;/p&gt;




&lt;h2&gt;
  
  
  How much do restores cost, and how often will you actually restore?
&lt;/h2&gt;

&lt;p&gt;Most people restore a handful of files a year and a full dataset once a decade, if ever. That matters because B2 includes a free download allowance of three times your average monthly stored data, so a household sized restore usually lands inside it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A single full restore is normally free:&lt;/strong&gt; pulling back everything you store is 1x your stored bytes against a 3x allowance, so you would need to restore your entire backup three times in one month before egress charges start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small restores cost more than you expect in bytes:&lt;/strong&gt; Duplicati downloads whole archive volumes, so recovering one 2 MB document can pull a 50 MB volume. The cash impact is negligible, the wait is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restoring without the local database is the slow path:&lt;/strong&gt; direct restore first downloads the &lt;code&gt;dlist&lt;/code&gt; and &lt;code&gt;dindex&lt;/code&gt; files to rebuild an index of what is where, which on a large backup with many versions can take hours before a single file lands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Class B download transactions are billed per call:&lt;/strong&gt; thousands of volume fetches during a big restore add a small line item alongside egress, another reason larger remote volume sizes suit archival datasets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal Backup charges nothing for either route:&lt;/strong&gt; web download restores are included, and the restore by mail USB drive is refunded when you return it, so the subscription genuinely removes restore cost from the equation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Budget the shape, not a scary number: routine restores are effectively free on both, and only repeated bulk pulls of your whole dataset make B2 egress a real line on the invoice.&lt;/p&gt;




&lt;h2&gt;
  
  
  How does retention depth change the arithmetic?
&lt;/h2&gt;

&lt;p&gt;Comparing a 30 day subscription against an unlimited history Duplicati job is not a fair comparison. Match the retention first, then compare the invoices.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Retention setting&lt;/th&gt;
&lt;th&gt;Effect on your bill&lt;/th&gt;
&lt;th&gt;Where it applies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Keep all versions, the Duplicati default&lt;/td&gt;
&lt;td&gt;Stored bytes grow with every change forever, so the B2 line rises even if your source folder never grows&lt;/td&gt;
&lt;td&gt;Duplicati&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--keep-time=30D&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Roughly matches the standard subscription window, keeping stored bytes close to your live dataset size&lt;/td&gt;
&lt;td&gt;Duplicati&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--retention-policy=1W:1D,4W:1W,12M:1M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Thins older versions to one per week then one per month, buying a year of history for a fraction of the bytes&lt;/td&gt;
&lt;td&gt;Duplicati&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 day version history, the plan default&lt;/td&gt;
&lt;td&gt;No extra charge, and anything deleted more than 30 days ago is gone&lt;/td&gt;
&lt;td&gt;Personal Backup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One year extended version history&lt;/td&gt;
&lt;td&gt;A fixed add on fee per computer per year, still flat regardless of data volume&lt;/td&gt;
&lt;td&gt;Personal Backup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forever version history&lt;/td&gt;
&lt;td&gt;Flat fee plus a per gigabyte monthly charge on data older than one year, so the metered model returns&lt;/td&gt;
&lt;td&gt;Personal Backup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical consequence is that deep retention narrows the gap. If you want a year of history, the subscription add on is a fixed number per computer, while Duplicati's smart retention adds real bytes to a metered bill. If you want a decade of history, Duplicati's thinned policy competes well against a forever tier that meters everything over twelve months old.&lt;/p&gt;

&lt;p&gt;Set retention before you measure stored size, or your crossover calculation is based on a number that will keep climbing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tuning Duplicati remote volume size, upload threads and API call classes
&lt;/h2&gt;

&lt;p&gt;Defaults are tuned for a 50 GB laptop, not a 2 TB archive. Four settings change your bill, your backup window and your restore speed, and they pull against each other.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--dblock-size&lt;/code&gt;, default 50 MB:&lt;/strong&gt; raising it to 200 MB or 500 MB cuts object count and the listing calls that go with it by the same factor. The cost is restore granularity, since recovering one small file downloads one whole volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--blocksize&lt;/code&gt;, default 100 KB:&lt;/strong&gt; this one is fixed at job creation and cannot be changed later without starting over. A 2 TB dataset at 100 KB implies roughly 20 million block records in the local SQLite database, which is what makes repair and rebuild operations crawl. Raising it to 1 MB or larger for multi terabyte jobs trades a little deduplication for a database that stays usable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--asynchronous-concurrent-upload-limit&lt;/code&gt;, default 4:&lt;/strong&gt; more parallel uploads fill a fast link, but each in flight volume needs temporary disk space, and B2 counts each upload as a Class A call, which is not billed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--number-of-retries&lt;/code&gt;, default 5, with &lt;code&gt;--retry-delay&lt;/code&gt;:&lt;/strong&gt; raising both rides out transient B2 errors instead of failing a job at 90 percent and re uploading later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The class split is what makes B2 forgiving here: uploads and deletes are Class A and free, downloads are Class B, and listing is Class C with a published daily free allowance. Duplicati lists the bucket on every run, so shrinking object count with a larger volume size keeps you comfortably inside it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Multiple computers, NAS shares and the per computer licence
&lt;/h2&gt;

&lt;p&gt;This is where the flat fee stops being flat. Every extra machine multiplies a subscription while adding only its own gigabytes to a B2 bucket, so a four device household is a completely different calculation from a single laptop.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One bucket serves every machine:&lt;/strong&gt; give each host its own Duplicati job and its own &lt;code&gt;--prefix&lt;/code&gt; value, for example &lt;code&gt;laptop-anna/&lt;/code&gt; and &lt;code&gt;nas-media/&lt;/code&gt;, so their archive volumes stay separated inside a single bucket and a single bill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issue one restricted application key per host:&lt;/strong&gt; B2 application keys can be scoped to one bucket and one name prefix, so a compromised laptop key cannot read or delete another machine's backup. The master key stays out of every configuration file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A central job over SMB shares deduplicates across machines:&lt;/strong&gt; point one Duplicati instance at mounted network shares and shared files are stored once. The catch is that a laptop out of the house at 02:00 is simply not backed up that night.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per machine agents catch laptops, at a cost:&lt;/strong&gt; Duplicati runs on Windows, macOS, Linux and in Docker, so each device can back itself up whenever it has a connection, but identical files on two machines are billed twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Servers and NAS boxes have no subscription path at all:&lt;/strong&gt; a Synology volume, a Proxmox host or a Linux VPS cannot be enrolled in a desktop plan, so mixed estates end up paying for two products anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Count your machines before you compare prices. Three subscriptions against one metered bucket moves the crossover point three times further out.&lt;/p&gt;




&lt;h2&gt;
  
  
  What each product simply refuses to back up
&lt;/h2&gt;

&lt;p&gt;Both tools have hard exclusions, and neither warns you loudly about them. Check this list against what you actually care about before comparing invoices.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data&lt;/th&gt;
&lt;th&gt;Backblaze Personal Backup&lt;/th&gt;
&lt;th&gt;Duplicati&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Operating system files, applications, temporary files&lt;/td&gt;
&lt;td&gt;Excluded by default, since the product protects user data rather than a bootable image&lt;/td&gt;
&lt;td&gt;Included if you select the path, though it is not a bare metal image either&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Virtual disk images such as &lt;code&gt;.vmdk&lt;/code&gt;, &lt;code&gt;.vhd&lt;/code&gt; and &lt;code&gt;.iso&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Excluded by default through the client's exclusion rules, editable in settings&lt;/td&gt;
&lt;td&gt;Included, and they deduplicate well between versions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network shares and NAS volumes&lt;/td&gt;
&lt;td&gt;Not supported at all&lt;/td&gt;
&lt;td&gt;Supported through any mounted path, including SMB and NFS mounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux hosts and servers&lt;/td&gt;
&lt;td&gt;No client exists&lt;/td&gt;
&lt;td&gt;First class, including headless Docker deployments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open or locked files&lt;/td&gt;
&lt;td&gt;Handled by the client's own snapshot logic&lt;/td&gt;
&lt;td&gt;Requires &lt;code&gt;--snapshot-policy=on&lt;/code&gt; to use VSS on Windows or LVM on Linux&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live databases&lt;/td&gt;
&lt;td&gt;Files are copied as found, with no consistency guarantee&lt;/td&gt;
&lt;td&gt;Same, unless you dump first with &lt;code&gt;--run-script-before&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two practical consequences follow. If your important data includes VM images or a NAS share, the subscription's unlimited pricing never applies to it, so the comparison collapses before cost enters the picture. If your important data is a laptop's documents and photos, both cover it fully, and you are back to arguing about gigabytes.&lt;/p&gt;

&lt;p&gt;Also check the client's maximum file size setting before trusting it with 80 GB video projects, because a size cap silently skips files rather than failing the backup.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where should you run Duplicati, and what does the host add to the bill?
&lt;/h2&gt;

&lt;p&gt;Duplicati has to run somewhere that can read your files, stay awake on schedule and hold a local SQLite database. That host is the hidden line item the subscription does not have.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On the machine being backed up:&lt;/strong&gt; the simplest option, with the web interface on port 8200 and no extra hardware cost. The catch is that a sleeping laptop runs no backup, so set a schedule that matches when the lid is actually open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a NAS via Docker:&lt;/strong&gt; one container backs up every share, which suits the central job pattern. Budget the local database on fast storage, because rebuilds on a spinning disk are painfully slow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a mini PC or home server:&lt;/strong&gt; a low power box running 24/7 adds electricity and a machine to maintain, but it backs up network shares on a fixed schedule without depending on anyone's laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a VPS or a managed personal server:&lt;/strong&gt; useful when the data already lives off site, for example a web application's files and database dumps. 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 alongside a self managed VPS, a home server or a NAS as one place to run the container.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule applies to every choice: a remote host can only back up data it can reach. Running Duplicati off site does nothing for the photos on a laptop unless those photos already sync somewhere the container can read.&lt;/p&gt;

</description>
      <category>duplicati</category>
      <category>backup</category>
      <category>selfhosted</category>
      <category>backblaze</category>
    </item>
  </channel>
</rss>
