<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Homelab | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/homelab/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 19 Oct 2026 23:20:51 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/homelab/index.xml" rel="self" type="application/rss+xml"/><item><title>Linux users: always choose BTRFS, not EXT4</title><link>https://akitaonrails.github.io/en/2026/10/12/linux-users-always-choose-btrfs-not-ext4/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/10/12/linux-users-always-choose-btrfs-not-ext4/</guid><pubDate>Mon, 12 Oct 2026 23:00:00 -0300</pubDate><description>&lt;p&gt;This post is a direct sequel to the &lt;a href="https://akitaonrails.github.io/en/2026/10/06/disaster-on-my-nas-how-i-almost-lost-90-tb-of-data/"&gt;disaster on my NAS&lt;/a&gt;. Summary for newcomers: my Synology, with eight drives in SHR (single parity), lost two drives in one strike during an upgrade. I survived thanks to an SHR implementation detail, bought a QNAP in a hurry, spent a week copying and verifying 91 TB, and lost not a single file by accident. The new QNAP runs ZFS in RAID-6; the rebuilt Synology now runs SHR-2.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is a direct sequel to the <a href="/en/2026/10/06/disaster-on-my-nas-how-i-almost-lost-90-tb-of-data/">disaster on my NAS</a>. Summary for newcomers: my Synology, with eight drives in SHR (single parity), lost two drives in one strike during an upgrade. I survived thanks to an SHR implementation detail, bought a QNAP in a hurry, spent a week copying and verifying 91 TB, and lost not a single file by accident. The new QNAP runs ZFS in RAID-6; the rebuilt Synology now runs SHR-2.</p>
<p>Underneath this whole story there&rsquo;s a decision almost nobody makes consciously: <strong>the filesystem</strong>. The Synology used btrfs all along. The QNAP uses ZFS. And your Linux machine, most likely, came from the factory with ext4 without anyone asking you anything. That&rsquo;s what this is about.</p>
<h2>Where the two came from<span class="hx:absolute hx:-mt-20" id="where-the-two-came-from"></span>
    <a href="#where-the-two-came-from" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>ext4 is the end of a dynasty of over three decades. ext (extended filesystem) was born with Linux itself, ext2 became the standard in the 90s, ext3 added a journal in 2001 so you wouldn&rsquo;t need an epic fsck after every power loss, and ext4 arrived in 2008 with extents, delayed allocation and volumes up to 1 exbibyte. The design goal was always the same: be the default filesystem, fast, simple and predictable. It&rsquo;s excellent at that. And it stopped there.</p>
<p>btrfs was born in 2007, at Oracle, with Chris Mason, with a completely different goal: be Linux&rsquo;s answer to ZFS. Instead of writing data over data, it&rsquo;s copy-on-write: every write goes to a new block and the pointers are updated cascading up the tree. That single decision is the mother of everything that matters in it.</p>
<p>What each one delivers, in a list:</p>
<p><strong>ext4:</strong></p>
<ul>
<li>journaling (consistent metadata after a crash)</li>
<li>extents and delayed allocation (good performance)</li>
<li>mature, stable, everywhere, every tool works</li>
</ul>
<p><strong>btrfs:</strong></p>
<ul>
<li>copy-on-write on everything</li>
<li>data and metadata checksums on every block</li>
<li>instant, cheap snapshots</li>
<li>independent subvolumes inside the same volume</li>
<li>transparent compression (zstd, lzo, zlib)</li>
<li>native RAID and scrub (verification sweep)</li>
<li>snapshot send/receive for incremental backup</li>
<li>reflinks (copies that don&rsquo;t duplicate blocks)</li>
</ul>
<p>The list already answers the title&rsquo;s question. Let&rsquo;s go piece by piece.</p>
<h2>Even NTFS and APFS have passed ext4<span class="hx:absolute hx:-mt-20" id="even-ntfs-and-apfs-have-passed-ext4"></span>
    <a href="#even-ntfs-and-apfs-have-passed-ext4" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>And if the comparison with btrfs seems unfair, look sideways. NTFS was born in 1993, with Windows NT, already with metadata journaling and ACLs, and back in the 2000s it gained a log of every change on the volume plus shadow copies (Windows&rsquo; &ldquo;Previous Versions&rdquo;). APFS, which Apple launched in 2017 made for flash, is copy-on-write like btrfs: native snapshots (it&rsquo;s what Time Machine uses for local restores), instant file clones that share blocks, volumes sharing the space of the same container, and per-file encryption.</p>
<p>Read the ext4 list again and compare: no snapshots, no data checksums, no CoW, no compression. Windows&rsquo; and Mac&rsquo;s filesystems, which nobody considers examples of sophistication, protect your data better than Linux&rsquo;s default, whose last big feature came out in 2008. btrfs is what puts Linux in the same generation as the other two, instead of a decade behind.</p>
<h2>Why btrfs is far superior<span class="hx:absolute hx:-mt-20" id="why-btrfs-is-far-superior"></span>
    <a href="#why-btrfs-is-far-superior" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>What copy-on-write is, for those who never stopped to think about it<span class="hx:absolute hx:-mt-20" id="what-copy-on-write-is-for-those-who-never-stopped-to-think-about-it"></span>
    <a href="#what-copy-on-write-is-for-those-who-never-stopped-to-think-about-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In classic filesystems, writing is destructive by definition: updating a block means writing the new content over the old one, in the same place on disk. If the machine dies mid-write, the file ends up half old, half new. The ext3/ext4 journal covers that for <strong>metadata</strong> (the directory structure doesn&rsquo;t corrupt), but your data itself is left to luck.</p>
<p>Copy-on-write inverts the order: <strong>nothing is ever overwritten</strong>. Writing to a file allocates a new block, writes the new content there, and only when everything is safely on disk does the filesystem flip the pointers to the new version, atomically. At any instant, you have either the whole old version or the whole new version. Never a mix of the two.</p>
<p>What that buys in practice:</p>
<ul>
<li><strong>Nearly free snapshots.</strong> The old blocks already persist by CoW&rsquo;s nature; a snapshot is just the filesystem holding the root pointer of that instant, pure accounting.</li>
<li><strong>Power loss doesn&rsquo;t corrupt.</strong> An interrupted write leaves the previous version intact, because it was never touched. For anyone who has lost a VM image or a database to a mid-write crash, this is worth the whole filesystem.</li>
<li><strong>Verification becomes possible.</strong> Since each block is written once and never touched again, keeping its checksum forever makes sense. It&rsquo;s the base of the corruption protection I detail further down.</li>
</ul>
<p>The cost exists and I pay it knowingly: random-write-heavy files (VM disks, databases) fragment and duplicate blocks, because each rewrite becomes a new block. For those cases you disable CoW per directory with <code>chattr +C</code>, which must be set <strong>before</strong> the file is created. In my layout, the VM image made with the right <code>+C</code> stayed healthy; the one made without became the textbook fragmentation case.</p>
<h3>Snapshots are the feature that changes your life<span class="hx:absolute hx:-mt-20" id="snapshots-are-the-feature-that-changes-your-life"></span>
    <a href="#snapshots-are-the-feature-that-changes-your-life" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A btrfs snapshot is an instant photo of a subvolume, and it costs almost nothing, because CoW already keeps the old blocks while someone references them. Creating a snapshot is a seconds operation, not hours. In practice: before every upgrade, the system takes a photo. If the upgrade breaks, you don&rsquo;t &ldquo;uninstall packages&rdquo;: you boot back into the previous photo and you&rsquo;re done. The boot recovery section, further down, shows this happening for real.</p>
<h3>Subvolumes are the right way to organize the disk<span class="hx:absolute hx:-mt-20" id="subvolumes-are-the-right-way-to-organize-the-disk"></span>
    <a href="#subvolumes-are-the-right-way-to-organize-the-disk" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A subvolume is an independent namespace inside the same filesystem, with its own snapshot policy. And it has a killer property: <strong>snapshots are not recursive</strong>. A child subvolume inside another is automatically left out of the parent&rsquo;s snapshot.</p>
<p>I use this for everything. On my desktop, the cache directories that can be re-downloaded (mise, rustup, npm, opencode, AI caches) are becoming nested subvolumes inside <code>@home</code>: they stay in the same place for any program, but out of every snapshot. That separation alone takes 165 GiB out of snapshot scope, and the big caches (Hugging Face, yay, uv, pip) already live outside the system volume, with symlinks pointing to them: minus 148 GiB of snapshot, part already migrated and part in progress. The snapshot stays small, the restore stays fast, and whatever can be re-downloaded from the internet never enters the photo.</p>
<p>The real cost, measured on the new QNAP (ZFS, but the CoW math is the same): 27 hourly snapshots of a folder with almost 80 TB, and nearly all of them consume megabytes, because between one hour and the next almost nothing changed. Cheap snapshots are what let this routine exist without guilt.</p>
<p><img src="/2026/10/12/usuarios-linux-sempre-escolham-btrfs-nao-ext4/qnap-snapshot-manager.png" alt="QNAP Snapshot Manager with 27 hourly snapshots of the CHAD folder, most consuming between 3 and 300 MB"  loading="lazy" /></p>
<h3>Reflink deduplication<span class="hx:absolute hx:-mt-20" id="reflink-deduplication"></span>
    <a href="#reflink-deduplication" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>First, the caveat that matters: btrfs has no native online deduplication like ZFS (which keeps a dedup table in RAM and charges dearly for it). What it has are two practical things: <strong>reflink</strong> and offline dedup. Reflink is a copy that shares blocks: <code>cp --reflink=auto</code> creates a new file that points to the same blocks as the original, instant and free, and each one starts diverging only where written. In the era of dozens of checkouts, worktrees and similar builds, that saves a mountain of blocks. For retroactive dedup there are userspace tools like <a href="https://github.com/markfasheh/duperemove"target="_blank" rel="noopener">duperemove</a> and <a href="https://github.com/Zygo/bees"target="_blank" rel="noopener">bees</a>.</p>
<h3>Real corruption protection<span class="hx:absolute hx:-mt-20" id="real-corruption-protection"></span>
    <a href="#real-corruption-protection" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Every btrfs block, data or metadata, carries a checksum. When the filesystem reads and the checksum doesn&rsquo;t match, it knows the block rotted, and if there&rsquo;s a second copy (RAID or metadata DUP) it self-heals. ext4 checks no data at all: bit rot passes silently until you open the file years later.</p>
<p>Two pieces of my configuration show what that buys in practice, and what ext4 simply doesn&rsquo;t offer. The first is metadata in DUP mode. On ext4, each structure on the volume exists only once: a well-placed bad sector corrupts the directory table and the whole drive stops mounting. On btrfs, even with a single drive, every metadata block exists duplicated. The bad sector keeps existing, but the checksum flags the rotten block on read and the second copy answers in its place, without you noticing. It&rsquo;s the difference between losing the volume and noting a warning in the log.</p>
<p>The second is the monthly scrub. ext4 never re-reads anything to check health: if a block rots, you find out the day someone tries to open that specific file, maybe years later, when there&rsquo;s no fixing it anymore. The scrub is a scheduled sweep that re-reads the whole volume validating checksums, and finds the rot while it&rsquo;s still small and recoverable. When the <code>btrfs device stats</code> counter leaves zero, it&rsquo;s the siren to replace the drive before the disaster, not after.</p>
<h3>Transparent compression, measured<span class="hx:absolute hx:-mt-20" id="transparent-compression-measured"></span>
    <a href="#transparent-compression-measured" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Every mount on my desktop uses <code>compress=zstd:1</code>. Level 1 costs a few percentage points of one CPU core, and btrfs detects what doesn&rsquo;t compress (model blob, video) and writes it raw, at no cost. The result, measured this week on my two new drives:</p>
<p><img src="/2026/10/12/usuarios-linux-sempre-escolham-btrfs-nao-ext4/btrfs-compressao-economia.png" alt="Compression savings table: 2.2 TB of data taking 1.6 TB on disk, with 35% savings on the system, 39% on Docker layers and zero on AI model weights"  loading="lazy" /></p>
<p>That&rsquo;s <strong>630 GB of free space</strong> on 2.2 TB: 15% more drive, paying nothing. On the initial migration the gain was even bigger, 45% on the system volume, because there was more text and less binary. Notice the table&rsquo;s details: programs and libraries compress well, Docker layers compress even better (39%), text logs compress 4.5x, and AI model weights compress nothing, which is expected and costs nothing. Source code, which is what we produce the most in the AI era, compresses beautifully.</p>
<h2>Synology uses btrfs, QNAP uses ZFS<span class="hx:absolute hx:-mt-20" id="synology-uses-btrfs-qnap-uses-zfs"></span>
    <a href="#synology-uses-btrfs-qnap-uses-zfs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Being fair to ZFS: for a big NAS it&rsquo;s superior, and it&rsquo;s what I chose for the QNAP. Native checksums and self-healing, mature RAIDZ, excellent send/receive. The price is complexity and resources: ZFS wants RAM for the ARC cache (my QNAP has 7 GB for a 174 TB pool, and I feel the lack), its online deduplication would need about 190 GB of RAM just for the table on my volume, and its provisioning model gave me a free scare when the snapshot was refused with 20 TB free (the thick vs thin story is in the NAS article).</p>
<p>btrfs is the right balance for a consumer machine. Light, native in the kernel, does almost everything ZFS does without demanding a storage administrator. Desktop, laptop, homelab, small or medium NAS: btrfs. Synology proved it in practice, twice: the btrfs volume survived readable with two drives out, and its snapshots saved 11 albums I deleted by mistake in the middle of the cleanup, byte by byte.</p>
<blockquote>
  <p>Big, serious NAS, with RAM to spare: ZFS. Everything else: btrfs. And for a personal Linux machine, it&rsquo;s not even a discussion.</p>

</blockquote>
<h2>Boot recovery via snapshot<span class="hx:absolute hx:-mt-20" id="boot-recovery-via-snapshot"></span>
    <a href="#boot-recovery-via-snapshot" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The feature that has saved me for real, told on both machines.</p>
<p>On the desktop, <a href="https://omarchy.org"target="_blank" rel="noopener">Omarchy</a> configures <a href="https://snapper.io/"target="_blank" rel="noopener">snapper</a> to take a snapshot before every update and <code>limine-snapper-sync</code> publishes each snapshot as a boot entry in <a href="https://github.com/limine-bootloader/limine"target="_blank" rel="noopener">Limine</a>. New kernel won&rsquo;t come up? In the boot menu you pick yesterday&rsquo;s snapshot and the machine comes back as if nothing happened. On my machine the scheduled routine runs via <a href="https://github.com/teejee2008/timeshift"target="_blank" rel="noopener">Timeshift</a> on cron (every boot and hourly), with snapper covering the update points: the way back is the same mechanism. (The gotcha: each snapshot entry carries the kernel image along, so a small ESP fills up fast. My 1 GB one already refused a kernel update for lack of space; the system stayed bootable on the old kernel, which is the right behavior.)</p>
<p>On the server, which runs <a href="https://get.opensuse.org/microos/"target="_blank" rel="noopener">openSUSE MicroOS</a>, it&rsquo;s even stronger: the whole system is transactional btrfs, and every update is a new snapshot. Last week, kernel 7.2.9 took down the NVMe controller silently, twice, in the middle of the nightly backup. The fix was a <code>transactional-update rollback</code> and a reboot back to 7.2.6, which ran the whole load (754 GiB of backup reads with concurrent download) without a hiccup. A kernel regression became a scheduled reboot, not a recovery all-nighter. One documented caveat: <code>/etc</code> lives inside the snapshot, so the rollback undoes configuration changes along with it. After any rollback, check your units again.</p>
<h2>Maintenance is not optional (and the deleted file that won&rsquo;t go away)<span class="hx:absolute hx:-mt-20" id="maintenance-is-not-optional-and-the-deleted-file-that-wont-go-away"></span>
    <a href="#maintenance-is-not-optional-and-the-deleted-file-that-wont-go-away" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A modern CoW filesystem delivers a lot, but it charges two routines ext4 never asked for.</p>
<p>The first is the scrub, which already appeared here: re-read everything and check checksums, monthly, on all machines. It&rsquo;s bit-rot detection in action, and without it the checksum only warns you about the rot when you try to read the file. The second point is subtler: <strong>a snapshot pins the blocks of deleted files</strong>. When you delete a file inside a snapshotted subvolume, the space doesn&rsquo;t come back, because the block stays referenced by the photo. That&rsquo;s exactly what happened on my NAS: the 2.14 TB cleanup I ran on the Synology only freed space for real after the old snapshots were removed; until then, the blocks stayed pinned.</p>
<p>The treatment is simple routine. Retention has to be automatic, with snapper or Timeshift deleting the old snapshots per policy (on the QNAP, the Snapshot Manager&rsquo;s retention policy plays the same role). Space that doesn&rsquo;t come back after deleting old snapshots is usually over-allocated chunks: a <code>btrfs balance start -dusage=50</code> returns the little-used pieces to the free pool (in my house I don&rsquo;t blindly schedule balance, I monitor the unallocated space and act when it gets tight). And on the restic side, <code>forget</code> alone only removes the snapshot&rsquo;s reference: the repository space is only reclaimed with <code>prune</code> along, <code>forget --prune</code>.</p>
<h2>House cleaning in the AI coding era<span class="hx:absolute hx:-mt-20" id="house-cleaning-in-the-ai-coding-era"></span>
    <a href="#house-cleaning-in-the-ai-coding-era" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now the part almost nobody connects with filesystems: the agent era produces garbage at industrial speed. I know because I measured.</p>
<ul>
<li><strong>Rust is the biggest disk polluter in the dev world.</strong> Every build leaves behind 100 to 180 MB test binaries, and they accumulate with each edit. On my desktop, the <code>target/</code> directories added up to <strong>920 GiB</strong>. ai-memory alone accounted for 704 GiB. The hygiene tool I was using is <a href="https://github.com/holmgr/cargo-sweep"target="_blank" rel="noopener">cargo-sweep</a>, weekly. A reader pointed out that the project declares itself unmaintained, and its own README points to <a href="https://github.com/dnlmlr/cargo-clean-all"target="_blank" rel="noopener">cargo-clean-all</a>, which works differently: instead of pruning old artifacts inside one <code>target/</code>, it finds every Rust project under a directory and deletes the whole <code>target/</code> of the ones you filter, for example <code>cargo clean-all --keep-days 7 ~</code> to clean only what hasn&rsquo;t been compiled in a week.</li>
<li><strong>Docker is right behind.</strong> On my machine I found 1.26 TB of build cache plus 938 GB of images. A single cleanup recovered about 600 GB. On the server, the weekly cleanup collects 0.6 to 4.5 GB every Sunday.</li>
<li><strong>AI and package caches bloat quietly.</strong> The Hugging Face cache had 86 GB until I deleted three dead models and it dropped to 21. Pacman had 22 GB of outdated packages. yay, uv and pip, another 125 GB.</li>
<li><strong>And agents create temporary directories without asking permission.</strong> A complex project with several branches means worktrees, and each release worktree has its own <code>CARGO_TARGET_DIR</code> full of test binaries. On ai-memory that has already blown up the disk, and it became a mandatory rule in the project&rsquo;s AGENTS.md: after every release, <code>cargo clean</code> in each created worktree and removal of finished worktrees, no exceptions.</li>
</ul>
<p>The solution is to automate the sweeping. My minimal example, a user timer that runs on the 1st and 15th at dawn, with Nice and idle I/O:</p>
<ul>
<li>deletes <code>target/</code> directories of projects idle for more than 14 days</li>
<li>cleans the yay cache</li>
<li>prunes only Docker build cache older than 30 days, never images or volumes</li>
</ul>
<p>And the part that became standard in my house: <strong>instruct the agents to clean up what they create</strong>. Three real rules from my projects, for you to copy:</p>
<blockquote>
  <p><strong>In the desktop&rsquo;s AGENTS.md:</strong> &ldquo;Never run broad cleanups like <code>docker system prune</code> without explicit user authorization after a read-only inventory. Cleanup uses an exact list of IDs; idle, old or unreferenced does not mean disposable.&rdquo;</p>

</blockquote>
<blockquote>
  <p><strong>In the homelab&rsquo;s CLAUDE.md:</strong> &ldquo;Before finishing any task, remove the temporary things the task created, and only those. Never delete what you didn&rsquo;t create or can&rsquo;t attribute to the task. When in doubt, list it for the owner.&rdquo;</p>

</blockquote>
<blockquote>
  <p><strong>In ai-memory&rsquo;s AGENTS.md:</strong> &ldquo;<code>target/</code> grows without limit: every edit to a shared crate leaves behind a copy of each 100-180 MB test binary (seen at 157 GiB). Weekly <code>cargo sweep --time 7</code>, and mandatory <code>cargo clean</code> after every release.&rdquo;</p>

</blockquote>
<h2>And the real backup: restic<span class="hx:absolute hx:-mt-20" id="and-the-real-backup-restic"></span>
    <a href="#and-the-real-backup-restic" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A snapshot lives on the same drive, so a snapshot is not a backup: if the drive dies, the snapshot dies with it. The last item in the kit is <a href="https://restic.net/"target="_blank" rel="noopener">restic</a>, which for me is the best backup system on Linux today.</p>
<p>restic is a single open source binary, written in Go, that does deduplicated, encrypted and versioned backup to practically any destination: another directory, another NAS via SFTP or REST, S3, Glacier. The central trick is content-defined chunking: it slices each file into variable-size blocks defined by the content, hashes each block and only writes to the repository the blocks that don&rsquo;t exist there yet. That&rsquo;s why the delta is cheap: today&rsquo;s backup carries the blocks that changed since yesterday, not the files that changed. Editing a 100 GB database in the middle costs a few megabytes of backup, not 100 GB again. Each run becomes a repository snapshot, a restore point you browse with <code>restic mount</code> as if it were a filesystem, and <code>restic check</code> audits the whole repository&rsquo;s integrity whenever you ask.</p>
<p>Configuration matters more than the tool, so here&rsquo;s ours, mistakes included. On the desktop, the repository lives on the NAS and retention is <code>keep-daily 7, keep-weekly 4, keep-monthly 6, keep-yearly 1</code>. On the server, backup runs daily at dawn to the repository on the QNAP, covering <code>/etc</code>, <code>/usr/local/bin</code>, <code>/home</code> and the container volumes, with the password off the machine and <code>pg_dump</code> before the databases, because a filesystem snapshot doesn&rsquo;t guarantee consistency for a database that&rsquo;s writing. The first full load was 729 GiB in under an hour, and restic crossed unharmed, on retries, the window in which the NFS mount went read-only by mistake during the migration.</p>
<p>The most important part of the configuration is what does <strong>not</strong> go into the backup. Our exclusions, all with a reason:</p>
<ul>
<li>AI model caches (Immich, Hugging Face, EasyOCR): if lost, download again;</li>
<li>Prometheus metrics: history that regenerates itself;</li>
<li>the Fulcrum database: reindexes from bitcoind;</li>
<li>the decision classifiers&rsquo; weights: re-downloadable by revision hash (and this exclusion&rsquo;s glob goes inside quotes inside the bash array, because unquoted it becomes a backup source instead of an exclusion);</li>
<li><code>node_modules</code>, <code>target/</code>, cargo registry, rustup toolchains: pure build artifacts.</li>
</ul>
<p>And one retention lesson to close. When we shortened the server policy to &ldquo;yesterday, 2 dailies, 2 weeklies and 1 monthly&rdquo;, the correct translation to flags is <code>--keep-daily 3 --keep-weekly 3 --keep-monthly 2</code>, not 2/2/1: restic&rsquo;s levels overlap, and yesterday&rsquo;s snapshot is also the newest of this week and this month, so each number needs to be one more than the older points you want to keep. The detail caught us off guard: the script had a literal 2/2/1, and with it the monthly level would never hold anything older than last night. In practice, the intention was to keep 5 restore points, and 2/2/1 delivers only 3: yesterday, plus one daily, plus one weekly. We erred, measured, corrected.</p>
<p>A humility note from the disaster: the old repositories were left on the Synology by my decision (almost 2 TB of abandoned history, because the content was already re-covered on the new QNAP). A backup repository ages too, and revisiting its scope is part of the hygiene.</p>
<h2>Conclusion: in the AI era, disk is not a detail<span class="hx:absolute hx:-mt-20" id="conclusion-in-the-ai-era-disk-is-not-a-detail"></span>
    <a href="#conclusion-in-the-ai-era-disk-is-not-a-detail" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Agents coding all day change the storage math at both ends. On the volume end: builds, caches, worktrees and models fill the disk much faster than any human habit ever did, and without a cleanup routine you find out when it blows up. On the safety end: the more generated code, the more you need a cheap way back, because not every agent change is a good one.</p>
<p>btrfs covers both. Transparent compression gives back 15% of disk for free, and source code is exactly the kind of data that compresses the most. Snapshots and subvolumes give you instant rollback on the system and isolation for what doesn&rsquo;t need protection. Checksums and scrub watch the health of blocks that ext4 simply ignores. ext4 had its 15 years of well-deserved reign. In 2026, choosing it on a new Linux is giving up protection you&rsquo;ll need, sooner or later. Choose btrfs and set up the cleanup today.</p>
]]></content:encoded><category>linux</category><category>storage-and-backup</category><category>homelab</category><category>omarchy</category></item><item><title>Disaster on My NAS! How I Almost Lost 90 TB of Data</title><link>https://akitaonrails.github.io/en/2026/10/06/disaster-on-my-nas-how-i-almost-lost-90-tb-of-data/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/10/06/disaster-on-my-nas-how-i-almost-lost-90-tb-of-data/</guid><pubDate>Tue, 06 Oct 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;Yesterday, Monday, I was woken up at 8 a.m. by a loud, insistent beep. I knew it was the NAS before I opened my eyes. I ran to check, and nearly lost it: two drives marked as critical, in an array that can never have more than one.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-synology-2-discos-criticos.png" alt="Synology panel showing Storage Pool 1 in critical state, with drives 1 and 8 marked Critical" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;At that moment I knew I was a hiccup away from losing everything. This post is the post-mortem of what happened, and the first part was written in the heat of the disaster, with the rescue copy still running.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Yesterday, Monday, I was woken up at 8 a.m. by a loud, insistent beep. I knew it was the NAS before I opened my eyes. I ran to check, and nearly lost it: two drives marked as critical, in an array that can never have more than one.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-synology-2-discos-criticos.png" alt="Synology panel showing Storage Pool 1 in critical state, with drives 1 and 8 marked Critical"  loading="lazy" /></p>
<p>At that moment I knew I was a hiccup away from losing everything. This post is the post-mortem of what happened, and the first part was written in the heat of the disaster, with the rescue copy still running.</p>
<blockquote>
  <p><strong>Update of October 11:</strong> the copy finished and the ending was a happy one. No file was lost by accident, both NASes are healthy, each with its own RAID-6. The full recovery is in the <a href="#six-days-later-the-end-of-the-story">second half of this post</a>. The first part stays as it was published, untouched.</p>

</blockquote>
<h2>I&rsquo;ve been using a NAS for a long time<span class="hx:absolute hx:-mt-20" id="ive-been-using-a-nas-for-a-long-time"></span>
    <a href="#ive-been-using-a-nas-for-a-long-time" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m not new to this. I made an <a href="https://www.youtube.com/playlist?list=PLdsnXVqbHDUcM0LTAxqrVrTy6Q7jQprjt"target="_blank" rel="noopener">entire video mini-series on storage and filesystems</a> (in Portuguese), and I recommend watching it before asking me &ldquo;what&rsquo;s a good beginner NAS?&rdquo;. There is no such thing.</p>
<p>I&rsquo;ve used external storage since the second half of the 2000s, when I was a Mac user and Drobo existed. The company is gone, but I had three of their DAS units before migrating to Synology, first on an entry-level model and then on the mid-tier DS1821+, which I&rsquo;ve been using for more than five years. I&rsquo;ve written about it here, <a href="/en/2025/04/17/configuring-my-synology-nas-with-nfs-on-linux/">setting up NFS on Linux</a> and <a href="/en/2025/04/24/accessing-your-nas-using-iscsi-instead-of-smb/">accessing it over iSCSI</a>.</p>
<p>It&rsquo;s been very valuable to me, and you don&rsquo;t get to question why I need that much space. It doesn&rsquo;t matter: everyone decides what they keep on theirs.</p>
<p>When I was producing YouTube videos, I edited 4K footage straight from the NAS, thanks to the NVMe cache and 10 GbE networking. I got used to big storage at 10 gigabits and I can&rsquo;t go back.</p>
<p>The array was organized in SHR, Synology&rsquo;s hybrid RAID, which in practice behaves like RAID-5 (more on that later): it survives the loss of one drive without losing any data. I always thought that was enough.</p>
<h2>Hard drives die. Always<span class="hx:absolute hx:-mt-20" id="hard-drives-die-always"></span>
    <a href="#hard-drives-die-always" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Make no mistake: hard drives go bad. Never, ever trust a lone drive forgotten in a closet. It will fail, and you will lose everything on it.</p>
<p>The best public data on this comes from Backblaze, which runs hundreds of thousands of drives and publishes failure rates every quarter. In the <a href="https://www.backblaze.com/blog/backblaze-drive-stats-for-q2-2026/"target="_blank" rel="noopener">Q2 2026 report</a>, with 354,415 drives monitored, the annualized rate was 1.73%, and the lifetime rate for the whole fleet stands at 1.41% per year.</p>
<p>That sounds small. Translated to human scale:</p>
<ul>
<li><strong>One drive on its own, for 5 years:</strong> the chance of it dying in that period is close to 7%. That&rsquo;s roughly the chance of rolling one specific number on a 14-sided die. Would you bet every family photo on that?</li>
<li><strong>One drive on its own, for 10 years:</strong> about 13%, one in eight.</li>
<li><strong>Eight drives, like my NAS, for one year:</strong> the chance of at least one failing is about 11%. In five years, more than 40%.</li>
</ul>
<p>And those numbers are from a datacenter, with controlled temperature, clean power and drives spinning all the time. A drawer drive that sits idle for years and gets knocked around in a move doesn&rsquo;t have statistics that pretty.</p>
<p>Memory cards are worse, and there isn&rsquo;t even an equivalent statistic to consult. What exists is torture testing: the <a href="https://www.bahjeez.com/the-great-microsd-card-survey-one-year-later/"target="_blank" rel="noopener">Great MicroSD Card Survey</a> tested 216 cards over a year of continuous writing, and 52 of them, one in four, had already died.</p>
<p>Genuine cards started throwing errors, on average, after about 2,500 rewrite cycles; counterfeit cards, after 700. A memory card is transport media. Keeping the only copy of anything on a microSD is asking to lose it.</p>
<p>The practical conclusion is the same as in the mini-series: important data needs redundancy. A parity array exists precisely so that the death of one drive, which is a matter of time, doesn&rsquo;t take anything with it.</p>
<h2>What happened<span class="hx:absolute hx:-mt-20" id="what-happened"></span>
    <a href="#what-happened" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>With all that, I should have been at ease with my setup. I wasn&rsquo;t.</p>
<p>It started the day before yesterday, Sunday. For a while I&rsquo;d been watching the volume approach the ceiling.</p>
<p>At first it was 8 drives of 10 TB. Over the years I swapped them, one by one, for 20 TB drives (DSM shows binary units; on the label they&rsquo;re 12 and 22 TB). It&rsquo;s a slow process: each swap takes days, because the array has to rebuild and redistribute the data.</p>
<p>I decided to swap one more, the seventh. Synology supports hot-swap, so it&rsquo;s simple: pull the old drive, slot in the new one, add it to the array and let DSM handle the rebuild.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-synology-pool-reparando.png" alt="Synology Storage Pool 1 repairing, 0.01% complete, with 108 TB allocated out of 121.8 TB"  loading="lazy" /></p>
<p>On Sunday night it looked like this: rebuild starting, all eight drives healthy, 108 TB allocated out of 121.8 TB.</p>
<p>On Monday, at 8 a.m., the beep. Drive 1, the new one, with 13 hours of use, marked critical:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-synology-drive1-critico.png" alt="Synology drive 1 in critical state, with an I/O error and 13 hours powered on"  loading="lazy" /></p>
<p>And drive 8, one of the old ones, with 22,095 hours powered on (two and a half years), also critical, with a read error at 08:05:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-synology-drive8-critico.png" alt="Synology drive 8 in critical state, with a read error and the message that the number of failed drives exceeds the RAID’s fault tolerance"  loading="lazy" /></p>
<p>The pool&rsquo;s message left no room: &ldquo;Unrecoverable errors have occurred because drive errors occurred after storage pool degradation. Please back up your data immediately.&rdquo;</p>
<h3>Why two drives is the end<span class="hx:absolute hx:-mt-20" id="why-two-drives-is-the-end"></span>
    <a href="#why-two-drives-is-the-end" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In RAID-5, if two drives go down, redundancy is zero, and in theory the whole array goes with them. It&rsquo;s an unrecoverable situation.</p>
<p>Anyone who watched my playlist knows why. An array like this doesn&rsquo;t store &ldquo;files,&rdquo; one per drive. A file is a collection of blocks, and the blocks are spread across all the drives, along with the parity. Losing a drive is losing a piece of every file. It doesn&rsquo;t matter that the other seven are healthy, because they only have parts.</p>
<p>Parity is there to recompute the missing piece when one drive disappears. With two missing, the math doesn&rsquo;t close.</p>
<p>And there&rsquo;s a cruel detail in the rebuild: to recreate the new drive, the array has to read every other drive end to end. That&rsquo;s more than 100 TB of continuous reading. If some old drive had a bad sector hiding in a corner nobody had read in a long time, that&rsquo;s when it shows up. That&rsquo;s what happened with drive 8.</p>
<h2>Where do you copy 90 TB to?<span class="hx:absolute hx:-mt-20" id="where-do-you-copy-90-tb-to"></span>
    <a href="#where-do-you-copy-90-tb-to" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The only way out, at that point, was to thank the heavens that the volumes were still accessible, with the files showing up, and use the window to copy everything out immediately. I couldn&rsquo;t touch anything else.</p>
<p>The problem: copy about 90 TB to where? Nobody has an empty 100 TB NAS lying around at home.</p>
<p>Normally I&rsquo;d buy this kind of hardware in the United States, on a tourist trip, and bring it back with me. It&rsquo;s much cheaper. Buying expensive electronics in Brazil isn&rsquo;t worth it: the country has charged import taxes worse than Trump&rsquo;s tariffs for decades, and the final price usually goes past double the American list price.</p>
<p>Except I had to choose. What is it worth to lose 90 TB of data I spent years collecting and that is nearly impossible to recover from anywhere else? It&rsquo;s one of those situations where the answer is &ldquo;it&rsquo;s priceless,&rdquo; and so, whatever the price, I pay.</p>
<p>I found a storage reseller close to home, <a href="https://www.controle.net"target="_blank" rel="noopener">Controle Net</a>. Good people, very quick to respond, and they sent me a proposal for equipment with same-day delivery.</p>
<p>Their first suggestion was a QNAP TS-832PX. I researched it before accepting. It has the same 8 bays and ships with 10 GbE, but the processor is a 1.7 GHz ARM Cortex-A57 (Annapurna Labs AL324) with 4 GB of RAM, half the 8 GB minimum that QuTS hero asks for. For RAID-6 on ZFS, with checksums and double parity on every block, that&rsquo;s weak hardware, as I explain further down. And it would be a downgrade from the DS1821+ I already had, which runs on a Ryzen V1500B.</p>
<p>I asked for another option, and after some back and forth I settled on a QNAP TS-873A, 8 bays, with the same Ryzen V1500B as my Synology, 8 GB of RAM, and 10 Toshiba MG11 24 TB enterprise-class drives.</p>
<p>This is why you have to do your own research and know, objectively, what you want to do with the equipment. The reseller didn&rsquo;t act in bad faith: they suggested an 8-bay NAS to someone who asked for an 8-bay NAS. The one who knew it would run ZFS in RAID-6 was me. If I had accepted the first proposal in a panic, I&rsquo;d have paid a lot for a worse machine than the one I was replacing.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-proposta-qnap.png" alt="Reseller proposal: QNAP TS-873A, 10 Toshiba MG11ACA24TE 24 TB drives, installation, training and 6 years of support, total R$ 185,320.00"  loading="lazy" /></p>
<p>R$ 185,320.00. Eye-watering.</p>
<h3>What it would cost in the United States<span class="hx:absolute hx:-mt-20" id="what-it-would-cost-in-the-united-states"></span>
    <a href="#what-it-would-cost-in-the-united-states" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I checked today&rsquo;s American list prices. About the exchange rate: the dollar is at R$ 4.97 right now, but that&rsquo;s the effect of Sunday&rsquo;s first-round election results, which knocked the rate down more than 4% on Monday. Through Friday it closed at R$ 5.22, and that&rsquo;s the fair rate to compare with, because that&rsquo;s the rate the reseller&rsquo;s stock was built at:</p>
<table>
  <thead>
      <tr>
          <th>Item</th>
          <th style="text-align: right">US price</th>
          <th style="text-align: right">Total</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>QNAP TS-873A-8G (<a href="https://store.qnap.com/ts-873a-8g-us.html"target="_blank" rel="noopener">QNAP store</a>, Newegg)</td>
          <td style="text-align: right">US$ 1,199</td>
          <td style="text-align: right">US$ 1,199</td>
      </tr>
      <tr>
          <td>10 × Toshiba MG11ACA24TE 24 TB (<a href="https://www.bhphotovideo.com/c/product/1889900-REG/toshiba_mg11aca24te_24tb_mg10_series_7200.html"target="_blank" rel="noopener">B&amp;H</a>)</td>
          <td style="text-align: right">US$ 999.99 each</td>
          <td style="text-align: right">US$ 9,999.90</td>
      </tr>
      <tr>
          <td>10 GbE card QXG-10G2T-X710 (<a href="https://www.bhphotovideo.com/c/product/1643883-REG/qnap_qxg_10g2t_x710_dual_port_10gbase_t_10gbe_network.html"target="_blank" rel="noopener">B&amp;H</a>)</td>
          <td style="text-align: right">US$ 351.99</td>
          <td style="text-align: right">US$ 351.99</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td style="text-align: right"></td>
          <td style="text-align: right"><strong>~US$ 11,550</strong></td>
      </tr>
  </tbody>
</table>
<p>At R$ 5.22, about R$ 60 thousand. I paid R$ 185 thousand, more than three times that.</p>
<p>The comparison isn&rsquo;t perfect: the Brazilian proposal includes installation, training and six years of technical support, and the equipment was on my desk by the end of the same day. But it gives the measure of the Brazil effect.</p>
<p>If the rule of thumb is &ldquo;double the American price,&rdquo; normal would be about R$ 120 thousand. The extra R$ 65 thousand is, in my own rough estimate, the sum of services, local stock of enterprise drives and my urgency. Whoever buys on a Monday morning with the array dying is in no position to haggle.</p>
<h3>And drives are expensive everywhere<span class="hx:absolute hx:-mt-20" id="and-drives-are-expensive-everywhere"></span>
    <a href="#and-drives-are-expensive-everywhere" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>To make it worse, this is the worst possible moment to buy hard drives. The AI bubble is sucking up production.</p>
<table>
  <thead>
      <tr>
          <th>Indicator</th>
          <th>Before</th>
          <th>Now</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>WD Blue 4 TB (Camelcamelcamel history)</td>
          <td>US$ 67 to 85</td>
          <td>US$ 99, <a href="https://winbuzzer.com/2026/02/18/wd-seagate-2026-hard-drive-shortage-ai-data-centers-xcxwbn/"target="_blank" rel="noopener">nearly 50% more in five months</a> (February 2026)</td>
      </tr>
      <tr>
          <td>Production capacity</td>
          <td>available at retail</td>
          <td>WD: 2026 practically sold out, with contracts through 2027 and 2028; Seagate: nearline capacity fully allocated</td>
      </tr>
      <tr>
          <td>WD gross margin</td>
          <td>41.0% a year earlier</td>
          <td><a href="https://datacenterdisk.com/news/hard-drive-prices-up-50-percent-2026"target="_blank" rel="noopener">54.1% in the quarter ended July 2026</a></td>
      </tr>
  </tbody>
</table>
<p>I thought Seagate had left the consumer market. I checked, and that&rsquo;s not quite it: it hasn&rsquo;t left, but in February 87% of its hard drive sales were already nearline drives, for datacenters, and WD gets 89% of its revenue from cloud customers and only 5% from retail. WD&rsquo;s CEO said the company is &ldquo;pretty much sold out for calendar year 2026.&rdquo;</p>
<p>Seagate has said it won&rsquo;t expand production capacity; growth comes only from higher-density drives. For someone buying drives at home, the practical effect is similar to them having left: you get what the hyperscalers didn&rsquo;t want, at whatever price the seller asks.</p>
<h2>QNAP instead of another Synology<span class="hx:absolute hx:-mt-20" id="qnap-instead-of-another-synology"></span>
    <a href="#qnap-instead-of-another-synology" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After spending the morning and afternoon between negotiation, paperwork and payment, by the end of the day I had the QNAP powered on next to the Synology.</p>
<p>Choosing QNAP went beyond availability. Synology has been making questionable business decisions.</p>
<p>In 2025 it launched the Plus line <a href="https://www.tomshardware.com/pc-components/nas/synology-walks-back-controversial-compatibility-policy-for-2025-nas-units-third-party-hdd-and-ssd-support-returns-with-diskstation-manager-7-3-update"target="_blank" rel="noopener">requiring its own-brand drives or drives it certified</a>.</p>
<p>The own-brand ones are Seagate, Toshiba and WD drives rebadged with custom firmware, and anyone using something else was left without pool creation, health monitoring, deduplication or firmware updates. After the outcry, it <a href="https://www.howtogeek.com/synology-is-walking-back-its-drive-requirements/"target="_blank" rel="noopener">walked that back in DSM 7.3</a> and allowed HDDs and SATA SSDs from other brands.</p>
<p>Except M.2 pools still require the compatibility list. The uncertainty is left hanging: what stops them from doing it again?</p>
<p>Setting up the QNAP is very easy. QuTS hero, their ZFS-based system, is as simple to use as Synology&rsquo;s DSM.</p>
<h2>Fixing my mistake: RAID-6 and ZFS<span class="hx:absolute hx:-mt-20" id="fixing-my-mistake-raid-6-and-zfs"></span>
    <a href="#fixing-my-mistake-raid-6-and-zfs" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This time I knew I had to configure RAID-6, which survives two drives failing at the same time. The downside is less total capacity, because the equivalent of two drives becomes parity instead of one. The upside is that I shouldn&rsquo;t have to go through an emergency like this again.</p>
<p>One of QNAP&rsquo;s strengths is supporting ZFS out of the box, well integrated and easy to use.</p>
<p>For those who don&rsquo;t know it: ZFS was born at Sun Microsystems, for Solaris, in the mid-2000s, and today lives on as OpenZFS on Linux and FreeBSD. It merges into one system what used to be three layers, the RAID, the volume manager and the filesystem. It keeps a checksum of every block, so it notices when a drive silently returns corrupted data, and repairs it on its own from parity. And it never overwrites data in place: every write goes to a new block, which makes snapshots practically free.</p>
<p>Its reputation as a RAM hog comes from two things. The first is the ARC, its cache, which by design takes up almost all the machine&rsquo;s free memory. That scares anyone looking at the graph, but it&rsquo;s cache: it gives the memory back when another process asks. The second is deduplication, which keeps a huge table with the hash of every block and needs it in RAM or it becomes unusably slow. The old rule of &ldquo;1 GB of RAM per TB of disk&rdquo; comes from there, and only applies to people who turn deduplication on.</p>
<p>Even without it, ZFS is heavier than ext4 or a simple Btrfs. Computing checksums, compression and double parity for every block costs CPU, and with little memory the cache shrinks and everything gets slow. On weak hardware, like an entry-level NAS with an ARM processor and 2 GB of RAM, it suffers.</p>
<p>QNAP asks for at least 8 GB for QuTS hero. Mine came with 8 GB and a quad-core Ryzen, which works for my use, which is storing large files and serving them over the network. Deduplication stays off.</p>
<p>I bought 10 drives for an 8-bay NAS, so two are left in the drawer as spares, for the day one fails.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-qnap-criando-pool-raid6.png" alt="QNAP storage pool creation wizard: 8 disks in RAID 6, 126 TB, 5% over-provisioning, 5% guaranteed snapshot space and an 85% alert threshold"  loading="lazy" /></p>
<p>For my simple setup, three settings:</p>
<ul>
<li><strong>5% over-provisioning.</strong> It&rsquo;s a slice of the pool the system reserves and never hands out for data. ZFS always writes to new space and gets slow when the pool fills up near the limit; this slack keeps performance stable. On SSD pools it also extends drive life, but these are hard drives, so the minimum is enough.</li>
<li><strong>5% guaranteed snapshot space.</strong> It&rsquo;s the reserve for snapshots, the &ldquo;photos&rdquo; of the state of your files that let you go back after an accident or ransomware. Guaranteeing the space means the snapshots keep existing even if I fill up the rest. My data changes little (it&rsquo;s mostly cold archive), so snapshots take almost nothing and 5% is plenty.</li>
<li><strong>Alert at 85%.</strong> The system warns me when the pool passes 85% usage, with room for me to act before hitting the ceiling. It was exactly the lack of room that had me swapping drives in a hurry on the Synology.</li>
</ul>
<p>Adding the two reserves, about 13 TB are set aside and 112.9 TB are left for use.</p>
<p>A detail of my case: I&rsquo;m copying the 90 TB with snapshots turned off, and I&rsquo;ll only enable the schedule after the copy finishes. A snapshot protects a file&rsquo;s previous state, and on an empty NAS receiving its first load there is no previous state worth protecting. The source of truth is still the Synology.</p>
<p>And there&rsquo;s a practical cost. I stopped and restarted the job several times, deleted folders that had gone in by mistake and copied again. With snapshots on, every deleted thing would keep taking up space inside some snapshot, and I&rsquo;d be filling the pool with the migration&rsquo;s own garbage. When everything is on this side and checked, then yes: scheduled snapshots, to protect me from deleting what I shouldn&rsquo;t and from ransomware.</p>
<p>After that I created one giant &ldquo;thick&rdquo; partition. In ZFS you can create a &ldquo;thin&rdquo; folder, which only takes up what&rsquo;s inside it and can promise more space than exists, or a &ldquo;thick&rdquo; one, which reserves the full size at creation. Since it&rsquo;s a pool with one owner and one use, thick is more predictable: the space is mine and nothing competes for it.</p>
<p>Finally, I created the shares and installed the HBS 3 app.</p>
<h2>Copying 90 TB<span class="hx:absolute hx:-mt-20" id="copying-90-tb"></span>
    <a href="#copying-90-tb" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>HBS 3 (Hybrid Backup Sync) is QNAP&rsquo;s app for backup and synchronization. It connects to another NAS, to rsync, FTP, SMB or the cloud.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-hbs3-criar-job.png" alt="HBS 3 sync job creation screen, with the destination options: local NAS, remote NAS, Rsync, FTP and CIFS/SMB"  loading="lazy" /></p>
<p>I connected to the Synology&rsquo;s SMB share and told it to sync everything.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-hbs3-job-sync.png" alt="HBS 3 syncing from the Synology TERACHAD to the QNAP TS-873A"  loading="lazy" /></p>
<p>I had to stop and restart a few times, tweaking the job settings, because it was absurdly slow. And the file count wouldn&rsquo;t stop growing:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-hbs3-snapshot-explosao.png" alt="HBS 3 job status showing 45 million files and 865 TB calculated, at an average of 350 MB/s"  loading="lazy" /></p>
<p>865 TB on a 108 TB NAS, and that&rsquo;s when I understood what was going on.</p>
<p>I excluded two kinds of path from the job:</p>
<ul>
<li><strong>The <code>#snapshot</code> directories.</strong> On Synology, when you check &ldquo;make snapshot visible,&rdquo; each shared folder gets a <code>#snapshot</code> subfolder with a full view of the folder at each point in time. Inside the Synology that takes no space, because a snapshot only stores the difference. Seen from outside, over SMB, every snapshot looks like a full copy of everything, and HBS was trying to copy them all.</li>
<li><strong>The restic backup directories.</strong> restic stores everything in hundreds of thousands of pack files of a few megabytes each. Network copying is fast with large files and suffers with small ones, because each one costs an open, metadata and a close. And those backups I&rsquo;ll redo from scratch on the new NAS.</li>
</ul>
<p>With that, throughput rose to an average of 400 MB/s.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-qnap-400mbs.png" alt="QNAP resource monitor showing 414.8 MB/s received on the 10 GbE interface"  loading="lazy" /></p>
<p>That&rsquo;s less than half of what a 10 GbE network delivers, but it&rsquo;s more than three times the gigabit most people have at home. Both sides really are at 10 gigabits:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-qnap-10gbe.png" alt="QNAP 10 GbE adapter connected at 10 Gbps"  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-synology-10gbe.png" alt="Synology LAN 5 interface at 10000 Mbps, full duplex"  loading="lazy" /></p>
<p>Why doesn&rsquo;t it reach 1,000 MB/s? My main hypothesis is drive 8. The source array is degraded, and every read that goes through the affected pieces has to be recomputed from parity, with a drive that on top of that returns errors and retries. It makes sense that the Synology can&rsquo;t go faster than this.</p>
<p>There&rsquo;s a second factor, which my own screenshots give away: the Synology has jumbo frames on (MTU 9000), and the QNAP image shows the default MTU of 1500. That screenshot is from earlier; I&rsquo;ve already changed the QNAP to 9000 as well.</p>
<p>Why it matters: MTU is the maximum size of each packet that travels on the network. At the default 1500 bytes, filling a 10-gigabit link means processing more than 800 thousand packets per second, and each packet costs CPU on both sides, for the header, the checksum and the interrupt. With jumbo frames each packet carries six times more data, so it&rsquo;s one sixth of the packets for the same volume, and more processor is left for moving files. It only works if everything in the path matches: both cards and the switch. If one side stays at 1500, the connection negotiates down to the smaller value and the other side&rsquo;s jumbo frames do nothing.</p>
<p>At 400 MB/s, 90 TB takes about two and a half days, and I prefer two and a half guaranteed days.</p>
<h2>What Synology explained<span class="hx:absolute hx:-mt-20" id="what-synology-explained"></span>
    <a href="#what-synology-explained" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In parallel, I opened a ticket with Synology support. After some back and forth, I sent the logs. They responded fast, the same day, and I&rsquo;ll give them that. The engineer&rsquo;s explanation, summarized:</p>
<ul>
<li>Drive 8 had been logging media errors <strong>for over a month</strong>. A media error is when the NAS tells the drive to read a sector and the drive answers that it couldn&rsquo;t, even after several retries. It&rsquo;s always an internal drive failure, and the drive should have been replaced.</li>
<li>It was drive 8&rsquo;s poor health that took it down during the rebuild of drive 1.</li>
<li>In practice I lost two drives, and the pool should have crashed completely. It didn&rsquo;t because of how SHR works.</li>
</ul>
<p>SHR isn&rsquo;t a single RAID-5. To use drives of different sizes without wasting space, it slices the drives and builds several smaller RAIDs, one per size band, and joins everything into one volume. The engineer sent this example diagram:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-synology-shr-particoes.png" alt="Synology support diagram showing how SHR splits drives of different sizes into partitions 1, 2 and 3"  loading="lazy" /></p>
<p>In my case, the pool is made of two partitions. In the first, the rebuild of drive 1 was far enough along and the failure of drive 8 was mild enough that both kept working. In the second, both are missing. That&rsquo;s why the pool didn&rsquo;t crash entirely and I can still see the files.</p>
<p>His recommendation was the one I was already following: copy everything out before anything else, because there&rsquo;s no telling whether the situation gets worse or whether it&rsquo;s fixable. After the backup he can even try forcing drive 8 back into the pool so the rebuild continues, but there&rsquo;s no guarantee the drive will go back in, or that it will survive the process. In his words, copying and recreating the pool from scratch is probably the quickest way forward.</p>
<p>Classic RAID-5, by the way, requires all drives to be the same size, and to grow capacity I&rsquo;d have to replace all of them at once. SHR is what let me swap one at a time over the years. Ironically, it&rsquo;s also what saved me from losing everything at once.</p>
<h2>The two mistakes<span class="hx:absolute hx:-mt-20" id="the-two-mistakes"></span>
    <a href="#the-two-mistakes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The way I understood it, there are two mistakes, one mine and one theirs, and that&rsquo;s where the whole disaster lives.</p>
<p><strong>Mine:</strong> I underestimated RAID-6, which on Synology is called SHR-2. With two parity drives, drive 8 failing in the middle of the rebuild would have been just a scare. I traded safety for 20 TB of space and spent five years thinking I&rsquo;d made a good deal.</p>
<p><strong>Theirs:</strong> from the logs, the software knew drive 8 had been failing for at least a month, and I never got a single notification. I have email notifications configured. And they work: when the critical situation blew up, I got a flood of emails. This one, about drive 8&rsquo;s bad sectors, arrived at one in the afternoon on Monday, five hours after the disaster:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20261006120000_nas-synology-email-bad-sectors.png" alt="Synology email warning that the number of bad sectors increased on drive 8, a Seagate ST22000NT001"  loading="lazy" /></p>
<p>Before that, nothing. No warning about drive 8 until it failed catastrophically.</p>
<p>If I had received one, I would never have tried to swap drive 1 before replacing drive 8. The whole situation was avoidable, and it was the result of a human mistake plus some software bug.</p>
<p>That&rsquo;s the other reason I didn&rsquo;t buy another Synology. I have a principle: if the horse throws me, I put the horse down. I can&rsquo;t trust it again.</p>
<h2>Where I am now<span class="hx:absolute hx:-mt-20" id="where-i-am-now"></span>
    <a href="#where-i-am-now" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>So far I&rsquo;ve copied about 20 TB of the original 90 TB, and HBS 3 keeps working. I still don&rsquo;t know whether I lost any data permanently. I hope not, but the most likely outcome is that I lost what was in the RAID slice the engineer described as crashed.</p>
<p>I have offsite backup on AWS Glacier of what matters most, but not of everything.</p>
<p>A lot of people ask why I don&rsquo;t use S3 or some other cloud storage for everything. I return the question: think for ten seconds. How long does it take to transfer 90 TB over your internet connection? Do the math:</p>
<ul>
<li>With a solid 1 gigabit of upload, never dropping for a second: more than 8 days.</li>
<li>With 500 megabits: almost 17 days.</li>
<li>With 100 megabits, which is good upload for many homes: 83 days.</li>
</ul>
<p>And that&rsquo;s to upload. When disaster hits you need to download it all back, with the clock running. Not counting the rent: 90 TB on standard S3 costs about US$ 2 thousand a month at list price. Cloud is great for the critical subset, which is how I use it. For the whole archive, physics and the invoice won&rsquo;t allow it.</p>
<h2>Lessons<span class="hx:absolute hx:-mt-20" id="lessons"></span>
    <a href="#lessons" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I was put in an impossible situation on a Monday morning, I acted right away as best I could, and I&rsquo;ll probably come out of this mostly unscathed. We&rsquo;ll see, HBS 3 is still syncing. What I take from it:</p>
<ol>
<li><strong>Single parity isn&rsquo;t enough for big drives.</strong> With 20 TB drives, a rebuild takes days and reads everything. That&rsquo;s the window in which the second drive tends to show up. RAID-6, SHR-2 or RAIDZ2.</li>
<li><strong>Don&rsquo;t rely only on the vendor&rsquo;s notifications.</strong> Look at SMART and the drive logs before starting any swap. I didn&rsquo;t, because I trusted I&rsquo;d be warned.</li>
<li><strong>Replace the sick drive first, then the small one.</strong> A capacity upgrade on an array with a suspect drive is roulette.</li>
<li><strong>Leave slack.</strong> I was swapping drives because the volume was at the ceiling. An 85% alert exists for that.</li>
<li><strong>Have somewhere to copy to.</strong> My way out cost R$ 185 thousand because I had no plan B. A second NAS, even a modest one, with what matters, would have bought me time to shop calmly.</li>
<li><strong>RAID is not backup.</strong> I know that, I teach that, and I still only had offsite for part of it.</li>
</ol>
<p>The copy is done and I already know the size of the damage: zero. The full recovery comes next.</p>
<h2>Six days later: the end of the story<span class="hx:absolute hx:-mt-20" id="six-days-later-the-end-of-the-story"></span>
    <a href="#six-days-later-the-end-of-the-story" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When I published the first part, I had about 20 TB copied and no certainty. The outcome: I finished evacuating the ~91 TB, verified file by file, and lost nothing by accident. Not even what was on the fallen RAID slice, because it stayed readable until the end.</p>
<p>Who operated the recovery were my two agents. The desktop agent controls my PC and both NASes over SSH, and the homelab agent takes care of the server where my 57 containers run. The two coordinate by message: one leaves a report in the other&rsquo;s inbox, and I follow the conversation.</p>
<p><img src="/2026/10/06/desastre-no-meu-nas-como-quase-perdi-90-tb-de-dados/nas-ai-memory-agentes.png" alt="The desktop agent’s terminal checking the ai-memory message inbox, where the homelab agent leaves its reports"  loading="lazy" />)</p>
<p>This part matters because nothing that follows was me typing commands. I made the decisions; they executed, measured and screwed up badly a few times, as you&rsquo;ll see.</p>
<h3>HBS 3 was quietly betraying me<span class="hx:absolute hx:-mt-20" id="hbs-3-was-quietly-betraying-me"></span>
    <a href="#hbs-3-was-quietly-betraying-me" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In the first part I told how HBS 3, pulling the Synology shares over CIFS, reached 400 MB/s after the adjustments. What I didn&rsquo;t know: speed was the smallest of its problems.</p>
<p>Two defects showed up when the agent checked the source inventory against what HBS reported:</p>
<ul>
<li><strong>It didn&rsquo;t go where it had no permission, and told nobody.</strong> HBS mounts the Synology as my user. The <code>Bitcoin/data</code> directory, 901 GB with my node&rsquo;s wallet and chainstate, belongs to uid 101 and is closed to anyone else. CIFS couldn&rsquo;t get in there, and HBS reported success without recording the omission. A &ldquo;green&rdquo; backup that leaves the entire Bitcoin node behind.</li>
<li><strong>It skipped files with &ldquo;unsupported&rdquo; names.</strong> 113 files, recorded only on a detail screen nobody opens during a days-long copy.</li>
</ul>
<blockquote>
  <p>A backup tool that reports success while omitting what it couldn&rsquo;t read is worse than one that fails screaming. Verify the backup against an independent inventory of the source, never against the tool&rsquo;s own log.</p>

</blockquote>
<p>We abandoned HBS. The agent opened SSH on both NASes, enabled the rsync server on the QNAP, and the Synology started pushing straight to its rsyncd, with nothing going through the PC. Each job&rsquo;s wrapper: <code>rsync -a --numeric-ids -W --partial --timeout=600</code>, attempts with retry, and <strong>never <code>--delete</code></strong>. The <code>--numeric-ids</code> preserved the original uids with no chown afterwards, <code>--partial</code> turned any interruption into a continuation, and the absence of <code>--delete</code> made the destination always a superset: re-sharding or killing a job never cost data.</p>
<h3>The bottleneck wasn&rsquo;t the broken drive<span class="hx:absolute hx:-mt-20" id="the-bottleneck-wasnt-the-broken-drive"></span>
    <a href="#the-bottleneck-wasnt-the-broken-drive" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Some context was missing from the first part. This Synology was born with eight consumer-line Seagate IronWolfs, and over the years I had been swapping them one by one for 20 TB IronWolf Pros. The disaster happened exactly during one of those upgrades: I swapped drive 1 for the new Pro and, during the rebuild, drive 8 failed. One single old drive was left in the array: drive 2.</p>
<p>And it, not drive 8, was the evacuation bottleneck. Measured under load:</p>
<ul>
<li>Drive 2 (the old consumer one, with 37 thousand hours of use): 91% utilization, delivering 27 MB/s, each I/O taking 4 to 15 times longer than its siblings.</li>
<li>The six Pros: between 34% and 45% utilization, sitting idle waiting for it.</li>
</ul>
<p>In RAID-5 (and in SHR), a sequential read spans the whole stripe, and the stripe only completes when the slowest member answers. One slow drive caps the whole array: because of it, the array couldn&rsquo;t go past about 200 MB/s while six good drives had room to spare. And its SMART was clean, zero reallocated sectors. Consumer drives usually ship without time-limited error recovery (ERC/TLER) active, so the drive retries a marginal sector for seconds, internally, without accounting for anything. The Synology&rsquo;s NVMe cache diverted part of the reads from the platters and helped mask the symptom.</p>
<blockquote>
  <p>The drive that fails and the drive that slows you down don&rsquo;t have to be the same, and SMART &ldquo;PASSED&rdquo; says nothing about latency. A slow array is diagnosed with per-drive utilization and response time, not with SMART.</p>

</blockquote>
<h3>Parallelizing rsync is science, not guesswork<span class="hx:absolute hx:-mt-20" id="parallelizing-rsync-is-science-not-guesswork"></span>
    <a href="#parallelizing-rsync-is-science-not-guesswork" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>First instinct: more streams. Wrong, twice.</p>
<p>The first time, a bug of ours: a &ldquo;clean&rdquo; job relaunch didn&rsquo;t kill anything, because the Synology&rsquo;s busybox <code>ps</code> truncates the command line and the verification grep didn&rsquo;t find the old processes. Result: 32 rsyncs running into 7 destinations, four to five writers per tree, the QNAP with its receive buffers clogged, and the cable transmitting 516 MB/s while the array read 140. After the cleanup (enumerating <code>/proc/*/cmdline</code>, never trusting <code>ps</code> again), useful throughput <strong>rose</strong> from 140 to 181 MB/s while total load collapsed.</p>
<p>The second time, measurement inverted common sense: with large-file trees, fewer streams were faster, because the average size of each read request tripled and the array did long sequential runs instead of jumping around.</p>
<p>But small-file trees are another sport. <code>TERACHAD/Emulators</code> has 21 TB in 3.4 million files averaging 6 MB: latency-bound work (stat, creation, metadata), not bandwidth-bound. And there aggressive parallelization worked, with one detail: the right shard is chosen by <strong>file count</strong>, not by size. A single directory, <code>ROMS_FINAL/xbox-hdd-ready</code>, held 995 thousand of ROMS_FINAL&rsquo;s 1.59 million files, averaging 1.7 MB per file. Isolating it from the multi-gigabyte ISOs:</p>
<table>
  <thead>
      <tr>
          <th>Configuration</th>
          <th style="text-align: right">GB/hour</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>single stream, 6-hour average</td>
          <td style="text-align: right">~157</td>
      </tr>
      <tr>
          <td>two shards</td>
          <td style="text-align: right">272</td>
      </tr>
      <tr>
          <td><strong>three shards</strong></td>
          <td style="text-align: right"><strong>436 to 501</strong></td>
      </tr>
  </tbody>
</table>
<p>Between 2.8 and 3.2 times the single stream&rsquo;s average throughput, on the same machine, on the same network. And there&rsquo;s a beautiful reason: with the array missing a member, reads of data that doesn&rsquo;t live on drive 2 don&rsquo;t need it, so the extra concurrency let the other drives work while it choked.</p>
<blockquote>
  <p>&ldquo;More parallelism&rdquo; and &ldquo;less parallelism&rdquo; are both wrong answers as a general rule. Bandwidth-bound work splits little; latency-bound work splits a lot, and by file count. Measure before choosing.</p>

</blockquote>
<h3>Copying is half the work; proving is the other half<span class="hx:absolute hx:-mt-20" id="copying-is-half-the-work-proving-is-the-other-half"></span>
    <a href="#copying-is-half-the-work-proving-is-the-other-half" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Every job that finished went through a dry-run of the same rsync, with the same excludes: zero files needing transfer means an identical destination by size and date. And even so it almost went wrong, because <strong>the verification inherited the copy&rsquo;s excludes</strong>: a tree excluded from the transfer stayed excluded from its own proof. Re-running the check without excludes turned up 952 files and 191 GB missing in Downloads (exactly the batches I had set aside as &ldquo;only existing copy&rdquo;) and the old restic repositories, which a similar directory name on the QNAP pretended to cover: the repositories&rsquo; internal ID proved they were different ones, not copies.</p>
<p>There was source drift too: a tree verified as complete received 16 GB of new checkpoints on the Synology after the check, and only the final re-verification caught it. Verification has an expiration date.</p>
<p>And a conscious decision, recorded so nobody accuses me of hiding it: the old backup histories (about 2 TB of restic snapshots and a duplicity set from 2025) were left behind by my choice, because the new repositories on the QNAP already cover the same content from now on. Deliberate loss of history, not of data.</p>
<p>Closing the books: <strong>17 rsync jobs, all exit 0, all verified, about 5.9 million byte-identical files</strong>, and the Synology shutdown only happened after every tree was proven.</p>
<p><img src="/2026/10/06/desastre-no-meu-nas-como-quase-perdi-90-tb-de-dados/nas-homelab-containers-status.png" alt="Recovery status table mid-process: the containers already released and the ones still held waiting for each tree’s rsync verification"  loading="lazy" />)</p>
<h3>Meanwhile, in the homelab<span class="hx:absolute hx:-mt-20" id="meanwhile-in-the-homelab"></span>
    <a href="#meanwhile-in-the-homelab" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>On the other side of the house, the marvin server, which mounts the NAS over NFS and runs the 57 containers, almost died standing up. Dead NAS plus &ldquo;hard&rdquo; NFS mounts plus 57 containers restarting on their own made an infinite queue of uninterruptible I/O: the machine answered ping, but sshd couldn&rsquo;t complete a handshake. And every insistent SSH attempt only made it worse, because OpenSSH punishes a repetitive source by extending the ban. We knocked on the door and the door added a lock.</p>
<p>The way out was to stop knocking: I paused the torrents through the qBittorrent API (which kept responding), went to the physical console, disabled Docker, rebooted, and the server came back. A watchdog I didn&rsquo;t even remember existed had, on its own, taken down the 14 NAS-dependent containers before any database corrupted: bitcoind&rsquo;s LevelDB opened clean on the first try.</p>
<p>The migration itself was elegant: fstab pointed at the QNAP and the old mount points became symlinks into the new one. <strong>No docker-compose changed.</strong> Then came the QNAP NFS gotcha: permission is per client. The desktop was allowed without squash, but marvin&rsquo;s writes came in as uid 1001, blocking everything that writes to the NAS. The first fix attempt still flipped the mount to read-only in the middle of the first restic backup, which survived on retries. Final configuration: read and write, no squash for anyone, tested with <code>touch</code> and <code>ls -ln</code> from each client, because &ldquo;it worked on the desktop&rdquo; proves nothing about the server.</p>
<p><img src="/2026/10/06/desastre-no-meu-nas-como-quase-perdi-90-tb-de-dados/nas-qnap-configurando-nfs.png" alt="QNAP NFS permissions screen, with the per-host rule for the homelab server"  loading="lazy" />)</p>
<p>After that it was resurrection tree by tree: each service only came up when its tree was verified on the QNAP. In two days, 57 of 57 containers back, and a brand-new restic backup running daily to the QNAP.</p>
<h3>The new kernel took down the NVMe<span class="hx:absolute hx:-mt-20" id="the-new-kernel-took-down-the-nvme"></span>
    <a href="#the-new-kernel-took-down-the-nvme" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>In the middle of the recovery, a free scare from the server. Seven minutes after the nightly backup started, the NVMe controller vanished silently: every binary took SIGBUS, Docker and sshd died, and nothing remained recorded anywhere, because the log lives on the device that died. Not even a kernel panic: just silence.</p>
<p>The same workload had run fine hours earlier on kernel 7.2.6 and died on 7.2.9. Disabling APST (the NVMe&rsquo;s power management) didn&rsquo;t fix it: a second identical crash, a 7.2.9 regression confirmed by elimination. MicroOS is transactional and snapshot-based, so the rollback was a reboot into the 7.2.6 lineage, not a reinstall. Automatic updates were deliberately left broken on that lineage so nobody goes back to 7.2.9 unsupervised, and testing 7.2.8 waits until I can sit next to it.</p>
<blockquote>
  <p>A transactional operating system turns a kernel regression into a reboot. Without system snapshots, that night would have cost a lot more.</p>

</blockquote>
<h3>The thick provisioning scare<span class="hx:absolute hx:-mt-20" id="the-thick-provisioning-scare"></span>
    <a href="#the-thick-provisioning-scare" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>On the QNAP, the whole copy ran with no snapshots at all: in a bulk migration, snapshots are dead weight. With the copy done, I went to enable the ZFS snapshot schedule and got a dry error: &ldquo;pool free space is not enough&rdquo;. With 20 TB free.</p>
<p><img src="/2026/10/06/desastre-no-meu-nas-como-quase-perdi-90-tb-de-dados/nas-qnap-thick-thin-erro-snapshot.png" alt="QNAP error when trying to create a snapshot: insufficient pool free space, even with terabytes free"  loading="lazy" />)</p>
<p>Both statements were true about different things. The CHAD folder had been created as <strong>thick</strong>: out of the 100 TB quota, ZFS reserves the full 100 TB, and to snapshot a dataset with a reservation, the pool has to guarantee space to overwrite every referenced block while the snapshot pins the old copies. The math: 80 TB written plus 100 TB reserved, against 126 TB usable in the pool. It doesn&rsquo;t close. And <code>df</code> lies on both sides, because it measures the folder against the quota, not against the pool.</p>
<p>The fix was converting the folder to <strong>thin</strong>, which QuTS hero does in-place, hidden on the folder&rsquo;s Management screen, not in the edit dialog where &ldquo;Thick&rdquo; looks like static text. In thin, the number becomes a ceiling instead of a reservation: the 10 TB reservation evaporated, the pool ended up with 39 TB free, and the first snapshot came out consuming 334 KB over 79.9 TB referenced. ZFS needed almost nothing; the reservation was what demanded a 100 TB guarantee to authorize.</p>
<p><img src="/2026/10/06/desastre-no-meu-nas-como-quase-perdi-90-tb-de-dados/nas-qnap-chad-properties.png" alt="CHAD folder properties after the conversion, now as thin"  loading="lazy" />)</p>
<p>Now the QNAP has a snapshot schedule with a retention policy, as it should have had since day one:</p>
<p><img src="/2026/10/06/desastre-no-meu-nas-como-quase-perdi-90-tb-de-dados/nas-qnap-snapshot-schedule.png" alt="CHAD folder snapshot schedule on the QNAP"  loading="lazy" />)</p>
<p><img src="/2026/10/06/desastre-no-meu-nas-como-quase-perdi-90-tb-de-dados/nas-qnap-snapshot-retention.png" alt="Snapshot retention policy: how many to keep per day, week and month"  loading="lazy" />)</p>
<p>Two footnotes for anyone building one of these. If your thin folder has an 80% alert, raise the quota for aesthetic slack, because the alert measures against the quota and not against the pool. And this QNAP&rsquo;s real bottleneck is RAM: 7 GB for a 174 TB pool. QuTS hero&rsquo;s official minimum is 8 GB, and sensible practice at this scale is 16 and up. The size of the ARC cache governs metadata speed, and that&rsquo;s why the whole-tree sweeps during the migration were slow. A RAM upgrade is worth more than any fine-tuning.</p>
<h3>The Synology is born again<span class="hx:absolute hx:-mt-20" id="the-synology-is-born-again"></span>
    <a href="#the-synology-is-born-again" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>With everything verified on the QNAP, the Synology could finally be operated on. I swapped drives 2 and 8 for new IronWolf Pros, deleted the wounded pool and recreated it from scratch, this time in <strong>SHR-2</strong>, the two-parity-drive mode, their RAID-6. The new pool came out with 100 TB, all drives healthy:</p>
<p><img src="/2026/10/06/desastre-no-meu-nas-como-quase-perdi-90-tb-de-dados/nas-synology-pool-novo-saudavel.png" alt="Synology Storage Manager showing the new SYNOCHAD pool in SHR with two-drive tolerance, 100 TB and all drives Healthy"  loading="lazy" />)</p>
<p>It goes back to work as a replica target, and no longer as a single point of failure. The QNAP stays as primary, in RAIDZ2, with 87 TB of the 126 TB used and snapshots scheduled:</p>
<p><img src="/2026/10/06/desastre-no-meu-nas-como-quase-perdi-90-tb-de-dados/nas-qnap-storage-manager-final.png" alt="QNAP Storage Manager with the RAID 6 Ready pool and the CHAD folder in thin with 79.9 TB of 95 TB"  loading="lazy" />)</p>
<h3>What I take from the second half<span class="hx:absolute hx:-mt-20" id="what-i-take-from-the-second-half"></span>
    <a href="#what-i-take-from-the-second-half" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ol>
<li><strong>Distrust silent success.</strong> HBS reported green while leaving 901 GB behind. A backup is verified against a source inventory.</li>
<li><strong>SMART doesn&rsquo;t measure slowness.</strong> The drive that nearly killed the evacuation was &ldquo;healthy.&rdquo; Per-drive utilization and latency tell the truth.</li>
<li><strong>Parallelism is calibrated by measuring.</strong> Bandwidth-bound splits little, latency-bound splits a lot, and the shard is chosen by file count.</li>
<li><strong>Verification doesn&rsquo;t inherit excludes.</strong> If the proof uses the same filters as the copy, it only reaffirms the copy&rsquo;s assumptions. And verify again immediately before destroying the source.</li>
<li><strong>NFS permission is per client.</strong> Test writes from every machine that will use it.</li>
<li><strong>Don&rsquo;t hammer a dying sshd.</strong> Each attempt extends the ban. One patient attempt, or the physical console.</li>
<li><strong>The dangerous repair comes last.</strong> The array stayed degraded and slow until the last byte was proven somewhere else; only then did rebuilding stop costing anything.</li>
</ol>
<p>I came out of this better protected than I went in: two NASes, each with its own RAID-6, scheduled snapshots on the QNAP, daily restic, and Glacier for what&rsquo;s critical. The disaster cost me a QNAP, two drives and a week. It could have cost 90 TB.</p>
]]></content:encoded><category>storage-and-backup</category><category>homelab</category><category>hardware</category></item><item><title>Bitcoin on the Home Server: Sovereignty and Privacy with Coldcard, Sparrow and Fulcrum</title><link>https://akitaonrails.github.io/en/2026/04/01/bitcoin-on-the-home-server-sovereignty-with-coldcard-sparrow-fulcrum/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/04/01/bitcoin-on-the-home-server-sovereignty-with-coldcard-sparrow-fulcrum/</guid><pubDate>Wed, 01 Apr 2026 19:00:00 -0300</pubDate><description>&lt;p&gt;This post is a direct follow-up to my recent articles about the &lt;a href="https://akitaonrails.github.io/en/2026/03/31/migrating-my-home-server-with-claude-code/"&gt;new home server with openSUSE MicroOS&lt;/a&gt; and the &lt;a href="https://akitaonrails.github.io/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/"&gt;Minisforum MS-S1 Max&lt;/a&gt;. Those covered the foundation. Here I want to show one concrete use for it: putting together a decent Bitcoin stack at home, focused on privacy, operational sovereignty and safe transactions on my side.&lt;/p&gt;
&lt;p&gt;First things first: this isn&amp;rsquo;t an evangelism piece or a day-trading pitch. Quite the opposite. As I write this, on April 1, 2026, Bitcoin is around US$ 68k and close to R$ 391k, below the 2025 peaks. Plenty of people look at that and either panic or start fantasizing about leveraged trades. I think both reactions are wrong. There&amp;rsquo;s a &amp;ldquo;super cycle&amp;rdquo; thesis floating around based on institutional demand, spot ETFs and the lagged halving effect. Maybe. Maybe not. What I do know is that short-term candles don&amp;rsquo;t change the part I actually care about: infrastructure. If you need leverage to &amp;ldquo;speed up your gains,&amp;rdquo; you&amp;rsquo;re probably just speeding up your chances of getting liquidated.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is a direct follow-up to my recent articles about the <a href="/en/2026/03/31/migrating-my-home-server-with-claude-code/">new home server with openSUSE MicroOS</a> and the <a href="/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">Minisforum MS-S1 Max</a>. Those covered the foundation. Here I want to show one concrete use for it: putting together a decent Bitcoin stack at home, focused on privacy, operational sovereignty and safe transactions on my side.</p>
<p>First things first: this isn&rsquo;t an evangelism piece or a day-trading pitch. Quite the opposite. As I write this, on April 1, 2026, Bitcoin is around US$ 68k and close to R$ 391k, below the 2025 peaks. Plenty of people look at that and either panic or start fantasizing about leveraged trades. I think both reactions are wrong. There&rsquo;s a &ldquo;super cycle&rdquo; thesis floating around based on institutional demand, spot ETFs and the lagged halving effect. Maybe. Maybe not. What I do know is that short-term candles don&rsquo;t change the part I actually care about: infrastructure. If you need leverage to &ldquo;speed up your gains,&rdquo; you&rsquo;re probably just speeding up your chances of getting liquidated.</p>
<p>For me, the useful question isn&rsquo;t &ldquo;is it going up tomorrow?&rdquo; The useful question is: &ldquo;if I want to store and move Bitcoin without outsourcing everything to an exchange, a web wallet and a public API, how do I set that up properly at home?&rdquo;</p>
<h2>The real problem: too much convenience costs too much privacy<span class="hx:absolute hx:-mt-20" id="the-real-problem-too-much-convenience-costs-too-much-privacy"></span>
    <a href="#the-real-problem-too-much-convenience-costs-too-much-privacy" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Most people&rsquo;s default flow is simple: buy on an exchange, leave the balance sitting there, or install some random wallet on the phone and call it done. It works. It also concentrates risk and leaks metadata everywhere.</p>
<p>If you leave a balance on an exchange, you have custody risk. If you use a desktop wallet pointed at a public server, you have privacy risk. If you use a hardware wallet casually, bought second-hand on Mercado Livre, you have supply chain risk. Mix all that with hurry, and it gets worse.</p>
<p>That&rsquo;s why I ended up at a combination that, for someone technical who wants to run their own infra, feels pretty solid:</p>
<ul>
<li>Coldcard for cold storage</li>
<li>Sparrow Wallet on Linux as the desktop wallet and transaction coordinator</li>
<li>Fulcrum on the home server as a private Electrum server</li>
<li>bitcoind on the same server as a real full node, validating the chain and broadcasting without depending on third parties</li>
</ul>
<p>It&rsquo;s not the easiest path. But that&rsquo;s exactly the point. Real security rarely comes from the easiest path.</p>
<h2>The concepts that confuse beginners<span class="hx:absolute hx:-mt-20" id="the-concepts-that-confuse-beginners"></span>
    <a href="#the-concepts-that-confuse-beginners" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Before getting into the stack, it&rsquo;s worth aligning on four terms that usually get tossed around like everyone already knows them:</p>
<table>
  <thead>
      <tr>
          <th>Concept</th>
          <th>What it is</th>
          <th>Why it matters</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Airgap</td>
          <td>A device that never touches the internet, not even over data USB</td>
          <td>Reduces the signer&rsquo;s attack surface</td>
      </tr>
      <tr>
          <td>PSBT</td>
          <td>Partially Signed Bitcoin Transaction</td>
          <td>Standard format for preparing, signing and finalizing transactions in stages</td>
      </tr>
      <tr>
          <td>Watch-only wallet</td>
          <td>A wallet that sees balances/addresses but doesn&rsquo;t hold a private key</td>
          <td>Great for the desktop: it observes and assembles the transaction, but doesn&rsquo;t sign</td>
      </tr>
      <tr>
          <td>Full node</td>
          <td>A node that validates blocks and protocol rules locally</td>
          <td>You don&rsquo;t have to &ldquo;trust&rdquo; anyone&rsquo;s API</td>
      </tr>
      <tr>
          <td>Electrum server</td>
          <td>An indexing layer that quickly answers wallet queries</td>
          <td>Without one, desktop wallets end up dependent on public servers</td>
      </tr>
  </tbody>
</table>
<p>In plain language, the flow looks like this:</p>
<ol>
<li>Sparrow, on the desktop, builds the transaction.</li>
<li>That transaction becomes a PSBT.</li>
<li>The PSBT goes to the Coldcard via microSD.</li>
<li>The Coldcard signs it offline.</li>
<li>The signed file goes back to Sparrow.</li>
<li>Sparrow broadcasts through your own server, not through someone else&rsquo;s public infrastructure.</li>
</ol>
<p>That&rsquo;s what people mean by &ldquo;airgapped workflow.&rdquo; It&rsquo;s not magic. It&rsquo;s just disciplined separation of roles.</p>
<h2>Coldcard: cold signer, offline, the right kind of annoying<span class="hx:absolute hx:-mt-20" id="coldcard-cold-signer-offline-the-right-kind-of-annoying"></span>
    <a href="#coldcard-cold-signer-offline-the-right-kind-of-annoying" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I use <a href="https://coldcard.com/"target="_blank" rel="noopener">Coldcard</a> as cold storage. The reason is simple: it was designed from day one as a Bitcoin-only device, with a heavy focus on airgapped operation through microSD. That alone eliminates an entire category of &ldquo;conveniences&rdquo; that many people find practical, but that I&rsquo;d rather not have anywhere near my keys.</p>
<p><a href="https://coldcard.com/mk4"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/bitcoin-sovereignty/coldcard-mk4-official.png" alt="Coldcard Mk4 on the official Coinkite site"  loading="lazy" /></a></p>
<p>In practice, the Coldcard holds the most important part of the system: the private key. It doesn&rsquo;t need to know about a server, Electrum, public API, exchange, or any of that. Its job is one thing: sign transactions offline.</p>
<p>That decoupling is great for two reasons:</p>
<ul>
<li>The desktop can be convenient without becoming a single point of failure.</li>
<li>The signer stays isolated even if your main machine has problems.</li>
</ul>
<p>And here&rsquo;s a warning I really want to put in mental all-caps:</p>
<p><strong>Never buy a hardware wallet second-hand. Ever.</strong></p>
<p>This isn&rsquo;t an exaggeration. You have no way to actually know what happened to that device before it reached your hands. It could have a pre-generated seed, tampered firmware, swapped components, repackaged box, compromised supply chain, or simply some dumb trick waiting for you to let your guard down. Hardware wallet is one of those categories where saving R$ 300 buying used is insanity. Always buy from the manufacturer&rsquo;s official site or from a reseller officially authorized by the manufacturer. And even then, check seals, provenance and firmware.</p>
<h2>Can you do something similar with an old phone?<span class="hx:absolute hx:-mt-20" id="can-you-do-something-similar-with-an-old-phone"></span>
    <a href="#can-you-do-something-similar-with-an-old-phone" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>You can. But I&rsquo;d treat it as a study or budget alternative, not as an obvious substitute for a Coldcard.</p>
<p>The most serious path for that today is <a href="https://airgap.it/"target="_blank" rel="noopener">AirGap Vault</a>, which was specifically designed to use an old smartphone as an offline signer over QR codes, keeping the device off the network. The idea is good, and for many people it might be the right entry point.</p>
<p>But there are trade-offs:</p>
<ul>
<li>An old smartphone wasn&rsquo;t designed as a dedicated hardware wallet</li>
<li>The device&rsquo;s prior history matters</li>
<li>An aged battery, bad screen and abandoned Android are real problems</li>
<li>The threat model is less clear than on a dedicated device</li>
</ul>
<p>So my view is simple: can you use it? Yes. Would I recommend it as the main solution for storing meaningful wealth? No. For that I still prefer dedicated hardware bought from the right source.</p>
<h2>Sparrow Wallet: the best desktop piece in this puzzle<span class="hx:absolute hx:-mt-20" id="sparrow-wallet-the-best-desktop-piece-in-this-puzzle"></span>
    <a href="#sparrow-wallet-the-best-desktop-piece-in-this-puzzle" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On Linux, I use <a href="https://sparrowwallet.com/"target="_blank" rel="noopener">Sparrow Wallet</a>. For me, today, it&rsquo;s one of the best pieces of software in this ecosystem.</p>
<p><a href="https://www.sparrowwallet.com/features/"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/04/01/bitcoin-sovereignty/sparrow-transactions.png" alt="Sparrow Wallet running, showing a detailed history of the wallet and transactions"  loading="lazy" /></a></p>
<p>What I like about it:</p>
<ul>
<li>works very well on Linux desktop</li>
<li>supports hardware wallets properly</li>
<li>understands PSBT without drama</li>
<li>makes it crystal clear what&rsquo;s happening in a transaction</li>
<li>it&rsquo;s great as a watch-only wallet</li>
</ul>
<p>In my flow, Sparrow does three things:</p>
<ol>
<li>Holds the watch-only wallet.</li>
<li>Builds the transaction with outputs and fees.</li>
<li>Receives the signature back from the Coldcard and broadcasts it.</li>
</ol>
<p>That separation is elegant. The desktop becomes the coordinator. The signer stays cold.</p>
<h2>Why Coldcard + Sparrow works so well<span class="hx:absolute hx:-mt-20" id="why-coldcard--sparrow-works-so-well"></span>
    <a href="#why-coldcard--sparrow-works-so-well" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This combo is good because each piece does what it does best:</p>
<ul>
<li>the Coldcard protects the key</li>
<li>Sparrow organizes the human use of the wallet</li>
<li>the server handles the infrastructure</li>
</ul>
<p>A lot of wallets try to do everything. I prefer this modular design. It&rsquo;s less &ldquo;magic,&rdquo; more explicit, and easier to reason about without lying to yourself.</p>
<p>If I&rsquo;m at the desktop, I want visibility. If I&rsquo;m at the signer, I want isolation. If I&rsquo;m at the server, I want validation and a local index. That division is clean.</p>
<h2>The Sparrow problem when you don&rsquo;t run your own infra<span class="hx:absolute hx:-mt-20" id="the-sparrow-problem-when-you-dont-run-your-own-infra"></span>
    <a href="#the-sparrow-problem-when-you-dont-run-your-own-infra" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now comes the important detail. Sparrow alone doesn&rsquo;t solve privacy.</p>
<p>If you install it, open it and just use public servers, the people on the other end learn quite a lot about your wallet: your address set, xpubs or derivations, balance, history, query behavior, broadcast. It&rsquo;s not custody, but it&rsquo;s still exposure.</p>
<p>That&rsquo;s the hole Fulcrum fills.</p>
<h2>Fulcrum: the home&rsquo;s private Electrum server<span class="hx:absolute hx:-mt-20" id="fulcrum-the-homes-private-electrum-server"></span>
    <a href="#fulcrum-the-homes-private-electrum-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/cculianu/Fulcrum"target="_blank" rel="noopener">Fulcrum</a> is an Electrum server. Instead of letting Sparrow ask things of a third-party public server, it asks my own server.</p>
<p>In practice, that means:</p>
<ul>
<li>local balance lookups</li>
<li>local history</li>
<li>local address discovery</li>
<li>local broadcast</li>
</ul>
<p>In other words: the desktop wallet stops &ldquo;phoning home&rdquo; to the world every time you open the program.</p>
<p>In my current setup, Sparrow points at a Fulcrum running on the home server on the LAN, with port <code>50001</code> on the internal network and <code>50002</code> with TLS.</p>
<h2>And why Fulcrum isn&rsquo;t enough on its own<span class="hx:absolute hx:-mt-20" id="and-why-fulcrum-isnt-enough-on-its-own"></span>
    <a href="#and-why-fulcrum-isnt-enough-on-its-own" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Because Fulcrum doesn&rsquo;t replace a full node. It indexes on top of a full node.</p>
<p>The thing actually validating blocks, consensus rules, scripts, transactions and the chain is <code>bitcoind</code>. Fulcrum sits in front of it as an indexing layer, because plain Bitcoin Core wasn&rsquo;t built to serve a desktop wallet with that kind of fast querying.</p>
<p>So the correct architecture is:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Coldcard (offline signer)
</span></span><span class="line"><span class="cl">        ^
</span></span><span class="line"><span class="cl">        | microSD / PSBT
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">Sparrow Wallet (desktop watch-only + coordinator)
</span></span><span class="line"><span class="cl">        |
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">Fulcrum (private Electrum server)
</span></span><span class="line"><span class="cl">        |
</span></span><span class="line"><span class="cl">        v
</span></span><span class="line"><span class="cl">bitcoind (full node)</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>What I actually brought up on the home server<span class="hx:absolute hx:-mt-20" id="what-i-actually-brought-up-on-the-home-server"></span>
    <a href="#what-i-actually-brought-up-on-the-home-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On my home server, the stack lives in a dedicated Docker Compose folder and is made of two containers:</p>
<ul>
<li><code>bitcoin-bitcoind</code></li>
<li><code>bitcoin-fulcrum</code></li>
</ul>
<p>The compose is simple. And that&rsquo;s good. Sensitive infra doesn&rsquo;t gain anything by getting clever in YAML.</p>
<p>The main design is this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">bitcoin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">lncm/bitcoind:v28.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">bitcoin-bitcoind</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">user</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${BITCOIN_UID}:${BITCOIN_GID}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/data:/data/.bitcoin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8333:8333&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">stop_grace_period</span><span class="p">:</span><span class="w"> </span><span class="l">5m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;bitcoin-cli&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-datadir=/data/.bitcoin&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;ping&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">60s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">fulcrum</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cculianu/fulcrum:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">bitcoin-fulcrum</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">always</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/fulcrum:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/srv/bitcoin/data:/bitcoin:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;Fulcrum&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;/data/fulcrum.conf&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;50001:50001&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;50002:50002&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">bitcoin</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In my case, the restriction of <code>50001</code> to LAN happens at the host&rsquo;s network layer. The YAML above is the skeleton of the stack, not the entire firewall policy.</p>
<p>The most important parts of this:</p>
<ul>
<li><code>restart: always</code> because this is a long-running service</li>
<li>explicit volume so state isn&rsquo;t lost</li>
<li><code>user: &quot;${BITCOIN_UID}:${BITCOIN_GID}&quot;</code> because the persistent directory needs to match the storage&rsquo;s real ownership, so I&rsquo;d rather pin UID/GID explicitly than trust the image&rsquo;s default</li>
<li>the RPC isn&rsquo;t published on the host; it stays on the Compose internal network, which is all Fulcrum needs</li>
<li>the healthcheck uses Bitcoin Core&rsquo;s own local <code>.cookie</code>, so there&rsquo;s no need to spread a fixed password through commands</li>
<li>Fulcrum mounts the node&rsquo;s datadir as read-only just to authenticate via the <code>.cookie</code> without inventing parallel credentials</li>
<li>in <code>fulcrum.conf</code>, that becomes a simple configuration: talk to <code>bitcoin:8332</code> and read the mounted <code>.cookie</code>, instead of repeating credentials in plaintext</li>
<li><code>security_opt: label:disable</code> because on this host with MicroOS, SELinux and sensitive bind mounts, I preferred the pragmatic route of disarming this specific friction rather than wasting time fighting labels on a volume that&rsquo;s already being handled in a controlled way</li>
<li><code>depends_on</code> with <code>service_healthy</code> so Fulcrum only comes up after bitcoind&rsquo;s RPC is responding</li>
<li><code>stop_grace_period: 5m</code> because bitcoind needs real time to flush state on a graceful shutdown</li>
</ul>
<h2>The final version<span class="hx:absolute hx:-mt-20" id="the-final-version"></span>
    <a href="#the-final-version" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Today, the design I want to keep is this: <code>bitcoind</code> with <code>txindex</code>, <code>dbcache=1024</code>, persistent volume, 5-minute graceful stop, <code>.cookie</code> authentication, and Fulcrum in front serving Sparrow over LAN or TLS.</p>
<p>The current stack looks like this:</p>
<table>
  <thead>
      <tr>
          <th>Component</th>
          <th>State</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bitcoin Core</td>
          <td><code>28.0</code></td>
      </tr>
      <tr>
          <td>Fulcrum</td>
          <td><code>2.1.0</code></td>
      </tr>
      <tr>
          <td>Container stop timeout</td>
          <td><code>300</code> seconds</td>
      </tr>
      <tr>
          <td>Node data dir</td>
          <td>dedicated persistent volume mounted at <code>/data/.bitcoin</code></td>
      </tr>
      <tr>
          <td>Network</td>
          <td><code>8333</code> for P2P, RPC only on the Compose internal network, <code>50001/50002</code> for the private Electrum</td>
      </tr>
  </tbody>
</table>
<p>I&rsquo;m not interested in turning this into a spectacle. The point is simpler: the final infrastructure has to be boring, predictable and stable.</p>
<h2>The tunings that actually matter<span class="hx:absolute hx:-mt-20" id="the-tunings-that-actually-matter"></span>
    <a href="#the-tunings-that-actually-matter" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s no magic here. There are a few parameters that make a real difference and a bunch of stuff that just decorates compose files.</p>
<p><code>stop_grace_period: 5m</code> exists because bitcoind isn&rsquo;t a disposable stateless API container. It maintains chainstate, indexes and an in-memory cache. If you don&rsquo;t give the process time to finish properly, you create unnecessary work for the next start.</p>
<p><code>user: &quot;${BITCOIN_UID}:${BITCOIN_GID}&quot;</code> is there for a much less glamorous and much more important reason: persistent storage with the wrong permissions is an excellent way to break a working service. So I&rsquo;d rather align the container with the volume&rsquo;s actual ownership instead of leaving that implicit.</p>
<p><code>dbcache=1024</code> is the spot I find most reasonable for a domestic node that&rsquo;s always on. Big enough not to suffer constant I/O, small enough that every restart isn&rsquo;t a labor.</p>
<p><code>txindex=1</code> I keep because I want the complete node, not a minimalist install just to claim &ldquo;it runs Bitcoin.&rdquo; If the goal here is operational autonomy, I&rsquo;d rather have the full index.</p>
<p><code>rpcworkqueue=512</code> and <code>rpcthreads=16</code> are the kind of tweak that makes sense when you know you&rsquo;ll have Fulcrum querying the node all day and you want some headroom.</p>
<p>On the Fulcrum side, the main parameters are:</p>
<ul>
<li><code>db_mem = 8192</code></li>
<li><code>db_max_open_files = -1</code></li>
<li><code>bitcoind_clients = 8</code></li>
<li><code>worker_threads = 0</code></li>
<li><code>peering = false</code></li>
</ul>
<p>Again: nothing esoteric. Just enough cache, reasonable parallelism and absolutely no announcing this server as a public service.</p>
<p>In my current <code>bitcoin.conf</code>, the important core ended up like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">server</span><span class="o">=</span><span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">txindex</span><span class="o">=</span><span class="s">1</span>
</span></span><span class="line"><span class="cl"><span class="na">prune</span><span class="o">=</span><span class="s">0</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcbind</span><span class="o">=</span><span class="s">0.0.0.0</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcallowip</span><span class="o">=</span><span class="s">172.0.0.0/8</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcthreads</span><span class="o">=</span><span class="s">16</span>
</span></span><span class="line"><span class="cl"><span class="na">rpcworkqueue</span><span class="o">=</span><span class="s">512</span>
</span></span><span class="line"><span class="cl"><span class="na">dbcache</span><span class="o">=</span><span class="s">1024</span>
</span></span><span class="line"><span class="cl"><span class="na">maxmempool</span><span class="o">=</span><span class="s">512</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>All of this makes sense on a server with decent RAM and fast NVMe. But the detail that matters most is still the clean shutdown. Wallet infrastructure has no room for &ldquo;we&rsquo;ll deal with it later&rdquo; thinking.</p>
<h2>The actual size of all this<span class="hx:absolute hx:-mt-20" id="the-actual-size-of-all-this"></span>
    <a href="#the-actual-size-of-all-this" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is another point a lot of people underestimate.</p>
<p>If you look at older Bitcoin Core documentation, you&rsquo;ll find numbers like 350 GB of disk for a node with default config. That&rsquo;s outdated. More current data on the size of the blockchain points to something around <strong>725.82 GB on March 11, 2026</strong>, and that&rsquo;s just the raw chain, without the extra indexes that many technical folks will want to keep.</p>
<p>And here comes the catch: the stack I&rsquo;m describing isn&rsquo;t &ldquo;a bare Bitcoin Core just to claim you run a node.&rdquo; It&rsquo;s <code>bitcoind</code> with <code>txindex</code>, plus Fulcrum, plus headroom for rebuild, logs, snapshots and normal network growth.</p>
<p>So to put together something similar today, I&rsquo;d think like this:</p>
<ul>
<li>below 1 TB: I wouldn&rsquo;t even start</li>
<li>1 TB: pragmatic minimum</li>
<li>2 TB: comfortable range</li>
<li>above that: if you want long-term headroom, snapshots and less operational anxiety</li>
</ul>
<p>And here&rsquo;s the most important observation of all in self-hosting: don&rsquo;t assume persistence, mount and backup are right just because the YAML looks clean. Verify.</p>
<p>Another thing I wouldn&rsquo;t forget on a btrfs host: put Fulcrum&rsquo;s database (<code>fulc2_db</code>) on a separate subvolume. The reason is mundane. That directory grows, changes constantly and has nothing to do with generic automated snapshots of <code>/var</code>. If you mix everything, you end up dragging a large rebuildable index along with system snapshots, burning space and making maintenance more annoying than it needed to be. The Fulcrum index isn&rsquo;t sensitive configuration. It&rsquo;s heavy, volatile, rebuildable data. I treat it exactly like that.</p>
<h2>Hardening: what I&rsquo;ve already applied<span class="hx:absolute hx:-mt-20" id="hardening-what-ive-already-applied"></span>
    <a href="#hardening-what-ive-already-applied" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is where the difference between &ldquo;ran on my laptop&rdquo; and &ldquo;I&rsquo;d trust this to operate my wallet&rdquo; shows up.</p>
<p>In the current state of the stack, the points I consider important ended up like this:</p>
<ul>
<li>Bitcoin Core&rsquo;s RPC no longer relies on unnecessary host exposure; Fulcrum talks to <code>bitcoind</code> over the Docker internal network, which is what actually matters</li>
<li><code>50001</code> is restricted to internal LAN use</li>
<li><code>50002</code> is available with TLS, which is the right move when you need to leave plaintext behind</li>
<li>shutdown is graceful, with <code>stop_grace_period: 5m</code>, so <code>bitcoind</code> has time to flush state instead of dying any old way</li>
<li>the storage mount isn&rsquo;t on a &ldquo;we&rsquo;ll see later&rdquo; basis; there&rsquo;s a mount check before Docker comes up, precisely to avoid silent drift</li>
</ul>
<p>Each of those items exists for a very concrete reason.</p>
<p>Pulling the RPC off the host&rsquo;s surface reduces attack at zero cost. Fulcrum is already in the same Compose and can already talk to the service by its internal name. There&rsquo;s no real gain in leaving that port exposed where it doesn&rsquo;t need to be exposed.</p>
<p>Separating <code>50001</code> and <code>50002</code> also helps keep the house in order. Within a controlled LAN, plaintext is acceptable. Outside of that, the minimum reasonable thing is TLS. Mixing the two scenarios usually turns into a mess.</p>
<p><code>stop_grace_period: 5m</code> looks like a container detail, but it isn&rsquo;t. Anyone who&rsquo;s ever had a database, an index or a blockchain node killed without grace knows how that turns into hours of work later. A stateful service needs a decent stop.</p>
<p>And the mount check is one of those annoying things that saves you from yourself. The YAML can look beautiful. If the storage didn&rsquo;t mount and the service came up writing where it shouldn&rsquo;t, you&rsquo;ve just manufactured a really irritating problem.</p>
<p>There&rsquo;s also one detail I really like in this final version of the stack: Fulcrum authenticates to <code>bitcoind</code> through the <code>.cookie</code> file, not through a fixed plaintext password. That&rsquo;s interesting for two reasons:</p>
<ul>
<li>you don&rsquo;t need to leave a static credential showing up in compose, inspect, healthcheck or documentation</li>
<li>the authentication is more aligned with the way Bitcoin Core already knows how to operate locally</li>
</ul>
<p>In practical terms, that reduces accidental leakage of operational secrets. It&rsquo;s not a magic solution to everything, but it&rsquo;s much better than spreading <code>rpcuser</code> and <code>rpcpassword</code> across files, logs and commands.</p>
<p>The only kind of hardening I try to avoid here is the one that&rsquo;s overly performative in YAML and loose in operation. I&rsquo;d rather have less &ldquo;stage engineering&rdquo; and more basic discipline:</p>
<ul>
<li>minimum network</li>
<li>minimum secrets</li>
<li>minimum privilege</li>
<li>clean shutdown</li>
<li>verified storage</li>
<li>separate subvolume for large rebuildable data, like the Fulcrum index</li>
</ul>
<p>And, again, document everything. Good infrastructure isn&rsquo;t the kind that just works today. It&rsquo;s the kind that keeps working when you come back to it six months later.</p>
<h2>Why this improves transactions on your side<span class="hx:absolute hx:-mt-20" id="why-this-improves-transactions-on-your-side"></span>
    <a href="#why-this-improves-transactions-on-your-side" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When I build a transaction in Sparrow and sign it on the Coldcard, the chain of trust is much better defined:</p>
<ul>
<li>the private key never touches the internet</li>
<li>the desktop wallet doesn&rsquo;t have to trust a public server</li>
<li>the broadcast can come out of my own node</li>
<li>the address history doesn&rsquo;t need to land on a third-party Electrum server</li>
</ul>
<p>This doesn&rsquo;t make anything invulnerable. There&rsquo;s still a risk of malware on the desktop, badly stored seeds, human error, social engineering and physical disaster. But the design becomes much more coherent.</p>
<h2>What about Lightning? Especially in Brazil?<span class="hx:absolute hx:-mt-20" id="what-about-lightning-especially-in-brazil"></span>
    <a href="#what-about-lightning-especially-in-brazil" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There I separate things.</p>
<p>Reserves and larger-value transactions I treat one way. Day-to-day spending I treat another.</p>
<p>For day-to-day spending, especially in Brazil, I think it&rsquo;s operationally dumb to carry a lot of balance in a hot wallet. Lightning wallets and spending apps have to be almost like a &ldquo;pocket wallet&rdquo;: just enough for daily life.</p>
<p>That goes double if you use a hybrid or custodial solution like <a href="https://www.redotpay.com/"target="_blank" rel="noopener">RedotPay</a>. I get why it&rsquo;s interesting for Brazilians: a Hong Kong company, international focus, a reasonably practical bridge between crypto and card spending. For travel, online shopping and life outside the Brazilian banking axis, it makes sense. But I&rsquo;d never treat it as a place to store wealth. That&rsquo;s a spending tool, not a vault.</p>
<p>Same logic for <a href="https://www.bitrefill.com/br/pt/"target="_blank" rel="noopener">Bitrefill Brasil</a>. I think the service is interesting precisely because it solves a real pain in Brazil: turning sats into concrete utility without selling your full position or depending on banking integration all the time. Gift cards, top-ups, small expenses. As a use tool, it makes a lot of sense.</p>
<p>For a Lightning wallet on the phone, I&rsquo;d look at first:</p>
<ul>
<li><a href="https://phoenix.acinq.co/"target="_blank" rel="noopener">Phoenix</a> for people who want something very good and simple</li>
<li><a href="https://breez.technology/"target="_blank" rel="noopener">Breez</a> for people who want a great payments experience</li>
<li><a href="https://zeusln.com/"target="_blank" rel="noopener">ZEUS</a> if you&rsquo;re more technical and you eventually plan to operate your own Lightning node</li>
</ul>
<p>All of them, in my head, fit into the &ldquo;pocket wallet&rdquo; category. Small balance. Daily use. Don&rsquo;t turn a phone app into a retirement vault.</p>
<h2>Recent news that reinforces this reasoning<span class="hx:absolute hx:-mt-20" id="recent-news-that-reinforces-this-reasoning"></span>
    <a href="#recent-news-that-reinforces-this-reasoning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m not building this kind of stack because I think it&rsquo;s pretty. I&rsquo;m building it because outsourcing too much goes wrong too often.</p>
<p>Two recent examples:</p>
<ul>
<li>the <a href="https://www.cnbc.com/2025/02/21/hackers-steal-1point5-billion-from-exchange-bybit-biggest-crypto-heist.html"target="_blank" rel="noopener">Bybit hack in 2025</a> showed, again, the basic risk of leaving meaningful custody at an exchange</li>
<li>the <a href="https://techcrunch.com/2025/05/15/coinbase-says-customers-personal-information-stolen-in-data-breach/"target="_blank" rel="noopener">Coinbase customer data leak in 2025</a> showed the other side of the problem: even when custody isn&rsquo;t the immediate concern, your identity, balance and history become an attack surface</li>
</ul>
<p>A stack like Coldcard + Sparrow + Fulcrum + full node doesn&rsquo;t eliminate every risk in the world. But it avoids two very real classes of problem:</p>
<ul>
<li>losing custody sovereignty</li>
<li>handing over wallet and transaction privacy on a silver platter to third parties</li>
</ul>
<h2>So is it worth it?<span class="hx:absolute hx:-mt-20" id="so-is-it-worth-it"></span>
    <a href="#so-is-it-worth-it" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For most people, honestly, probably not in the first month. It&rsquo;s labor-intensive, has a learning curve, and demands discipline.</p>
<p>But for programmers, engineers and any technical person who wants to learn not to depend on someone else&rsquo;s service all the time, I think it&rsquo;s an excellent exercise.</p>
<p>You learn about:</p>
<ul>
<li>separation of concerns</li>
<li>persistence and state</li>
<li>graceful shutdown</li>
<li>observability</li>
<li>secret isolation</li>
<li>the trade-off between convenience and security</li>
</ul>
<p>And all of that is valuable beyond Bitcoin.</p>
<p>In the end, that&rsquo;s what interests me most about this stack. It isn&rsquo;t about preaching &ldquo;hyperbitcoinization&rdquo; or posing as a price prophet. It&rsquo;s about building a system at home that I can trust more because I&rsquo;m the one who installed it, measured it, broke it, fixed it and documented it.</p>
<p>Is it work? Yes.</p>
<p>But that kind of work teaches exactly what modern software tries to make you forget: depending less on others is more work upfront, but it usually buys a lot more control in the long run.</p>
]]></content:encoded><category>bitcoin-and-cryptocurrency</category><category>homelab</category><category>security</category></item><item><title>Migrating my Home Server with Claude Code | openSUSE MicroOS</title><link>https://akitaonrails.github.io/en/2026/03/31/migrating-my-home-server-with-claude-code/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/31/migrating-my-home-server-with-claude-code/</guid><pubDate>Tue, 31 Mar 2026 16:00:00 -0300</pubDate><description>&lt;p&gt;My old home server was a mess. An Intel NUC with Ubuntu Server that I&amp;rsquo;d been patching together for two years. Containers with hardcoded paths, volumes mounted in random places (&lt;code&gt;/home/akitaonrails/docker/&lt;/code&gt;, &lt;code&gt;/home/akitaonrails/sonarr/&lt;/code&gt;, &lt;code&gt;/mnt/terachad/&lt;/code&gt;), docker-compose files scattered with no consistent layout. It worked, but if I lost the disk it would take days to rebuild everything from memory.&lt;/p&gt;
&lt;p&gt;With the &lt;a href="https://akitaonrails.github.io/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/"&gt;new Minisforum MS-S1 Max&lt;/a&gt; that I bought, I decided to do the migration properly. And I decided to use Claude Code from the start to speed up the process. It&amp;rsquo;s a home server, only I use it, so the risk of making a mistake is low. But if this were a real production server, I would never do this without rigorous human review at every step.&lt;/p&gt;</description><content:encoded><![CDATA[<p>My old home server was a mess. An Intel NUC with Ubuntu Server that I&rsquo;d been patching together for two years. Containers with hardcoded paths, volumes mounted in random places (<code>/home/akitaonrails/docker/</code>, <code>/home/akitaonrails/sonarr/</code>, <code>/mnt/terachad/</code>), docker-compose files scattered with no consistent layout. It worked, but if I lost the disk it would take days to rebuild everything from memory.</p>
<p>With the <a href="/en/2026/03/31/minisforum-ms-s1-max-amd-ai-max-395-review/">new Minisforum MS-S1 Max</a> that I bought, I decided to do the migration properly. And I decided to use Claude Code from the start to speed up the process. It&rsquo;s a home server, only I use it, so the risk of making a mistake is low. But if this were a real production server, I would never do this without rigorous human review at every step.</p>
<p>What follows is the migration writeup and a guide for anyone who wants to replicate it. If I ever have to rebuild from scratch, this post is the documentation.</p>
<h2>Choosing the operating system<span class="hx:absolute hx:-mt-20" id="choosing-the-operating-system"></span>
    <a href="#choosing-the-operating-system" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Why not Ubuntu Server again<span class="hx:absolute hx:-mt-20" id="why-not-ubuntu-server-again"></span>
    <a href="#why-not-ubuntu-server-again" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I used Ubuntu Server on the NUC for convenience. But <code>do-release-upgrade</code> is Russian roulette. Every time Ubuntu releases a new version, the upgrade is a real risk of breaking things. Packages change, configs get overwritten, dependencies clash. For a server that has to be running constantly, that&rsquo;s unacceptable.</p>
<h3>Why not Arch Linux<span class="hx:absolute hx:-mt-20" id="why-not-arch-linux"></span>
    <a href="#why-not-arch-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I use Arch on the desktop and I like it. But Arch is a rolling release with no stability guarantees at all. For a desktop where I can stop and fix problems, fine. For a headless server running 49 Docker containers that has to work after every reboot, no.</p>
<h3>Fedora CoreOS vs openSUSE MicroOS<span class="hx:absolute hx:-mt-20" id="fedora-coreos-vs-opensuse-microos"></span>
    <a href="#fedora-coreos-vs-opensuse-microos" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The two modern options for a container server are Fedora CoreOS and openSUSE MicroOS. Both are immutable systems: the root filesystem is read-only, updates are atomic (they either apply in full or don&rsquo;t apply at all), and rollback is instant.</p>
<p>The difference: Fedora CoreOS uses Ignition (declarative configuration before the first boot) and is designed to be provisioned automatically. MicroOS uses <code>transactional-update</code> and allows normal interactive use. For a home server where I want SSH and the ability to poke around manually, MicroOS fits better.</p>
<h3>What makes MicroOS different<span class="hx:absolute hx:-mt-20" id="what-makes-microos-different"></span>
    <a href="#what-makes-microos-different" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The immutable system concept changes how you operate the server:</p>
<p>Every package install or <code>/etc</code> edit goes through <code>transactional-update</code>, which creates a new btrfs snapshot, applies the change in that snapshot, and on the next reboot the system boots into the updated snapshot. If the change breaks something, you do <code>transactional-update rollback</code> and you&rsquo;re back on the previous snapshot in seconds.</p>
<p>Updates are automatic and daily. The <code>transactional-update.timer</code> downloads patches, creates a snapshot, and <code>rebootmgr</code> reboots in a configured window (in my case, between 4am and 5:30am). If the update breaks the boot, GRUB automatically falls back to the previous snapshot.</p>
<p>SELinux is enforcing by default. That caused 90% of the problems during the migration, but it&rsquo;s the right setting for security.</p>
<h2>Initial setup<span class="hx:absolute hx:-mt-20" id="initial-setup"></span>
    <a href="#initial-setup" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Hardware<span class="hx:absolute hx:-mt-20" id="hardware"></span>
    <a href="#hardware" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li>AMD Ryzen AI Max+ 395, 128GB LPDDR5X</li>
<li>96GB allocated as VRAM via BIOS (UMA Frame Buffer Size)</li>
<li>2TB NVMe (system + Docker)</li>
<li>Wired 2.5Gbps network</li>
<li>Synology DS1821+ NAS at 192.168.0.21 (NFS)</li>
</ul>
<h3>First steps<span class="hx:absolute hx:-mt-20" id="first-steps"></span>
    <a href="#first-steps" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>MicroOS install is standard. Then:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Create user with a UID that matches the NAS (so NFS works without permission issues)</span>
</span></span><span class="line"><span class="cl">useradd -u <span class="m">1026</span> -m akitaonrails
</span></span><span class="line"><span class="cl">passwd akitaonrails
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Configure sudo (inside transactional-update shell)</span>
</span></span><span class="line"><span class="cl">sudo transactional-update shell
</span></span><span class="line"><span class="cl"><span class="c1"># inside: add akitaonrails to sudoers</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span>
</span></span><span class="line"><span class="cl">sudo reboot</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Synology NFS<span class="hx:absolute hx:-mt-20" id="synology-nfs"></span>
    <a href="#synology-nfs" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The Synology NAS exports <code>/volume1/TERACHAD</code> over NFS. The mount point on MicroOS is <code>/var/mnt/terachad</code> (not <code>/mnt/</code>, which lives on the immutable root).</p>
<p>In <code>/etc/fstab</code> (applied through transactional-update):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>192.168.0.21:/volume1/TERACHAD /var/mnt/terachad nfs4 nfsvers=4.1,rsize=262144,wsize=262144,hard,_netdev 0 0</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Details that matter: <code>nfsvers=4.1</code> because 4.2 didn&rsquo;t work with the Synology. <code>rsize=262144,wsize=262144</code> (256KB buffers) was the biggest NFS performance improvement. <code>hard</code> instead of <code>nofail</code> so the mount keeps retrying indefinitely if the NAS disconnects temporarily.</p>
<h3>GPU / ROCm<span class="hx:absolute hx:-mt-20" id="gpu--rocm"></span>
    <a href="#gpu--rocm" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This step was a pain. The Radeon 8060S in the AI Max+ 395 is gfx1151, which ROCm doesn&rsquo;t officially support. Three steps were needed, and all three are mandatory:</p>
<ol>
<li>BIOS: set UMA Frame Buffer Size to 96GB</li>
<li>Kernel: add <code>amdttm.pages_limit=25165824 amdttm.page_pool_size=25165824</code> to <code>/etc/kernel/cmdline</code></li>
<li>Docker: use <code>HSA_OVERRIDE_GFX_VERSION=11.5.1</code> in every ROCm container</li>
</ol>
<p>Without step 2, ROCm only sees 15.5GB even with the BIOS allocation in place. The numbers are 96GB / 4KB (page size) = 25,165,824 pages.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo transactional-update shell
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;amdttm.pages_limit=25165824 amdttm.page_pool_size=25165824&#34;</span> &gt;&gt; /etc/kernel/cmdline
</span></span><span class="line"><span class="cl"><span class="nb">exit</span>
</span></span><span class="line"><span class="cl">sudo sdbootutil update-all-entries  <span class="c1"># OUTSIDE the transactional shell</span>
</span></span><span class="line"><span class="cl">sudo reboot</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Verification:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cat /sys/class/drm/card1/device/mem_info_vram_total
</span></span><span class="line"><span class="cl"><span class="c1"># 103079215104 (96 * 1024^3)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>Docker on MicroOS<span class="hx:absolute hx:-mt-20" id="docker-on-microos"></span>
    <a href="#docker-on-microos" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo transactional-update --non-interactive pkg install docker
</span></span><span class="line"><span class="cl">sudo reboot
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now docker
</span></span><span class="line"><span class="cl">sudo usermod -aG docker akitaonrails
</span></span><span class="line"><span class="cl"><span class="c1"># logout and login for the group to take effect</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Install standalone docker-compose (the openSUSE package doesn&#39;t include it)</span>
</span></span><span class="line"><span class="cl">sudo curl -L <span class="s2">&#34;https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o /usr/local/bin/docker-compose
</span></span><span class="line"><span class="cl">sudo chmod +x /usr/local/bin/docker-compose
</span></span><span class="line"><span class="cl">mkdir -p ~/.docker/cli-plugins
</span></span><span class="line"><span class="cl">ln -s /usr/local/bin/docker-compose ~/.docker/cli-plugins/docker-compose</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>daemon.json<span class="hx:absolute hx:-mt-20" id="daemonjson"></span>
    <a href="#daemonjson" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;log-level&#34;</span><span class="p">:</span> <span class="s2">&#34;warn&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;log-driver&#34;</span><span class="p">:</span> <span class="s2">&#34;local&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;log-opts&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;max-size&#34;</span><span class="p">:</span> <span class="s2">&#34;10m&#34;</span><span class="p">,</span> <span class="nt">&#34;max-file&#34;</span><span class="p">:</span> <span class="s2">&#34;5&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;selinux-enabled&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;live-restore&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;userland-proxy&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;exec-opts&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;native.cgroupdriver=systemd&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>live-restore: true</code> keeps containers alive across a Docker daemon restart. <code>userland-proxy: false</code> uses iptables directly instead of proxy processes (less overhead). <code>selinux-enabled: true</code> is mandatory on MicroOS.</p>
<h2>SELinux and Docker: the biggest source of problems<span class="hx:absolute hx:-mt-20" id="selinux-and-docker-the-biggest-source-of-problems"></span>
    <a href="#selinux-and-docker-the-biggest-source-of-problems" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This deserves an entire section because it was responsible for 90% of the bugs during the migration.</p>
<p>On MicroOS with SELinux enforcing, every container that writes to a host bind-mounted directory needs special handling. There are two approaches: the <code>:Z</code> suffix on volumes and the <code>security_opt: label:disable</code> option.</p>
<h3>NEVER use <code>:Z</code>. Use <code>security_opt: label:disable</code>.<span class="hx:absolute hx:-mt-20" id="never-use-z-use-security_opt-labeldisable"></span>
    <a href="#never-use-z-use-security_opt-labeldisable" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>:Z</code> tells Docker to relabel the host directory with the container&rsquo;s SELinux context. Sounds like the right thing. In practice:</p>
<ul>
<li>SQLite databases break. The relabeling changes the file&rsquo;s context and SQLite may refuse to open the WAL journal.</li>
<li>NFS mounts silently ignore <code>:Z</code>. NFS doesn&rsquo;t support SELinux xattrs. The kernel ignores the flag without error, but the container still doesn&rsquo;t have permission.</li>
<li><code>:ro,Z</code> mounts try to relabel even when read-only, which fails on NFS and can corrupt the context on local paths.</li>
</ul>
<p>The right solution for every container on this system:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">myservice</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">label:disable    </span><span class="w"> </span><span class="c"># disables SELinux enforcement for this container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./data:/data     </span><span class="w"> </span><span class="c"># NO :Z</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./config.yml:/etc/config.yml:ro </span><span class="w"> </span><span class="c"># NO :Z even on :ro</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>label:disable</code> disables SELinux label enforcement for that container only, not for the entire system. Combined with Docker&rsquo;s network and process isolation, it&rsquo;s safe for a home server.</p>
<h2>Migrating the stacks<span class="hx:absolute hx:-mt-20" id="migrating-the-stacks"></span>
    <a href="#migrating-the-stacks" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>All Docker stacks were reorganized into <code>/var/opt/docker/&lt;stack&gt;/docker-compose.yml</code>. On the old server, they were scattered across <code>/home/akitaonrails/docker/</code>, <code>/home/akitaonrails/&lt;service&gt;/</code>, with no pattern.</p>
<h3>Substitutions applied across every compose file<span class="hx:absolute hx:-mt-20" id="substitutions-applied-across-every-compose-file"></span>
    <a href="#substitutions-applied-across-every-compose-file" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><table>
  <thead>
      <tr>
          <th>Before</th>
          <th>After</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>/mnt/terachad/</code></td>
          <td><code>/var/mnt/terachad/</code></td>
      </tr>
      <tr>
          <td><code>192.168.0.145</code></td>
          <td><code>192.168.0.90</code></td>
      </tr>
      <tr>
          <td><code>/home/akitaonrails/&lt;service&gt;/</code></td>
          <td><code>/var/opt/docker/&lt;stack&gt;/&lt;service&gt;/</code></td>
      </tr>
      <tr>
          <td><code>OLLAMA_BASE_URL=http://192.168.0.14:11434</code></td>
          <td><code>OLLAMA_BASE_URL=http://192.168.0.90:11434</code></td>
      </tr>
  </tbody>
</table>
<h3>Media stack (Plex, Radarr, Sonarr, etc.)<span class="hx:absolute hx:-mt-20" id="media-stack-plex-radarr-sonarr-etc"></span>
    <a href="#media-stack-plex-radarr-sonarr-etc" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The media stack is the most complex. Plex needs its own LAN IP (macvlan) for direct streaming to work. The setup:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker network create -d macvlan <span class="se">\
</span></span></span><span class="line"><span class="cl">  --subnet<span class="o">=</span>192.168.0.0/24 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --gateway<span class="o">=</span>192.168.0.1 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -o <span class="nv">parent</span><span class="o">=</span>enp97s0 <span class="se">\
</span></span></span><span class="line"><span class="cl">  plex_macvlan</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In compose, Plex needs to be on two networks: the macvlan (for the IP 192.168.0.6) and the default bridge (so other containers like Seerr can talk to it):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">plex</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">plex_macvlan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ipv4_address</span><span class="p">:</span><span class="w"> </span><span class="m">192.168.0.6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">mac_address</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;02:42:c0:a8:00:06&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">default</span><span class="p">:</span><span class="w"> </span>{}<span class="w">    </span><span class="c"># mandatory — without this, Seerr can&#39;t see Plex</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>A detail that almost broke me: Plex stores absolute paths in its database. If the container&rsquo;s internal volume changed from <code>/media</code> to <code>/data</code>, Plex no longer finds anything. You have to use exactly the same mount target as the old compose.</p>
<h3>Ollama with ROCm<span class="hx:absolute hx:-mt-20" id="ollama-with-rocm"></span>
    <a href="#ollama-with-rocm" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>New stack, didn&rsquo;t exist on the previous server:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">ollama</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ollama/ollama:rocm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">ollama</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">/dev/kfd:/dev/kfd</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">/dev/dri:/dev/dri</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">security_opt</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">seccomp:unconfined</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">label:disable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group_add</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;485&#34;</span><span class="w">   </span><span class="c"># render group GID</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;488&#34;</span><span class="w">   </span><span class="c"># video group GID</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">HSA_OVERRIDE_GFX_VERSION=11.5.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">PYTORCH_ROCM_ARCH=gfx1151</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_KEEP_ALIVE=30m</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_NUM_PARALLEL=4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_FLASH_ATTENTION=1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">OLLAMA_KV_CACHE_TYPE=q8_0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">/var/lib/ollama:/root/.ollama</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s2">&#34;11434:11434&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>OLLAMA_FLASH_ATTENTION=1</code> activates flash attention. <code>OLLAMA_KV_CACHE_TYPE=q8_0</code> uses 8-bit KV cache, cutting the bandwidth needed per token in half. Free performance optimizations.</p>
<h3>Monitoring (Grafana + Prometheus)<span class="hx:absolute hx:-mt-20" id="monitoring-grafana--prometheus"></span>
    <a href="#monitoring-grafana--prometheus" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Grafana uses a named volume (<code>grafana_data</code>) which is NOT included in normal filesystem backups. That&rsquo;s the reason I lost all my dashboards on the first attempt. The fix is an explicit backup of the named volume:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># On the old server:</span>
</span></span><span class="line"><span class="cl">docker run --rm -v grafana_data:/data:ro -v /tmp:/backup alpine <span class="se">\
</span></span></span><span class="line"><span class="cl">  tar czf /backup/grafana_data.tar.gz -C /data .
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Transfer and restore on the new one:</span>
</span></span><span class="line"><span class="cl">docker run --rm -v grafana_data:/data -v /tmp:/backup alpine <span class="se">\
</span></span></span><span class="line"><span class="cl">  sh -c <span class="s2">&#34;cd /data &amp;&amp; tar xzf /backup/grafana_data.tar.gz&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Same thing for Portainer (<code>portainer_data</code>). Any volume defined in the <code>volumes:</code> block of a compose without a host path needs this treatment.</p>
<h3>Cloudflare Tunnel<span class="hx:absolute hx:-mt-20" id="cloudflare-tunnel"></span>
    <a href="#cloudflare-tunnel" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I use <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/"target="_blank" rel="noopener">Cloudflare Tunnel</a> to access all the services from outside the house without opening ports on the router. The migration was the easiest part: copy the tunnel&rsquo;s JSON credentials file and the <code>config.yml</code>, update the IPs from <code>.145</code> to <code>.90</code>, and bring the container up. The tunnel keeps the same ID, no need to recreate DNS.</p>
<p>The hostnames live in <code>config.yml</code>: portainer, grafana, plex, seerr, qbittorrent, syncthing, radarr, sonarr, bazarr, prowlarr, vault, gitea, kavita, and others. Everything accessible via <code>https://&lt;service&gt;.example.com</code> from anywhere.</p>
<h3>Gitea (image registry)<span class="hx:absolute hx:-mt-20" id="gitea-image-registry"></span>
    <a href="#gitea-image-registry" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/go-gitea/gitea"target="_blank" rel="noopener">Gitea</a> acts as a private Docker registry on port 3007. The Frank FBI, Frank Mega, Frank Yomik and Mila projects have Docker images that are built and pushed to Gitea. For it to work, Docker&rsquo;s <code>daemon.json</code> needs:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;insecure-registries&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;192.168.0.90:3007&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Gitea SSH gave me a problem during the migration: the old app.ini had <code>SSH_LISTEN_PORT=22</code>, but the container&rsquo;s entrypoint also starts sshd on port 22. Conflict. Fix: <code>GITEA__server__SSH_LISTEN_PORT=2222</code> as an environment variable in compose.</p>
<h3>All 49 containers running<span class="hx:absolute hx:-mt-20" id="all-49-containers-running"></span>
    <a href="#all-49-containers-running" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The migrated server runs 49 containers across 15 stacks. The media stack alone has 10 containers (<a href="https://www.plex.tv/"target="_blank" rel="noopener">Plex</a>, <a href="https://github.com/Radarr/Radarr"target="_blank" rel="noopener">Radarr</a>, <a href="https://github.com/Sonarr/Sonarr"target="_blank" rel="noopener">Sonarr</a>, <a href="https://github.com/morpheus65535/bazarr"target="_blank" rel="noopener">Bazarr</a>, <a href="https://github.com/Prowlarr/Prowlarr"target="_blank" rel="noopener">Prowlarr</a>, <a href="https://github.com/qbittorrent/qBittorrent"target="_blank" rel="noopener">qBittorrent</a>, <a href="https://github.com/sabnzbd/sabnzbd"target="_blank" rel="noopener">SABnzbd</a>, <a href="https://github.com/Jackett/Jackett"target="_blank" rel="noopener">Jackett</a>, <a href="https://github.com/FlareSolverr/FlareSolverr"target="_blank" rel="noopener">FlareSolverr</a>, <a href="https://github.com/seerr/seerr"target="_blank" rel="noopener">Seerr</a>). The personal projects (<a href="https://github.com/akitaonrails/frank_fbi"target="_blank" rel="noopener">Frank FBI</a>, <a href="/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/">Frank Mega</a>, <a href="https://github.com/akitaonrails/FrankYomik"target="_blank" rel="noopener">Frank Yomik</a>, Mila) add another 11. Monitoring with <a href="https://github.com/grafana/grafana"target="_blank" rel="noopener">Grafana</a>, <a href="https://github.com/prometheus/prometheus"target="_blank" rel="noopener">Prometheus</a>, node-exporter and <a href="https://github.com/google/cadvisor"target="_blank" rel="noopener">cAdvisor</a>. Utilities like <a href="https://github.com/portainer/portainer"target="_blank" rel="noopener">Portainer</a>, <a href="https://github.com/dani-garcia/vaultwarden"target="_blank" rel="noopener">Vaultwarden</a>, <a href="https://github.com/syncthing/syncthing"target="_blank" rel="noopener">Syncthing</a>, <a href="https://github.com/causefx/Organizr"target="_blank" rel="noopener">Organizr</a>, <a href="https://github.com/containrrr/watchtower"target="_blank" rel="noopener">Watchtower</a>. <a href="https://github.com/go-gitea/gitea"target="_blank" rel="noopener">Gitea</a> as private Docker registry. <a href="https://github.com/immich-app/immich"target="_blank" rel="noopener">Immich</a> as self-hosted Google Photos. <a href="https://github.com/oae/kaizoku"target="_blank" rel="noopener">Kaizoku</a> for manga with <a href="https://github.com/Kareadita/Kavita"target="_blank" rel="noopener">Kavita</a> as the reader. <a href="https://github.com/ollama/ollama"target="_blank" rel="noopener">Ollama</a> with ROCm. And <a href="https://github.com/bitcoin/bitcoin"target="_blank" rel="noopener">Bitcoin Core</a>/<a href="https://github.com/cculianu/Fulcrum"target="_blank" rel="noopener">Fulcrum</a> indexing the blockchain off the NAS.</p>
<h2>Backups: two layers<span class="hx:absolute hx:-mt-20" id="backups-two-layers"></span>
    <a href="#backups-two-layers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>Layer 1: local btrfs snapshots (snapper)<span class="hx:absolute hx:-mt-20" id="layer-1-local-btrfs-snapshots-snapper"></span>
    <a href="#layer-1-local-btrfs-snapshots-snapper" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><code>/var</code> lives on a 3.7TB btrfs partition. snapper creates automatic snapshots: 7 daily + 1 weekly. They&rsquo;re crash-consistent, not application-consistent (postgres can be slightly inconsistent if there&rsquo;s heavy writing during the snapshot).</p>
<p>To recover an accidentally deleted file:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo snapper -c var list
</span></span><span class="line"><span class="cl">sudo cp /var/.snapshots/5/snapshot/opt/docker/media/radarr/appdata/config/radarr.db <span class="se">\
</span></span></span><span class="line"><span class="cl">        /var/opt/docker/media/radarr/appdata/config/radarr.db</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For a full stack rollback:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo docker compose -p media down
</span></span><span class="line"><span class="cl">sudo snapper -c var undochange 7..0 /var/opt/docker/media
</span></span><span class="line"><span class="cl">sudo docker compose -p media up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Layer 2: restic to the NAS (off-machine)<span class="hx:absolute hx:-mt-20" id="layer-2-restic-to-the-nas-off-machine"></span>
    <a href="#layer-2-restic-to-the-nas-off-machine" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><a href="https://github.com/restic/restic"target="_blank" rel="noopener">restic</a> runs every night at 3am, doing incremental backups to <code>/var/mnt/terachad/homelab-backups/</code>. Retention: 7 daily + 4 weekly. Content-based deduplication, so Plex config (19GB) and Gitea repos (12GB) only transfer deltas.</p>
<p>Before restic runs, a <code>pg_dump</code> exports the postgres databases (Immich, Kaizoku). The dumps go to <code>/tmp/homelab-db-dumps/</code> and are included in the backup.</p>
<p>What&rsquo;s NOT included in the backup (re-downloadable): Bitcoin blockchain (785GB on the NAS), Docker images (re-pullable), Ollama models (re-downloadable), HuggingFace/EasyOCR caches, Plex transcoding scratch.</p>
<p>Large re-downloadable directories were converted to btrfs subvolumes so snapper ignores them: <code>/var/lib/ollama</code> and <code>/var/opt/docker/bitcoin/fulcrum/fulc2_db</code>.</p>
<h2>Performance tuning<span class="hx:absolute hx:-mt-20" id="performance-tuning"></span>
    <a href="#performance-tuning" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><h3>btrfs with zstd compression<span class="hx:absolute hx:-mt-20" id="btrfs-with-zstd-compression"></span>
    <a href="#btrfs-with-zstd-compression" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Added <code>compress=zstd:1</code> to the fstab for the <code>/var</code> partition. zstd level 1 has near-zero CPU overhead on NVMe and compresses Docker metadata, JSON configs and logs nicely. Incompressible data (SQLite, postgres) is automatically skipped by btrfs.</p>
<h3>zram swap<span class="hx:absolute hx:-mt-20" id="zram-swap"></span>
    <a href="#zram-swap" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>With ~30GB of RAM available for the system (96GB go to VRAM), in-memory compressed swap helps. zram creates a ~15GB swap device (ram/2) with zstd compression, much faster than disk swap.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/zram-generator.conf</span>
</span></span><span class="line"><span class="cl"><span class="k">[zram0]</span>
</span></span><span class="line"><span class="cl"><span class="na">zram-size</span> <span class="o">=</span> <span class="s">ram / 2</span>
</span></span><span class="line"><span class="cl"><span class="na">compression-algorithm</span> <span class="o">=</span> <span class="s">zstd</span>
</span></span><span class="line"><span class="cl"><span class="na">swap-priority</span> <span class="o">=</span> <span class="s">100</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>btrfs nodatacow on database directories<span class="hx:absolute hx:-mt-20" id="btrfs-nodatacow-on-database-directories"></span>
    <a href="#btrfs-nodatacow-on-database-directories" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Copy-on-write + random database writes = write amplification. I disabled CoW on the directories that hold SQLite and postgres:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo chattr +C /var/opt/docker/gitea/data/gitea.db
</span></span><span class="line"><span class="cl">sudo chattr +C /var/opt/docker/immich/db/
</span></span><span class="line"><span class="cl">sudo chattr +C /var/opt/docker/media/radarr/appdata/config/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>CPU in performance mode<span class="hx:absolute hx:-mt-20" id="cpu-in-performance-mode"></span>
    <a href="#cpu-in-performance-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>On a headless server, there&rsquo;s no point saving energy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> performance <span class="p">|</span> sudo tee /sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Persisted via systemd service <code>cpu-epp.service</code>.</p>
<h3>Docker shutdown fix<span class="hx:absolute hx:-mt-20" id="docker-shutdown-fix"></span>
    <a href="#docker-shutdown-fix" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>A problem I discovered: Docker ships with <code>KillMode=process</code>, which means at system shutdown, systemd kills only dockerd and leaves all the <code>containerd-shim</code> processes (one per container, ~49 in my case) orphaned. systemd-shutdown then has to hunt them down one by one after the journal has already stopped, causing a silent multi-minute hang.</p>
<p>Fix:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/docker.service.d/shutdown.conf</span>
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">KillMode</span><span class="o">=</span><span class="s">control-group</span>
</span></span><span class="line"><span class="cl"><span class="na">TimeoutStopSec</span><span class="o">=</span><span class="s">30</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>The problems we ran into<span class="hx:absolute hx:-mt-20" id="the-problems-we-ran-into"></span>
    <a href="#the-problems-we-ran-into" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is the table of actual problems we hit during the migration. If you&rsquo;re planning something similar, read it before starting:</p>
<table>
  <thead>
      <tr>
          <th>Problem</th>
          <th>Cause</th>
          <th>Fix</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ROCm only sees 15.5GB of VRAM</td>
          <td>Kernel TTM caps pages even with BIOS at 96GB</td>
          <td>Add <code>amdttm.pages_limit=25165824</code> to kernel cmdline</td>
      </tr>
      <tr>
          <td>Every container: permission denied on volumes</td>
          <td>SELinux <code>container_t</code> can&rsquo;t write to unlabeled paths</td>
          <td><code>security_opt: label:disable</code> on every service</td>
      </tr>
      <tr>
          <td>NFS with <code>:Z</code> silently fails</td>
          <td>NFS doesn&rsquo;t support SELinux xattrs</td>
          <td>Never use <code>:Z</code> on NFS paths</td>
      </tr>
      <tr>
          <td>SQLite breaks with <code>:Z</code></td>
          <td>Relabeling changes the context, WAL mode fails</td>
          <td>Drop <code>:Z</code>, use <code>label:disable</code></td>
      </tr>
      <tr>
          <td>Radarr/Sonarr showed the setup screen</td>
          <td>Backup at <code>appdata/config/</code> but compose mounted <code>appdata/</code></td>
          <td>Fix to: <code>appdata/config:/config</code></td>
      </tr>
      <tr>
          <td>Grafana lost dashboards</td>
          <td>Named volume not included in filesystem backup</td>
          <td>Explicit named volume backup</td>
      </tr>
      <tr>
          <td>Plex can&rsquo;t find media</td>
          <td>Internal path changed from <code>/media</code> to <code>/data</code></td>
          <td>Restore the original path in compose</td>
      </tr>
      <tr>
          <td>Seerr can&rsquo;t connect to Plex</td>
          <td>macvlan isolated from the bridge network</td>
          <td>Add <code>default: {}</code> to Plex networks</td>
      </tr>
      <tr>
          <td>Fulcrum crash: &ldquo;option -b missing&rdquo;</td>
          <td>Env vars not supported by the image</td>
          <td>Use CLI flags in <code>command:</code></td>
      </tr>
      <tr>
          <td>bitcoind rejects RPC</td>
          <td>Binds on <code>::1</code> by default</td>
          <td>Add <code>-rpcbind=0.0.0.0 -rpcallowip=172.0.0.0/8</code></td>
      </tr>
      <tr>
          <td>sdbootutil warning in transactional shell</td>
          <td>Has to run outside the transaction</td>
          <td>Run <code>sdbootutil update-all-entries</code> in the normal shell</td>
      </tr>
      <tr>
          <td>Watchtower permission denied on docker.sock</td>
          <td>SELinux blocks socket access</td>
          <td><code>label:disable</code></td>
      </tr>
      <tr>
          <td>Gitea SSH crash</td>
          <td>Conflict: entrypoint sshd on port 22 + app on port 22</td>
          <td><code>GITEA__server__SSH_LISTEN_PORT=2222</code></td>
      </tr>
      <tr>
          <td>docker-compose not installed with Docker</td>
          <td>The openSUSE package only installs the daemon</td>
          <td>Install standalone binary manually</td>
      </tr>
  </tbody>
</table>
<h2>What to tell Claude Code before you start<span class="hx:absolute hx:-mt-20" id="what-to-tell-claude-code-before-you-start"></span>
    <a href="#what-to-tell-claude-code-before-you-start" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If I were redoing the migration from scratch, I&rsquo;d give Claude Code these instructions on the very first message. In order of importance:</p>
<p>Tell it that SELinux is enforcing and that it should NEVER use <code>:Z</code> on any Docker volume, but rather <code>security_opt: label:disable</code> on every service. Tell it that <code>/var/mnt/terachad/</code> is an NFS mount and that <code>:Z</code> should never appear on NFS paths. Tell it to always look at the original compose before rewriting and only change IPs, paths and container names, without inventing new volume layouts. Warn that named volumes need explicit backup (Grafana, Portainer). Explain that Plex runs on macvlan and needs <code>default: {}</code> in the networks. Inform that the GPU is gfx1151, not officially supported, and that it needs UMA 96GB in the BIOS + kernel TTM params + <code>HSA_OVERRIDE_GFX_VERSION=11.5.1</code>. And tell it that Bitcoin/Fulcrum don&rsquo;t process environment variables, everything goes as an argument in <code>command:</code>.</p>
<p>Those instructions would have prevented 80% of the problems we hit.</p>
<h2>Final server layout<span class="hx:absolute hx:-mt-20" id="final-server-layout"></span>
    <a href="#final-server-layout" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/var/opt/docker/
├── bitcoin/          (bitcoind &#43; fulcrum)
├── cloudflared/      (Cloudflare tunnel)
├── frank_fbi/        (email fraud analysis)
├── frank_mega/       (Mega clone)
├── frank_yomik/      (manga translation)
├── gitea/            (Docker registry)
├── immich/           (self-hosted Google Photos)
├── kaizoku/          (manga downloader &#43; reader)
├── media/            (Plex &#43; *arr stack)
├── mila/             (Discord bot)
├── monitor/          (Grafana &#43; Prometheus)
├── ollama/           (local LLM with ROCm)
├── rip/              (HandBrake)
└── utils/            (Portainer, Vaultwarden, Syncthing, etc.)

/var/mnt/terachad/    (Synology NFS)
├── Bitcoin/data/     (blockchain, 785GB)
├── Downloads/        (torrents &#43; nzbget)
├── Videos/           (Radarr movies &#43; Sonarr series)
└── Ollama/models/    (model overflow if local disk fills up)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h2>A warning about using AI to administer servers<span class="hx:absolute hx:-mt-20" id="a-warning-about-using-ai-to-administer-servers"></span>
    <a href="#a-warning-about-using-ai-to-administer-servers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I used Claude Code to speed up the migration. It created compose files, wrote backup scripts, configured the firewall, diagnosed SELinux problems. It worked well for my case: home server, only I use it, and I was reviewing every step.</p>
<p>But there are traps. Claude doesn&rsquo;t know that <code>:Z</code> breaks SQLite unless you tell it. It doesn&rsquo;t know that Fulcrum doesn&rsquo;t accept env vars unless it&rsquo;s already seen the Dockerfile. It will invent &ldquo;better&rdquo; volume layouts that break Plex because Plex stores absolute paths in its database.</p>
<p>If it were real production: don&rsquo;t do this without review. Every compose file Claude generates, read it in full before applying. Every destructive command (rollback, delete, recreate), confirm manually. And have tested backups before you start. Claude is great for generating the first version and diagnosing errors, but the architecture decisions and the safety validations are yours.</p>
<p>The previous home server posts that may give additional context:</p>
<ul>
<li><a href="/en/2024/04/03/my-personal-netflix-with-docker-compose/">My &ldquo;Personal Netflix&rdquo; with Docker Compose</a></li>
<li><a href="/en/2025/09/09/accessing-my-home-server-with-a-real-domain/">Accessing my Home Server with a real domain</a></li>
<li><a href="/en/2025/09/10/protecting-your-home-server-with-cloudflare-zero-trust/">Protecting your Home Server with Cloudflare Zero Trust</a></li>
<li><a href="/en/2025/09/10/installing-grafana-on-my-home-server/">Installing Grafana on my Home Server</a></li>
<li><a href="/en/2025/09/10/omarchy-2-0-bitwarden-self-hosted-vaultwarden/">Self-hosted Vaultwarden</a></li>
</ul>
]]></content:encoded><category>homelab</category><category>containers</category><category>devops</category><category>security</category></item><item><title>Vibe Code: I Built a Mega Clone in Rails in 1 Day for My Home Server</title><link>https://akitaonrails.github.io/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/02/21/vibe-code-built-a-mega-clone-in-rails-in-1-day-frankmega/</guid><pubDate>Sat, 21 Feb 2026 18:48:25 +0000</pubDate><description>&lt;p&gt;I&amp;rsquo;ve wanted a private file sharing service on my home server for a long time. You know when you need to send someone a big file? &amp;ldquo;Ah, use Google Drive.&amp;rdquo; No. &amp;ldquo;WeTransfer?&amp;rdquo; Also no. I want full control. I want to know where my files are, how long they exist for, who downloaded them, and I don&amp;rsquo;t want to depend on a third-party service for something this simple.&lt;/p&gt;
&lt;p&gt;So I did what any programmer would do: sat down, wrote a spec document, and with the help of Claude Code (Claude Opus 4.6) I built &lt;a href="https://github.com/akitaonrails/FrankMega"target="_blank" rel="noopener"&gt;&lt;strong&gt;FrankMega&lt;/strong&gt;&lt;/a&gt;, a simplified, self-hosted Mega clone, in Rails 8, in a single day of work. 21 commits, 3 hours of active development, 210 tests, zero external dependencies beyond SQLite.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I&rsquo;ve wanted a private file sharing service on my home server for a long time. You know when you need to send someone a big file? &ldquo;Ah, use Google Drive.&rdquo; No. &ldquo;WeTransfer?&rdquo; Also no. I want full control. I want to know where my files are, how long they exist for, who downloaded them, and I don&rsquo;t want to depend on a third-party service for something this simple.</p>
<p>So I did what any programmer would do: sat down, wrote a spec document, and with the help of Claude Code (Claude Opus 4.6) I built <a href="https://github.com/akitaonrails/FrankMega"target="_blank" rel="noopener"><strong>FrankMega</strong></a>, a simplified, self-hosted Mega clone, in Rails 8, in a single day of work. 21 commits, 3 hours of active development, 210 tests, zero external dependencies beyond SQLite.</p>
<p><img src="https://github.com/akitaonrails/FrankMega/raw/master/docs/upload_screen.png" alt="frankmega"  loading="lazy" /></p>
<p>And before the smart guy shows up in the comments with &ldquo;ah, but Vibe Coding is just prompt engineering, anyone can do it&rdquo; &ndash; hold on. This post exists exactly to show that <strong>it isn&rsquo;t</strong>.</p>
<h2>From Idea to Code: the IDEA.md<span class="hx:absolute hx:-mt-20" id="from-idea-to-code-the-ideamd"></span>
    <a href="#from-idea-to-code-the-ideamd" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Every project starts with a document. Mine was <a href="https://github.com/akitaonrails/FrankMega/blob/master/docs/IDEA.md"target="_blank" rel="noopener"><code>docs/IDEA.md</code></a> &ndash; 56 lines describing what I wanted:</p>
<ul>
<li>Single file upload, with a shareable link</li>
<li>Download counter (default 5, configurable)</li>
<li>Automatic expiration within 24 hours</li>
<li>Full authentication: password + 2FA + passkeys</li>
<li>No public registration page &ndash; invites only</li>
<li>Admin creates invites, manages users, bans abusers</li>
<li>Aggressive rate limiting, automatic IP banning</li>
<li>Deploy via Docker + Cloudflare Tunnel on my home server</li>
<li>Stack: Rails 8, SQLite, Tailwind CSS, Hotwire/Stimulus</li>
</ul>
<p>I deliberately added at the end: <em>&ldquo;Suggest important features that may be relevant for a service like this.&rdquo;</em> Because I know a spec document never covers everything. And that&rsquo;s where the interesting part begins.</p>
<h2>The Big Bang: Commit #1<span class="hx:absolute hx:-mt-20" id="the-big-bang-commit-1"></span>
    <a href="#the-big-bang-commit-1" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first commit (<code>e277226</code>) at 12:17 delivered <strong>207 files, 6,901 lines of code</strong>. In a single commit. Yes, I know it&rsquo;s controversial, but when you&rsquo;re building from scratch with AI, the first commit is necessarily huge.</p>
<p>What came in that initial commit:</p>
<ul>
<li>Full authentication with <code>has_secure_password</code> (bcrypt), TOTP via <code>rotp</code>, passkeys via <code>webauthn</code></li>
<li>Invitation system with unique codes and expiration</li>
<li>Full admin panel (Users, Invitations, Files, MIME Types)</li>
<li>Upload with Active Storage + drag-and-drop via Stimulus</li>
<li>Download links with 24-byte hashes (<code>SecureRandom.urlsafe_base64(24)</code>)</li>
<li>Rate limiting via Rack::Attack on every public endpoint</li>
<li>Automatic IP banning with a <code>Ban</code> model and async job</li>
<li>Dark/light theme with Tailwind CSS inspired by Mega.nz</li>
<li>73 tests (Minitest + FactoryBot)</li>
<li>Docker configuration with Puma + Thruster</li>
<li>Solid Queue/Cache/Cable &ndash; zero Redis</li>
</ul>
<p>Got it? <strong>All of that in commit #1</strong>. The IDEA.md became functional code in a single shot. But here&rsquo;s the point: <strong>the first commit was not production-ready</strong>. Not even close.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-32-29.png" alt="tema light"  loading="lazy" /></p>
<h2>The Reality: 20 Iteration Commits<span class="hx:absolute hx:-mt-20" id="the-reality-20-iteration-commits"></span>
    <a href="#the-reality-20-iteration-commits" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>After the Big Bang came 20 commits over 3 hours. And this is where the truth about software development lives, the one no &ldquo;Vibe Coding&rdquo; tutorial will tell you.</p>
<h3>Phase 1: &ldquo;Works on my machine&rdquo; (12:40 - 12:46)<span class="hx:absolute hx:-mt-20" id="phase-1-works-on-my-machine-1240---1246"></span>
    <a href="#phase-1-works-on-my-machine-1240---1246" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Four commits in 6 minutes. You know what happened? I went to build the Docker image and <strong>it broke</strong>. Missing <code>libssl-dev</code>. Fixed it. Then the CSP (Content Security Policy) from <code>secure_headers</code> clashed with the inline scripts from Rails&rsquo; importmap. Fixed it. Changed the port from 3000 to 3100 to avoid colliding with other services on my server.</p>
<p>Look at commit <code>389ebe8</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># CSP is now handled by the Rails built-in, not by secure_headers</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Because secure_headers doesn&#39;t support nonces for importmap inline scripts</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">csp</span> <span class="o">=</span> <span class="no">SecureHeaders</span><span class="o">::</span><span class="no">OPT_OUT</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This kind of gem incompatibility you only discover in the moment. No LLM will warn you about this up front because it&rsquo;s a specific combination of versions.</p>
<h3>Phase 2: The Security Commit &ndash; 22 Issues Fixed (13:10)<span class="hx:absolute hx:-mt-20" id="phase-2-the-security-commit--22-issues-fixed-1310"></span>
    <a href="#phase-2-the-security-commit--22-issues-fixed-1310" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the most important commit of the entire project: <code>4a854a6</code>. 35 files, 612 lines, 22 security issues fixed. I&rsquo;ll go into detail because this is where toy code separates from production code.</p>
<p>What the initial commit got wrong:</p>
<p><strong>CRITICAL &ndash; Encryption keys with hardcoded fallbacks:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE (dangerous):</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">active_record</span><span class="o">.</span><span class="n">encryption</span><span class="o">.</span><span class="n">primary_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="s2">&#34;KEY&#34;</span><span class="p">,</span> <span class="s2">&#34;test-primary-key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER (fail-fast):</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="no">Rails</span><span class="o">.</span><span class="n">env</span><span class="o">.</span><span class="n">production?</span>
</span></span><span class="line"><span class="cl">  <span class="n">config</span><span class="o">.</span><span class="n">active_record</span><span class="o">.</span><span class="n">encryption</span><span class="o">.</span><span class="n">primary_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="s2">&#34;ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If the ENV var didn&rsquo;t exist in production, the app would happily run with the hardcoded key. Anyone reading the source code could decrypt every user&rsquo;s OTP secrets. Now in production, if the variable is missing, the app <strong>crashes on boot</strong>. That&rsquo;s the correct behavior.</p>
<p><strong>CRITICAL &ndash; Replay Attack on OTP:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">verify_otp</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">totp</span> <span class="o">=</span> <span class="no">ROTP</span><span class="o">::</span><span class="no">TOTP</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">otp_secret</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">totp</span><span class="o">.</span><span class="n">verify</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="ss">drift_behind</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span> <span class="ss">drift_ahead</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span><span class="o">.</span><span class="n">present?</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">verify_otp</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">totp</span> <span class="o">=</span> <span class="no">ROTP</span><span class="o">::</span><span class="no">TOTP</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">otp_secret</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">timestamp</span> <span class="o">=</span> <span class="n">totp</span><span class="o">.</span><span class="n">verify</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="ss">drift_behind</span><span class="p">:</span> <span class="mi">30</span><span class="p">,</span> <span class="ss">drift_ahead</span><span class="p">:</span> <span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kp">false</span> <span class="k">unless</span> <span class="n">timestamp</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># Prevent replay: reject if this code&#39;s timestamp was already used</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">last_otp_at</span><span class="o">.</span><span class="n">present?</span> <span class="o">&amp;&amp;</span> <span class="no">Time</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="n">timestamp</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="n">last_otp_at</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">update_column</span><span class="p">(</span><span class="ss">:last_otp_at</span><span class="p">,</span> <span class="no">Time</span><span class="o">.</span><span class="n">at</span><span class="p">(</span><span class="n">timestamp</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="kp">true</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Every TOTP tutorial online shows <code>verify(code).present?</code> and stops there. The problem: a TOTP code is valid for 30 seconds. If I intercept your code (shoulder surfing, camera, clipboard), I can use it multiple times in that window. <code>last_otp_at</code> guarantees real one-time use.</p>
<p><strong>HIGH &ndash; Race Condition on the Download Counter:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE (race condition):</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">increment_download!</span>
</span></span><span class="line"><span class="cl">  <span class="n">increment!</span><span class="p">(</span><span class="ss">:download_count</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER (atomic):</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">increment_download!</span>
</span></span><span class="line"><span class="cl">  <span class="nb">self</span><span class="o">.</span><span class="n">class</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="nb">id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="s2">&#34;download_count &lt; max_downloads&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="s2">&#34;expires_at &gt; ?&#34;</span><span class="p">,</span> <span class="no">Time</span><span class="o">.</span><span class="n">current</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">update_all</span><span class="p">(</span><span class="s2">&#34;download_count = download_count + 1&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>increment!</code> does: (1) read the value, (2) add 1 in Ruby, (3) write it back. Two simultaneous requests can both read <code>4</code>, both write <code>5</code>, and the user gets two downloads when they should only have had one. The atomic <code>UPDATE ... WHERE</code> in SQL guarantees only one request wins. And the <code>== 1</code> returns success/failure in a single operation, with no lock.</p>
<p>This is the classic TOCTOU bug (Time of Check, Time of Use). You&rsquo;ll never see it in local development with one browser. It only shows up in production with concurrent requests.</p>
<p><strong>HIGH &ndash; Open Redirect after login:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># AFTER:</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">safe_redirect_url?</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">uri</span> <span class="o">=</span> <span class="no">URI</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">uri</span><span class="o">.</span><span class="n">host</span><span class="o">.</span><span class="n">nil?</span> <span class="o">||</span> <span class="n">uri</span><span class="o">.</span><span class="n">host</span> <span class="o">==</span> <span class="n">request</span><span class="o">.</span><span class="n">host</span>
</span></span><span class="line"><span class="cl"><span class="k">rescue</span> <span class="no">URI</span><span class="o">::</span><span class="no">InvalidURIError</span>
</span></span><span class="line"><span class="cl">  <span class="kp">false</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Without this, an attacker could send a link like <code>frankmega.com/session?return_to=https://evil.com</code> and after login the user would be redirected to the malicious site.</p>
<p><strong>MEDIUM &ndash; Predictable CSP nonce:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># BEFORE:</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">content_security_policy_nonce_generator</span> <span class="o">=</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">request</span><span class="p">)</span> <span class="p">{</span> <span class="n">request</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">id</span><span class="o">.</span><span class="n">to_s</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># AFTER:</span>
</span></span><span class="line"><span class="cl"><span class="n">config</span><span class="o">.</span><span class="n">content_security_policy_nonce_generator</span> <span class="o">=</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">_request</span><span class="p">)</span> <span class="p">{</span> <span class="no">SecureRandom</span><span class="o">.</span><span class="n">base64</span><span class="p">(</span><span class="mi">16</span><span class="p">)</span> <span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Session ID doesn&rsquo;t change between requests. If the nonce is predictable, an attacker can inject scripts with the correct nonce. It has to be random per request.</p>
<p>And more: Cloudflare IPv6 added to trusted proxies, minimum 12 characters for passwords, server-side MIME detection via Marcel, file size verified from the tempfile (not from the client header that can be forged), protection against deleting/banning/demoting the last admin.</p>
<p>The test count jumped from 73 to 109 with this single commit.</p>
<h2>How Rails Makes Security Easier<span class="hx:absolute hx:-mt-20" id="how-rails-makes-security-easier"></span>
    <a href="#how-rails-makes-security-easier" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Rails makes implementing security MUCH easier than doing it from scratch. Look at the comparison:</p>
<table>
  <thead>
      <tr>
          <th>Feature</th>
          <th>Rails</th>
          <th>From scratch</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CSRF</td>
          <td>Automatic in <code>ActionController::Base</code></td>
          <td>Implement per-session tokens in every form</td>
      </tr>
      <tr>
          <td>Password hash</td>
          <td><code>has_secure_password</code> (1 line)</td>
          <td>Pick algorithm, salt, implement verification</td>
      </tr>
      <tr>
          <td>Filtered parameters</td>
          <td><code>config.filter_parameters</code></td>
          <td>Hook into the logging system</td>
      </tr>
      <tr>
          <td>Strong params</td>
          <td><code>params.require().permit()</code></td>
          <td>Manual whitelist middleware</td>
      </tr>
      <tr>
          <td>Field encryption</td>
          <td><code>encrypts :otp_secret</code> (1 line)</td>
          <td>Envelope encryption, key rotation, transparent decrypt</td>
      </tr>
      <tr>
          <td>CSP with nonces</td>
          <td>1-block config</td>
          <td>Generate nonces, inject into HTML tags, set headers</td>
      </tr>
      <tr>
          <td>Rate limiting (Rails 8)</td>
          <td><code>rate_limit to: 10, within: 3.minutes</code></td>
          <td>Counter storage, sliding window logic</td>
      </tr>
      <tr>
          <td>SQL injection</td>
          <td>Parameterized queries by default</td>
          <td>Parameterize every query manually</td>
      </tr>
  </tbody>
</table>
<p>Rails 8 in particular brought <code>rate_limit</code> built into the controller:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">SessionsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
</span></span><span class="line"><span class="cl">  <span class="n">rate_limit</span> <span class="ss">to</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span> <span class="ss">within</span><span class="p">:</span> <span class="mi">3</span><span class="o">.</span><span class="n">minutes</span><span class="p">,</span> <span class="ss">only</span><span class="p">:</span> <span class="ss">:create</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">with</span><span class="p">:</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">redirect_to</span> <span class="n">new_session_path</span><span class="p">,</span> <span class="ss">alert</span><span class="p">:</span> <span class="n">t</span><span class="p">(</span><span class="s2">&#34;flash.sessions.create.rate_limit&#34;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And authentication? Rails 8 now ships with a built-in <strong>authentication generator</strong>. You no longer need Devise for basic stuff. FrankMega&rsquo;s <code>app/controllers/concerns/authentication.rb</code> is based on that and extends it with 2FA, passkeys and invitation-only registration.</p>
<h2>File Sharing Is More Than Downloads<span class="hx:absolute hx:-mt-20" id="file-sharing-is-more-than-downloads"></span>
    <a href="#file-sharing-is-more-than-downloads" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One thing lots of people don&rsquo;t realize: a file sharing service has a huge attack surface. It goes way beyond &ldquo;save file, generate link&rdquo;. Look at everything you need to consider:</p>
<h3>Filename Sanitization<span class="hx:absolute hx:-mt-20" id="filename-sanitization"></span>
    <a href="#filename-sanitization" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">sanitize_filename</span><span class="p">(</span><span class="nb">name</span><span class="p">,</span> <span class="n">content_type</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="no">File</span><span class="o">.</span><span class="n">basename</span><span class="p">(</span><span class="nb">name</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span>                          <span class="c1"># Strip path traversal</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="s2">&#34;UTF-8&#34;</span><span class="p">,</span> <span class="ss">invalid</span><span class="p">:</span> <span class="ss">:replace</span><span class="p">,</span>      <span class="c1"># Handle invalid UTF-8</span>
</span></span><span class="line"><span class="cl">                                <span class="k">undef</span><span class="p">:</span> <span class="ss">:replace</span><span class="p">,</span> <span class="ss">replace</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/[\x00-\x1f\x7f\/\\:*?&#34;&lt;&gt;|]/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span> <span class="c1"># Control chars + unsafe chars</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">/\A\.+/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>                        <span class="c1"># Leading dots (hidden files)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">,</span> <span class="s2">&#34; &#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span>                  <span class="c1"># Collapse whitespace</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># Windows reserved device names</span>
</span></span><span class="line"><span class="cl">  <span class="n">base_without_ext</span> <span class="o">=</span> <span class="n">sanitized</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sr">/\.[^.]*\z/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">base_without_ext</span><span class="o">.</span><span class="n">match?</span><span class="p">(</span><span class="sr">/\A(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])\z/i</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">sanitized</span> <span class="o">=</span> <span class="s2">&#34;_</span><span class="si">#{</span><span class="n">sanitized</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">strip_extension_junk</span><span class="p">(</span><span class="n">sanitized</span><span class="p">,</span> <span class="n">content_type</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span> <span class="o">=</span> <span class="n">truncate_filename</span><span class="p">(</span><span class="n">sanitized</span><span class="p">,</span> <span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">sanitized</span><span class="o">.</span><span class="n">presence</span> <span class="o">||</span> <span class="s2">&#34;unnamed_file&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Every line exists for a reason:</p>
<ul>
<li><code>File.basename</code> blocks path traversal (<code>../../etc/passwd</code> becomes <code>passwd</code>)</li>
<li>Stripping control chars blocks null byte injection and terminal escape injection</li>
<li>Windows reserved names (CON, NUL, AUX, LPT1-9) cause problems if storage is accessed from Windows</li>
<li>Truncating to 255 bytes (not characters!) while preserving the extension &ndash; a 4-byte emoji counts as 4 toward the ext4/NTFS limit</li>
<li><code>strip_extension_junk</code> handles names that come from URLs with glued-on parameters: <code>photo.jpg_1280x720+quality=80</code> becomes <code>photo.jpg</code></li>
</ul>
<p>And this is server-side. There&rsquo;s also client-side validation in the Stimulus controller to reject before upload:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="nx">isInvalidFilename</span><span class="p">(</span><span class="nx">name</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="k">new</span> <span class="nx">Blob</span><span class="p">([</span><span class="nx">name</span><span class="p">]).</span><span class="nx">size</span> <span class="o">&gt;</span> <span class="mi">255</span><span class="p">)</span> <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="sr">/[\x00-\x1f\x7f/:*?&#34;&lt;&gt;|\\]/</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">name</span><span class="p">))</span> <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Server-Side MIME Type Detection<span class="hx:absolute hx:-mt-20" id="server-side-mime-type-detection"></span>
    <a href="#server-side-mime-type-detection" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="vi">@shared_file</span><span class="o">.</span><span class="n">content_type</span> <span class="o">=</span> <span class="no">Marcel</span><span class="o">::</span><span class="no">MimeType</span><span class="o">.</span><span class="n">for</span><span class="p">(</span><span class="n">uploaded</span><span class="o">.</span><span class="n">tempfile</span><span class="p">,</span> <span class="nb">name</span><span class="p">:</span> <span class="n">uploaded</span><span class="o">.</span><span class="n">original_filename</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="vi">@shared_file</span><span class="o">.</span><span class="n">file_size</span> <span class="o">=</span> <span class="n">uploaded</span><span class="o">.</span><span class="n">tempfile</span><span class="o">.</span><span class="n">size</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Don&rsquo;t trust the <code>Content-Type</code> the browser sends. Marcel inspects the file&rsquo;s magic bytes to determine the real type. And the file size comes from the tempfile on disk, not from the <code>Content-Length</code> header which can be forged.</p>
<h3>Quotas and Disk Usage<span class="hx:absolute hx:-mt-20" id="quotas-and-disk-usage"></span>
    <a href="#quotas-and-disk-usage" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_16-00-52.png" alt="quota"  loading="lazy" /></p>
<p>Each user has a disk quota (5 GB default, admin can customize):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">can_upload?</span><span class="p">(</span><span class="n">file_size</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">grace</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">x</span><span class="o">.</span><span class="n">security</span><span class="o">.</span><span class="n">disk_quota_grace_bytes</span>
</span></span><span class="line"><span class="cl">  <span class="p">(</span><span class="n">storage_used</span> <span class="o">+</span> <span class="n">file_size</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="p">(</span><span class="n">disk_quota</span> <span class="o">+</span> <span class="n">grace</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Without a quota, a single malicious user fills up the server disk. With the 10% grace buffer, an upload that slightly exceeds the limit is accepted so the user isn&rsquo;t frustrated in the edge case.</p>
<h3>Automatic Cleanup<span class="hx:absolute hx:-mt-20" id="automatic-cleanup"></span>
    <a href="#automatic-cleanup" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">CleanupExpiredFilesJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:default</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span>
</span></span><span class="line"><span class="cl">    <span class="no">SharedFile</span><span class="o">.</span><span class="n">inactive</span><span class="o">.</span><span class="n">find_each</span> <span class="k">do</span> <span class="o">|</span><span class="n">shared_file</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">shared_file</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">purge</span> <span class="k">if</span> <span class="n">shared_file</span><span class="o">.</span><span class="n">file</span><span class="o">.</span><span class="n">attached?</span>
</span></span><span class="line"><span class="cl">      <span class="n">shared_file</span><span class="o">.</span><span class="n">destroy</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Runs every 15 minutes via Solid Queue. If nobody downloaded in 24 hours, gone. If it hit the download limit, gone. Without this, the server disk fills up and the service dies.</p>
<h3>Automatic IP Banning<span class="hx:absolute hx:-mt-20" id="automatic-ip-banning"></span>
    <a href="#automatic-ip-banning" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">InvalidHashAccessJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
</span></span><span class="line"><span class="cl">  <span class="n">queue_as</span> <span class="ss">:default</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">ip_address</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">security</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">x</span><span class="o">.</span><span class="n">security</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">unless</span> <span class="n">security</span><span class="o">.</span><span class="n">enable_banning</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">cache_key</span> <span class="o">=</span> <span class="s2">&#34;invalid_hash:</span><span class="si">#{</span><span class="n">ip_address</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">count</span> <span class="o">=</span> <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">increment</span><span class="p">(</span><span class="n">cache_key</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="ss">expires_in</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">hour</span><span class="p">)</span> <span class="o">||</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">count</span> <span class="o">&gt;=</span> <span class="n">security</span><span class="o">.</span><span class="n">max_invalid_hash_attempts</span>
</span></span><span class="line"><span class="cl">      <span class="no">Ban</span><span class="o">.</span><span class="n">ban!</span><span class="p">(</span><span class="n">ip_address</span><span class="p">,</span> <span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;Repeated invalid download hash access&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">               <span class="ss">duration</span><span class="p">:</span> <span class="n">security</span><span class="o">.</span><span class="n">ban_duration</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">delete</span><span class="p">(</span><span class="n">cache_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Tried 3 invalid hashes? Banned for 1 hour. That stops hash enumeration. With 24 bytes of entropy (192 bits, 2^192 possibilities) brute force is computationally infeasible, but the extra protection costs nothing.</p>
<h3>Layered Rate Limiting<span class="hx:absolute hx:-mt-20" id="layered-rate-limiting"></span>
    <a href="#layered-rate-limiting" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Rack::Attack operates at the middleware layer (before Rails processes the request):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># Login: 5 attempts per minute per IP</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;logins/ip&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">minute</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">.</span><span class="n">ip</span> <span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;/session&#34;</span> <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="o">.</span><span class="n">post?</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Login: 5 attempts per minute per email</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;logins/email&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">minute</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span> <span class="o">==</span> <span class="s2">&#34;/session&#34;</span> <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="o">.</span><span class="n">post?</span>
</span></span><span class="line"><span class="cl">    <span class="n">req</span><span class="o">.</span><span class="n">params</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="s2">&#34;email_address&#34;</span><span class="p">)</span><span class="o">&amp;.</span><span class="n">to_s</span><span class="o">&amp;.</span><span class="n">downcase</span><span class="o">&amp;.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Downloads: 60 views and 30 downloads per minute</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;downloads_get/ip&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">60</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">minute</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">.</span><span class="n">ip</span> <span class="k">if</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">start_with?</span><span class="p">(</span><span class="s2">&#34;/d/&#34;</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">req</span><span class="o">.</span><span class="n">get?</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># General: 300 requests per 5 minutes (except assets)</span>
</span></span><span class="line"><span class="cl"><span class="no">Rack</span><span class="o">::</span><span class="no">Attack</span><span class="o">.</span><span class="n">throttle</span><span class="p">(</span><span class="s2">&#34;requests/ip&#34;</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">300</span><span class="p">,</span> <span class="ss">period</span><span class="p">:</span> <span class="mi">5</span><span class="o">.</span><span class="n">minutes</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">req</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">  <span class="n">req</span><span class="o">.</span><span class="n">ip</span> <span class="k">unless</span> <span class="n">req</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">start_with?</span><span class="p">(</span><span class="s2">&#34;/assets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Seven different throttles for specific scenarios. Plus Rails 8&rsquo;s <code>rate_limit</code> in the controllers. Banned IPs never even hit Rails &ndash; they&rsquo;re rejected at the middleware blocklist with status 403.</p>
<p>And the limits are configurable: 1x in production, 10x in development so you don&rsquo;t lock yourself out during testing. Everything centralized in <code>config/initializers/security.rb</code>.</p>
<h2>The Download Saga: 5 Commits in 21 Minutes<span class="hx:absolute hx:-mt-20" id="the-download-saga-5-commits-in-21-minutes"></span>
    <a href="#the-download-saga-5-commits-in-21-minutes" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-31-25.png" alt="download"  loading="lazy" /></p>
<p>This part is fun because it shows real debugging. Between 14:44 and 15:05 I made <strong>5 commits</strong> trying to get downloads working with Turbo Drive:</p>
<ol>
<li><code>cc3c23d</code> &ndash; Added <code>data-turbo=false</code> on the download button. <strong>Didn&rsquo;t work.</strong></li>
<li><code>5224ed2</code> &ndash; Moved <code>data-turbo=false</code> to the <code>&lt;form&gt;</code>. <strong>Didn&rsquo;t work.</strong></li>
<li><code>98b0d4f</code> &ndash; Dropped <code>redirect_to rails_blob_path</code> and used <code>send_file</code> directly. <strong>Partially worked.</strong></li>
<li><code>1fdf5f9</code> &ndash; Switched from POST to GET link. Worked, but&hellip; bots can burn downloads with GET.</li>
<li><code>4c74b27</code> &ndash; Went back to POST. Added URL artifact cleanup in filenames.</li>
</ol>
<p>The root cause: Turbo Drive intercepts navigation aggressively, and downloading a file via <code>redirect_to</code> (which generates a chain of 302 redirects from Active Storage) confuses Turbo. The final fix: <code>send_file</code> straight from disk with <code>disposition: &quot;attachment&quot;</code> via POST with <code>data: { turbo: false }</code> on the form.</p>
<p>This kind of problem an LLM won&rsquo;t solve on its own. You need to test in a real browser, see what happens, try, fail, adjust. It&rsquo;s pure iterative development.</p>
<h2>The Gap Between the Idea and the Final Product<span class="hx:absolute hx:-mt-20" id="the-gap-between-the-idea-and-the-final-product"></span>
    <a href="#the-gap-between-the-idea-and-the-final-product" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Look at what the IDEA.md didn&rsquo;t mention and that emerged during development:</p>
<ol>
<li>Per-user disk quotas &ndash; without these one user fills the server disk</li>
<li>Full I18n (EN + PT-BR) &ndash; 66 files, ~250 strings extracted</li>
<li>Terms of Service with mandatory acceptance</li>
<li>User self-deletion &ndash; privacy requirement</li>
<li>Blocked downloads for banned users &ndash; their links return 410 Gone</li>
<li>Client-side upload validation &ndash; checks size, quota and filename before upload</li>
<li>Inline file previews &ndash; images, video and audio on the download page without consuming downloads</li>
<li>Styled error pages &ndash; instead of plain text &ldquo;Not Found&rdquo;, branded pages with i18n</li>
</ol>
<p>The IDEA.md said &ldquo;use activeadmin or administrate&rdquo;. In practice I built a custom admin panel with Tailwind because <code>administrate</code> had compatibility issues with Rails 8.1. The IDEA.md mentioned &ldquo;progress bar if possible&rdquo;. In practice I used drag-and-drop with a file preview via Stimulus, which is better than a progress bar.</p>
<p>Nobody can predict every feature on day 1. You discover requirements as you build. And each new feature brings edge cases that need handling.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-32-50.png" alt="pt"  loading="lazy" /></p>
<h2>The Numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>At the end of the project:</p>
<ul>
<li>21 commits in a ~3-hour session</li>
<li>210 tests (Minitest + FactoryBot), 513 assertions, zero failures</li>
<li>~3,100 lines of application code (models, controllers, views, JS, CSS, configs)</li>
<li>~1,965 lines of tests (including dedicated security tests)</li>
<li>9 models, 20 controllers, 36 views, 9 Stimulus controllers</li>
<li>24 distinct security measures across 7 layers</li>
<li>CI: SimpleCov, RuboCop (zero offenses), Brakeman (zero warnings), bundler-audit (zero vulnerabilities)</li>
<li>Zero external dependencies beyond SQLite. No Redis, no PostgreSQL, no external queue service</li>
</ul>
<h2>The Invitation-Only Server<span class="hx:absolute hx:-mt-20" id="the-invitation-only-server"></span>
    <a href="#the-invitation-only-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-31-54.png" alt="invite"  loading="lazy" /></p>
<p>FrankMega has no public signup. The flow is:</p>
<ol>
<li>On first run, with no users, the setup screen appears to create the admin</li>
<li>The <code>/setup</code> route only exists while <code>User.count.zero?</code> &ndash; afterward it vanishes entirely:</li>
</ol>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">constraints</span><span class="p">(</span><span class="o">-&gt;</span><span class="p">(</span><span class="n">request</span><span class="p">)</span> <span class="p">{</span> <span class="no">User</span><span class="o">.</span><span class="n">count</span><span class="o">.</span><span class="n">zero?</span> <span class="p">})</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">get</span> <span class="s2">&#34;setup&#34;</span><span class="p">,</span> <span class="ss">to</span><span class="p">:</span> <span class="s2">&#34;setup#new&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="n">post</span> <span class="s2">&#34;setup&#34;</span><span class="p">,</span> <span class="ss">to</span><span class="p">:</span> <span class="s2">&#34;setup#create&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<ol start="3">
<li>The admin creates invites in the admin panel (with expiration)</li>
<li>Each invite generates a unique 16-byte code</li>
<li>Each code can only be used once (<code>with_lock</code> + transaction to prevent a race condition)</li>
<li>After signup, the invite is marked as &ldquo;used&rdquo;</li>
</ol>
<p>This is perfect for a personal/family service. Full control over who gets in.</p>
<h2>Deploy: Docker + Cloudflare Tunnel<span class="hx:absolute hx:-mt-20" id="deploy-docker--cloudflare-tunnel"></span>
    <a href="#deploy-docker--cloudflare-tunnel" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/frankmd/2026/02/screenshot-2026-02-21_15-33-40.png" alt="share"  loading="lazy" /></p>
<p>To wrap up, the deploy tutorial in case you want to run this on your own Docker home server. It&rsquo;s not complicated but there are details that matter.</p>
<h3>The Dockerfile<span class="hx:absolute hx:-mt-20" id="the-dockerfile"></span>
    <a href="#the-dockerfile" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Multi-stage build in 3 stages:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="c"># Stage 1: Base (runtime)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">ruby:3.4.8-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y curl libjemalloc2 libvips sqlite3<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># jemalloc for better memory management</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Stage 2: Build (throwaway)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">base</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">build</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> apt-get install -y build-essential git libssl-dev<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Bundle install + asset precompilation with dummy env vars</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Stage 3: Final</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="c"># Copies gems and app, runs as non-root user (rails:rails, UID 1000)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;./bin/thrust&#34;</span><span class="p">,</span> <span class="s2">&#34;./bin/rails&#34;</span><span class="p">,</span> <span class="s2">&#34;server&#34;</span><span class="p">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>bin/thrust</code> is Thruster (from Basecamp) &ndash; an HTTP proxy in front of Puma that handles gzip/brotli compression, asset caching, X-Sendfile acceleration. It listens on port 80 and proxies to Puma on 3000.</p>
<p>The entrypoint runs <code>db:prepare</code> and <code>db:seed</code> automatically at boot:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash -e
</span></span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="p">@: -2:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;./bin/rails&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="p">@: -1:</span><span class="nv">1</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;server&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  ./bin/rails db:prepare
</span></span><span class="line"><span class="cl">  ./bin/rails db:seed
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="si">${</span><span class="p">@</span><span class="si">}</span><span class="s2">&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>docker-compose.yml<span class="hx:absolute hx:-mt-20" id="docker-composeyml"></span>
    <a href="#docker-composeyml" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Two services:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">akitaonrails/frankmega:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;3100:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/seuuser/frankmega/uploads:/rails/storage/uploads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/seuuser/frankmega/db:/rails/storage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SECRET_KEY_BASE</span><span class="p">:</span><span class="w"> </span><span class="l">${SECRET_KEY_BASE}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HOST</span><span class="p">:</span><span class="w"> </span><span class="l">${HOST}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># ... around 15 more variables</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">tunnel</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/cloudflared:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TUNNEL_TOKEN=${TUNNEL_TOKEN}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">web</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>cloudflare/cloudflared</code> sidecar creates an outbound tunnel to Cloudflare&rsquo;s edge. No need to open ports on your home server&rsquo;s firewall. Cloudflare handles SSL termination, DDoS protection, and routes traffic to the container.</p>
<h3>Step by Step<span class="hx:absolute hx:-mt-20" id="step-by-step"></span>
    <a href="#step-by-step" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p><strong>1. Create the Tunnel on Cloudflare:</strong></p>
<ul>
<li>Cloudflare Zero Trust Dashboard &gt; Networks &gt; Tunnels &gt; Create</li>
<li>Type: Cloudflared</li>
<li>Copy the <code>TUNNEL_TOKEN</code></li>
<li>Configure the hostname: <code>frankmega.yourdomain.com</code> pointing to <code>http://web:80</code></li>
</ul>
<p><strong>2. Generate the secrets:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># SECRET_KEY_BASE</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">64</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Encryption keys (3 separate values)</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span>  <span class="c1"># PRIMARY_KEY</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span>  <span class="c1"># DETERMINISTIC_KEY</span>
</span></span><span class="line"><span class="cl">openssl rand -hex <span class="m">32</span>  <span class="c1"># KEY_DERIVATION_SALT</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>3. Configure <code>.env</code>:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-env" data-lang="env"><span class="line"><span class="cl"><span class="nv">SECRET_KEY_BASE</span><span class="o">=</span>&lt;generated above&gt;
</span></span><span class="line"><span class="cl"><span class="nv">RAILS_MASTER_KEY</span><span class="o">=</span>&lt;from config/master.key&gt;
</span></span><span class="line"><span class="cl"><span class="nv">HOST</span><span class="o">=</span>frankmega.seudominio.com
</span></span><span class="line"><span class="cl"><span class="nv">WEBAUTHN_ORIGIN</span><span class="o">=</span>https://frankmega.seudominio.com
</span></span><span class="line"><span class="cl"><span class="nv">WEBAUTHN_RP_ID</span><span class="o">=</span>frankmega.seudominio.com
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY</span><span class="o">=</span>&lt;generated&gt;
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY</span><span class="o">=</span>&lt;generated&gt;
</span></span><span class="line"><span class="cl"><span class="nv">ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT</span><span class="o">=</span>&lt;generated&gt;
</span></span><span class="line"><span class="cl"><span class="nv">TUNNEL_TOKEN</span><span class="o">=</span>&lt;from Cloudflare&gt;
</span></span><span class="line"><span class="cl"><span class="nv">FORCE_SSL</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP_LOCALE</span><span class="o">=</span>pt-BR</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>4. Bring it up:</strong></p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose pull
</span></span><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>5. Visit</strong> <code>https://frankmega.yourdomain.com</code>, create the admin account, start using it.</p>
<h3>Important Caveats<span class="hx:absolute hx:-mt-20" id="important-caveats"></span>
    <a href="#important-caveats" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><ul>
<li><code>FORCE_SSL=false</code> to test locally at <code>http://localhost:3100</code> without Cloudflare in front, otherwise you hit a redirect loop</li>
<li>The WebAuthn origin has to match exactly, including <code>https://</code>. If you get it wrong, passkeys silently stop working</li>
<li>Cloudflare IPs are hardcoded in the initializer (because the <code>cloudflare-rails</code> gem isn&rsquo;t compatible with Rails 8.1). If Cloudflare changes the ranges, you need to rebuild the image</li>
<li>Encryption keys are permanent. If you swap them after users configure 2FA, the OTP secrets become unreadable and users lose access</li>
<li>Cloudflare free has a 100 MB upload limit. On the free plan, files above that don&rsquo;t make it through the tunnel. The app allows up to 1 GB but Cloudflare is the bottleneck</li>
<li>The container runs as UID 1000. If you use bind mounts instead of named volumes, the directory has to be owned by UID 1000</li>
<li>There are 4 separate SQLite databases in production (app, cache, queue, cable), all in the <code>db_data</code> volume. Backup means copying the entire volume</li>
</ul>
<h2>The Real Point About &ldquo;Vibe Coding&rdquo;<span class="hx:absolute hx:-mt-20" id="the-real-point-about-vibe-coding"></span>
    <a href="#the-real-point-about-vibe-coding" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Claude Code is absurdly productive. I wouldn&rsquo;t have done all of this in 3 hours without it. Remember when you&rsquo;d bang your head against the wall for a week just to get login working? Yeah.</p>
<p>The I18n commit alone (66 files, ~250 strings extracted for two languages) would take a full day by hand.</p>
<p>But here&rsquo;s the point nobody wants to hear: <strong>the LLM did not make the security decisions</strong>. I asked it to run an audit and it found the problems, but I had to know how to ask. And I had to know which questions to ask. <em>&ldquo;Review security&rdquo;</em> is a vague instruction. <em>&ldquo;Check if there&rsquo;s a race condition on the download counter increment&rdquo;</em> is an instruction only someone who knows what TOCTOU is can give.</p>
<p>Those 56 lines of IDEA.md carry decades of web development experience. I knew to ask for rate limiting because I&rsquo;ve seen services taken down by bots. I knew to ask for IP banning because I&rsquo;ve dealt with abuse. I knew to ask for an atomic download counter because I understand concurrency.</p>
<p>If a beginner wrote the same IDEA.md, they probably wouldn&rsquo;t have half of those concerns. And the LLM wouldn&rsquo;t spontaneously suggest them. The result would be a service that&rsquo;s functional, pretty, and completely insecure.</p>
<p>The experienced programmer&rsquo;s value in Vibe Coding isn&rsquo;t writing code. It&rsquo;s knowing what to ask for and reviewing what was generated. Experience is the filter between &ldquo;works&rdquo; and &ldquo;works in production&rdquo;.</p>
<p>Those 210 tests and 24 security measures didn&rsquo;t come from prompt engineering. They came from knowing what to test and why.</p>
<p>The code is open source under AGPL-3.0: <a href="https://github.com/akitaonrails/frankmega"target="_blank" rel="noopener">github.com/akitaonrails/frank_mega</a>. Deploy it on your server, poke around the code, learn from the commits. And if you find more security flaws, let me know.</p>
<p>Remember:</p>
<blockquote>
  <p>AI is the mirror of your own competence.</p>

</blockquote>
]]></content:encoded><category>vibe-coding</category><category>rails</category><category>homelab</category><category>security</category></item><item><title>Omarchy 2.0 - Bitwarden Self-Hosted / VaultWarden</title><link>https://akitaonrails.github.io/en/2025/09/10/omarchy-2-0-bitwarden-self-hosted-vaultwarden/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/09/10/omarchy-2-0-bitwarden-self-hosted-vaultwarden/</guid><pubDate>Wed, 10 Sep 2025 20:00:00 -0300</pubDate><description>&lt;p&gt;Continuing the &lt;a href="https://akitaonrails.github.io/en/tags/omarchy/"&gt;Omarchy series&lt;/a&gt; and doing a cross-over with the &lt;a href="https://akitaonrails.github.io/en/tags/homeserver/"&gt;home server series&lt;/a&gt;, let&amp;rsquo;s talk about Password Managers and 2FA. Anyone who follows me knows I talk often about personal digital security, the importance of paying attention to links so you don&amp;rsquo;t fall for scams, phishing, or accidentally install malware.&lt;/p&gt;
&lt;p&gt;More important: having a strong, random, exclusive and different password for &lt;strong&gt;EACH&lt;/strong&gt; site, app or service you use. Never, ever, reuse a password. Why? Because every day one of these sites gets breached and passwords get leaked. If you search for yourself on &lt;a href="https://haveibeenpwned.com/"target="_blank" rel="noopener"&gt;Have I Been Pwned&lt;/a&gt; you&amp;rsquo;ll surely find accounts of yours that have already leaked. And if you share that same password across 10 other sites, someone has probably already gotten into your account by now.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Continuing the <a href="/en/tags/omarchy/">Omarchy series</a> and doing a cross-over with the <a href="/en/tags/homeserver/">home server series</a>, let&rsquo;s talk about Password Managers and 2FA. Anyone who follows me knows I talk often about personal digital security, the importance of paying attention to links so you don&rsquo;t fall for scams, phishing, or accidentally install malware.</p>
<p>More important: having a strong, random, exclusive and different password for <strong>EACH</strong> site, app or service you use. Never, ever, reuse a password. Why? Because every day one of these sites gets breached and passwords get leaked. If you search for yourself on <a href="https://haveibeenpwned.com/"target="_blank" rel="noopener">Have I Been Pwned</a> you&rsquo;ll surely find accounts of yours that have already leaked. And if you share that same password across 10 other sites, someone has probably already gotten into your account by now.</p>
<p>Watch my video about security:</p>


<div class="embed-container">
  <iframe
    src="https://www.youtube.com/embed/s7ldn31OEFc"
    title="YouTube video player"
    frameborder="0"
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
    referrerpolicy="strict-origin-when-cross-origin"
    allowfullscreen>
  </iframe>
</div>

<p>Being too lazy to use a password manager is just a dumb excuse from lazy people. And a programmer has zero right to use that excuse.</p>
<h3>Omarchy 1Password<span class="hx:absolute hx:-mt-20" id="omarchy-1password"></span>
    <a href="#omarchy-1password" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Anyone who has followed DHH since the early days when Macs were the best platform for developers remembers he was always a fan of 1Password, one of the apps that worked best on iPhones.</p>
<p>I migrated back to Android and Linux many years ago, in 2015. I used <strong>1Password</strong>, but on Android the app was horrendous, ugly, and painful to use. That&rsquo;s why I gave up on it and kept bouncing between several other apps. Google Authenticator, Lastpass (which I don&rsquo;t recommend).</p>
<p>I know there are several other open source ones that people prefer, but I ended up settling on Bitwarden, which is <strong>BEAUTIFUL</strong> both on Android and as a browser extension. On top of that, it has open components and allows <strong>self-hosting</strong>.</p>
<p>More than that, it doesn&rsquo;t make it hard to export all the data from your account and import it somewhere else. Unlike apps like Twilio Authy, which locks you out of everything and is why I stopped using it.</p>
<p>Bitwarden has a secure random password generator, supports <strong>2FA TOTP</strong>, lets you store secure notes (mini texts) and a lot more. The apps integrate with phone biometrics, so it&rsquo;s very convenient to use.</p>
<p>I still keep my Bitwarden inside Samsung&rsquo;s <a href="https://www.samsung.com/global/sustainability/popup/popup_doc/AYUqoB6qDDMAIx_C/"target="_blank" rel="noopener">Secure Vault</a>, the <strong>Samsung Knox Vault</strong>. So there are 2 layers of security. And when I need to walk around somewhere suspicious, I don&rsquo;t take my main phone, only a burner phone with nothing important logged in.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910193713_bitwarden-generator.png" alt="Bitwarden Generator"  loading="lazy" /></p>
<p>Look how beautiful it is. I like it and I&rsquo;ve been using it for a few years now. My passwords range from 21 to 42 random characters, a different one for each site. I&rsquo;ve updated all the passwords for everything more than once in the past few years, and all accounts have 2FA/TOTP registered inside Bitwarden too, which is how I managed to drop Authy.</p>
<h3>Installing VaultWarden Self-Hosted<span class="hx:absolute hx:-mt-20" id="installing-vaultwarden-self-hosted"></span>
    <a href="#installing-vaultwarden-self-hosted" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>More important, the time has come to host my own Bitwarden server, <a href="https://github.com/dani-garcia/vaultwarden"target="_blank" rel="noopener"><strong>vaultwarden</strong></a>, which is an open source alternative, code open on GitHub, so you can poke around yourself to learn or even contribute to it. Again, I bring everything up with Docker Compose on my Home Server, so I just needed to add this snippet to my file:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">vaultwarden</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vaultwarden/server:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">vaultwarden</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DOMAIN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://bitwarden.fabioakita.dev&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SIGNUPS_ALLOWED</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;false&#34;</span><span class="w">          </span><span class="c"># set to true only to create first user</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">WEBSOCKET_ENABLED</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ADMIN_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;iA...Q9&#34;</span><span class="w">  </span><span class="c"># for /admin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># SMTP (recommended)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SMTP_HOST</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;smtp.gmail.com&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SMTP_PORT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;587&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SMTP_SECURITY</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;starttls&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SMTP_USERNAME</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;boss@akitaonrails.com&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SMTP_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;xxx&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SMTP_FROM</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;boss@akitaonrails.com&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/vaultwarden/data:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;9999:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;wget&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-q&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-O&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost/health&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Important:</p>
<ul>
<li>Create a long, random <code>ADMIN_TOKEN</code>. Use Bitwarden itself for that, keep it secret.</li>
<li>Set <code>SIGNUPS_ALLOWED</code> to <code>true</code> only once, log into the local site and create your new account. Then change it back to <code>false</code> in the file and restart the container. That way nobody else will be able to create any new accounts.</li>
<li>It&rsquo;s recommended to configure a GMAIL SMTP to send notification emails. For that, you need to create an app password in Google.</li>
</ul>
<p>To create a password exclusively for vaultwarden, go to <a href="https://myaccount.google.com/apppasswords"target="_blank" rel="noopener">Google App Passwords</a> and generate a new exclusive secret password:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910194453_screenshot-2025-09-10_16-34-48.png" alt="App Password"  loading="lazy" /></p>
<p>It will only show you that password once. Copy and paste it straight into the docker compose yaml. With email properly configured, every time someone logs in, it sends an email notification. That&rsquo;s good to make sure nobody shady is messing with your server:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910201642_screenshot-2025-09-10_20-16-25.png" alt="notification"  loading="lazy" /></p>
<p>Now I just need to bring up the container:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose -f utils-docker-compose.yml up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>Routing the Cloudflared tunnel<span class="hx:absolute hx:-mt-20" id="routing-the-cloudflared-tunnel"></span>
    <a href="#routing-the-cloudflared-tunnel" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>As I already showed in the <a href="https://akitaonrails.com/en/2025/09/09/accessing-my-home-server-with-a-real-domain/"target="_blank" rel="noopener">Cloudflared post</a> now I want to be able to access it remotely, via secure tunnel, using a public URL like <code>https://bitwarden.fabioakita.dev</code>. To do that we have to edit that <code>/etc/cloudflared/config.yml</code> file and add this to the end of it:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">bitwarden.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:9999</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">bitwarden.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">http2Origin</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then we have to restart the service and also add the route manually:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo cloudflared tunnel route dns homelab bitwarden.fabioakita.dev
</span></span><span class="line"><span class="cl">sudo systemctl restart cloudflared</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And done! It might take up to 5 minutes for DNS to sync and all that, but then just hop into your browser and you&rsquo;ll see this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910194927_screenshot-2025-09-10_19-49-14.png" alt="VaultWarden Login"  loading="lazy" /></p>
<p>If that <code>SIGNUPS_ALLOWED</code> is <code>true</code> there will also be a link to &ldquo;Create new account&rdquo;. In my case I already did that and turned it off, so there&rsquo;s only login.</p>
<h3>Importing your Data<span class="hx:absolute hx:-mt-20" id="importing-your-data"></span>
    <a href="#importing-your-data" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Before moving on, let&rsquo;s open the Bitwarden extension - which is still logged into the cloud version at bitwarden.com - and export all my data:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910195209_bitwarden-export.png" alt="Export Vault"  loading="lazy" /></p>
<p>Just authenticate, then on the bottom icon bar you&rsquo;ll find &ldquo;Settings&rdquo;, then &ldquo;Vault Options&rdquo; and finally &ldquo;Export vault&rdquo;. Leave the default JSON format (it will be an <strong>encrypted JSON</strong>) and download that file somewhere safe.</p>
<p>Now we can log back into our self-hosted version at <code>https://bitwarden.fabioakita.dev</code> and go through the same menu, but choose &ldquo;Import&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910195410_screenshot-2025-09-10_19-53-56.png" alt="Import"  loading="lazy" /></p>
<p>And done! Now my self-hosted version is <strong>identical</strong> to the cloud version I had been using until now.</p>
<h3>Logging into the new self-hosted server<span class="hx:absolute hx:-mt-20" id="logging-into-the-new-self-hosted-server"></span>
    <a href="#logging-into-the-new-self-hosted-server" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Now just log out of both the mobile App and the browser extension. Then on the login screen you&rsquo;ll see the following option:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910195632_bitwarden-login.png" alt="Login self-hosted"  loading="lazy" /></p>
<p>On the web extension the window doesn&rsquo;t let you see that option, use the mouse scroll and scroll to the bottom of the window and you&rsquo;ll see that small option. Choose &ldquo;self-hosted&rdquo; and a window will open to type the URL <code>https://bitwarden.fabioakita.dev</code> and done!</p>
<p>From here on the usage is exactly the same as before. The interface is the same, the options are the same. Configure <strong>biometrics</strong>, PIN, lock timeout and everything else the way you like it.</p>
<p>Do the same on the mobile app and done!</p>
<p>On this new account we created on the self-hosted server, don&rsquo;t forget to enable 2FA too, which is the &ldquo;Authenticator App&rdquo; option under &ldquo;Settings/Security&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910200009_screenshot-2025-09-10_19-59-55.png" alt="2FA"  loading="lazy" /></p>
<p>For all other accounts, we can register the 2FA inside Bitwarden. But for the master account of Bitwarden/Vaultwarden itself, we need to use a second app just for that.</p>
<p>I don&rsquo;t know if it&rsquo;s the best, but I&rsquo;ve been enjoying using <a href="https://getaegis.app/"target="_blank" rel="noopener">Aegis Authenticator</a> on my Android:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910203243_hero.7C782sEM.png" alt="Aegis"  loading="lazy" /></p>
<p>It&rsquo;s open source, the code is on <a href="https://github.com/beemdevelopment/aegis"target="_blank" rel="noopener">GitHub</a>, so it seems minimally trustworthy. I also keep it inside the Samsung Knox Vault. I only need it when I have to re-login into Bitwarden/Vaultwarden. To log into Google, Steam or any other account, the 2FA for each one lives inside Vaultwarden itself:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910200449_2fa.png" alt="2FA"  loading="lazy" /></p>
<h3>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Now all that&rsquo;s left is to log in with my old account at <strong>bitwarden.com</strong>, which is the &ldquo;official&rdquo; cloud version, and delete my account, to make sure nobody else has access.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910200615_screenshot-2025-09-10_20-06-04.png" alt="Deletar"  loading="lazy" /></p>
<p>And there, <strong>PERMANENTLY DELETED</strong>! My accounts no longer exist on some random server I don&rsquo;t control, even if they were encrypted.</p>
<p>I&rsquo;m pretty happy with this solution. Now my Vaultwarden is in a container, the home server data gets automatic backups to my NAS, everything is encrypted and access only happens via Cloudflare&rsquo;s secure tunnel. The risk is very low and nobody has a chance of getting access to my data, because it&rsquo;s no longer in the &ldquo;cloud&rdquo;.</p>
<p>My data is mine alone.</p>
]]></content:encoded><category>omarchy</category><category>homelab</category><category>containers</category><category>security</category></item><item><title>Installing Grafana on My Home Server</title><link>https://akitaonrails.github.io/en/2025/09/10/installing-grafana-on-my-home-server/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/09/10/installing-grafana-on-my-home-server/</guid><pubDate>Wed, 10 Sep 2025 16:00:00 -0300</pubDate><description>&lt;p&gt;This post continues my &lt;a href="https://akitaonrails.github.io/en/tags/homeserver/"&gt;home server series&lt;/a&gt;, once again serving as notes for my future self. As I explained before, my home server is an Intel NUC mini-PC that I only access via SSH. So I wanted a way to monitor system resource usage without having to manually SSH in and open BTOP or NTOP.&lt;/p&gt;
&lt;p&gt;I had been too lazy to install Grafana back then, but I finally got around to it, and it was much easier than I expected, which is why I decided to share how I did it.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post continues my <a href="/en/tags/homeserver/">home server series</a>, once again serving as notes for my future self. As I explained before, my home server is an Intel NUC mini-PC that I only access via SSH. So I wanted a way to monitor system resource usage without having to manually SSH in and open BTOP or NTOP.</p>
<p>I had been too lazy to install Grafana back then, but I finally got around to it, and it was much easier than I expected, which is why I decided to share how I did it.</p>
<p>The idea is to spin up <a href="https://github.com/google/cadvisor"target="_blank" rel="noopener">cAdvisor</a>, which monitors container resources, and <a href="https://github.com/prometheus/prometheus"target="_blank" rel="noopener">Prometheus</a>, which scrapes system resource data, then consolidate the data from both into <a href="https://grafana.com/"target="_blank" rel="noopener">Grafana</a>, a web application that lets you build graphical dashboards to monitor your systems. If you do it right, in the end you&rsquo;ll have a screen like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910155108_screenshot-2025-09-10_15-50-49.png" alt="Grafana Dashboard"  loading="lazy" /></p>
<h3>Installation<span class="hx:absolute hx:-mt-20" id="installation"></span>
    <a href="#installation" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Spinning this up on my home server is very simple. I do everything with Docker Compose, so:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># monitor-docker-compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">monitoring</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">driver</span><span class="p">:</span><span class="w"> </span><span class="l">bridge</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">prometheus_data</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">grafana_data</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">node-exporter</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">quay.io/prometheus/node-exporter:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- --<span class="l">path.rootfs=/host</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">pid</span><span class="p">:</span><span class="w"> </span><span class="l">host</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">monitoring]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/:/host:ro,rslave</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;no-new-privileges:true&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">read_only</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cadvisor</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">gcr.io/cadvisor/cadvisor:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">monitoring]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;8080:8080&#34;</span><span class="nt">]   # optional</span><span class="p">:</span><span class="w"> </span><span class="l">expose UI locally</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/:/rootfs:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/var/run:/var/run:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/sys:/sys:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/var/lib/docker/:/var/lib/docker:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/dev/disk/:/dev/disk:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;no-new-privileges:true&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">read_only</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">prometheus</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">prom/prometheus:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">monitoring]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">prometheus_data:/prometheus</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./prometheus.yml:/etc/prometheus/prometheus.yml:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- --<span class="l">config.file=/etc/prometheus/prometheus.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- --<span class="l">storage.tsdb.path=/prometheus</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- --<span class="l">storage.tsdb.retention.time=30d</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- --<span class="l">web.enable-lifecycle</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;9090:9090&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;no-new-privileges:true&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">read_only</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">grafana</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">grafana/grafana:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">monitoring]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">prometheus]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;3001:3000&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">GF_SECURITY_ADMIN_USER</span><span class="p">:</span><span class="w"> </span><span class="l">admin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">GF_SECURITY_ADMIN_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">change-me</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">GF_USERS_ALLOW_SIGN_UP</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;false&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">grafana_data:/var/lib/grafana</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./provisioning/datasources:/etc/grafana/provisioning/datasources:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">security_opt</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;no-new-privileges:true&#34;</span><span class="p">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Pay attention to the local directory mappings. If you put <code>/etc/grafana/prometheus.yml</code>, for example, you have to change this line:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nn">...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">prometheus_data:/prometheus</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/etc/grafana/prometheus.yml:/etc/prometheus/prometheus.yml:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nn">...</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Speaking of which, here&rsquo;s the <code>prometheus.yml</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">global</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">scrape_interval</span><span class="p">:</span><span class="w"> </span><span class="l">15s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">scrape_configs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;prometheus&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">static_configs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>{<span class="w"> </span><span class="nt">targets</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;prometheus:9090&#39;</span><span class="p">]</span><span class="w"> </span>}<span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;node&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">static_configs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>{<span class="w"> </span><span class="nt">targets</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;node-exporter:9100&#39;</span><span class="p">]</span><span class="w"> </span>}<span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">job_name</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;cadvisor&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">static_configs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>{<span class="w"> </span><span class="nt">targets</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;cadvisor:8080&#39;</span><span class="p">]</span><span class="w"> </span>}<span class="p">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And finally <code>/etc/grafana/provisioning/datasources/datasource.yaml</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Be careful with port mappings too. The recommended approach is this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;127.0.0.1:8080:8080&#34;</span><span class="p">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That&rsquo;s because cAdvisor and Prometheus only need to be accessible to Grafana, inside the home server, and there&rsquo;s no reason to bind on <code>0.0.0.0</code> and have them reachable from outside the server. That&rsquo;s your call. Since I&rsquo;m on a home LAN and there are no tunnels pointing to these services, it makes no difference to me.</p>
<p>If, like me, you already have a service sitting on port 8080, cAdvisor will fail. In that case, remap the ports and reconfigure Prometheus, or just remove the port line. You don&rsquo;t need cAdvisor exposed externally.</p>
<p>Another detail: by default, Grafana comes up on port 3000, but I changed it to 3001 because I already had another service on that port. So once it&rsquo;s up, I can access it at <code>http://192.168.0.200:3001</code>.</p>
<p>As I explained in the <a href="/en/2025/09/09/accessing-my-home-server-with-a-real-domain/">Cloudflared article</a>, I route this address through Cloudflare to get a nice domain like <code>https://grafana.fabioakita.dev</code> to access it both locally and remotely, if I want. Go read those other articles afterward to understand how.</p>
<p>And that&rsquo;s it, now just spin up the containers:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose -f monitor-docker-compose.yml up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Done, super simple. I should have done this sooner.</p>
<p>As you can see in the Docker Compose file, when you open the site for the first time, the login is <code>admin</code> with password <code>change-me</code>. Obviously, the first thing to do is change the password:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910160042_screenshot-2025-09-10_16-00-31.png" alt="Admin Password"  loading="lazy" /></p>
<h3>Dashboards<span class="hx:absolute hx:-mt-20" id="dashboards"></span>
    <a href="#dashboards" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Now we&rsquo;re practically done. We can check whether Prometheus is working and integrated. Just go to the &ldquo;Connections&rdquo; menu and &ldquo;Data Sources&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910160151_screenshot-2025-09-10_16-01-40.png" alt="Data Sources"  loading="lazy" /></p>
<p>Grafana can consolidate data from dozens of different sources, and Prometheus is only one of them. In a larger, more complex infrastructure, this list would be huge.</p>
<p>To build dashboards, you can start from scratch and assemble them widget by widget, but it&rsquo;s easier to import dashboards that already exist. Just click the magnifying glass at the top, in the search box, and choose &ldquo;Import dashboards&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910160509_screenshot-2025-09-10_16-04-59.png" alt="import dashboards menu"  loading="lazy" /></p>
<p>On that page you can either import a JSON file, copy and paste the JSON, or search by an ID already registered on grafana.com:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910160603_screenshot-2025-09-10_16-05-46.png" alt="import by ID"  loading="lazy" /></p>
<p>Right off the bat, I learned that there are 3 IDs that are useful for me:</p>
<ul>
<li><strong>1860 - Node Exporter Full</strong> - the screenshot at the beginning of this article, with several system metrics like CPU, memory, disk, network, etc. The most useful one for me.</li>
<li><strong>13484 - Docker/cAdvisor Exporter</strong> - CPU and memory metrics, but broken down per container running on the server.</li>
<li><strong>3662 - Prometheus 2.0 overview</strong> - a general health summary.</li>
</ul>
<p>If you like these or other dashboards, just favorite them with a &ldquo;pin&rdquo; and you&rsquo;re set.</p>
<p>For example, here&rsquo;s the cAdvisor dashboard:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20250910160927_screenshot-2025-09-10_16-09-13.png" alt="cAdvisor dashboard"  loading="lazy" /></p>
<p>I haven&rsquo;t explored other dashboards yet. If you have ID recommendations to import, drop them in the comments below!</p>
<p>With this, I have a quick and good-looking view of my home server&rsquo;s resource usage, and I can spot anything weird happening right away. These services don&rsquo;t seem to consume many resources, so I can leave them running alongside the other containers.</p>
]]></content:encoded><category>homelab</category><category>containers</category><category>devops</category></item><item><title>Protecting Your Home Server with Cloudflare Zero Trust</title><link>https://akitaonrails.github.io/en/2025/09/10/protecting-your-home-server-with-cloudflare-zero-trust/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/09/10/protecting-your-home-server-with-cloudflare-zero-trust/</guid><pubDate>Wed, 10 Sep 2025 02:00:00 -0300</pubDate><description>&lt;p&gt;In the &lt;a href="https://akitaonrails.com/en/2025/09/09/accessing-my-home-server-with-a-real-domain/"target="_blank" rel="noopener"&gt;previous post&lt;/a&gt; I explain how I registered a real domain at Cloudflare and created secure encrypted tunnels that let me reach my home server even from outside the house, without exposing ports on the router or anything like that.&lt;/p&gt;
&lt;p&gt;This way I can access &lt;code&gt;https://portainer.fabioakita.dev&lt;/code&gt; (fake example domain) from my phone on 5G, and it hits the portainer Docker container sitting on my LAN at &lt;code&gt;http://192.168.0.200:9000&lt;/code&gt;, which was previously only reachable from home. All through a secure tunnel.&lt;/p&gt;</description><content:encoded><![CDATA[<p>In the <a href="https://akitaonrails.com/en/2025/09/09/accessing-my-home-server-with-a-real-domain/"target="_blank" rel="noopener">previous post</a> I explain how I registered a real domain at Cloudflare and created secure encrypted tunnels that let me reach my home server even from outside the house, without exposing ports on the router or anything like that.</p>
<p>This way I can access <code>https://portainer.fabioakita.dev</code> (fake example domain) from my phone on 5G, and it hits the portainer Docker container sitting on my LAN at <code>http://192.168.0.200:9000</code>, which was previously only reachable from home. All through a secure tunnel.</p>
<p>Note that I keep saying I&rsquo;m using fake example domains and IPs throughout these posts, because I don&rsquo;t want to expose the real domain.</p>
<p>Just by sitting behind a Cloudflare secure tunnel, I&rsquo;m already under several layers of protection like DDoS defense and many other well-known attack mitigations. And although I take care to configure every service with a strong, random, unique password per service, the risk isn&rsquo;t zero that someone could find my services and try to brute-force them, or exploit some zero-day vulnerability in one of the many services like Prowlarr, Sonarr, and friends.</p>
<p>So I&rsquo;m still at the mercy of undiscovered bugs that might pop up out of nowhere, particularly on each service&rsquo;s login screen or API endpoints.</p>
<p>To avoid that risk, there&rsquo;s another Cloudflare product worth configuring: <a href="https://www.cloudflare.com/zero-trust/products/access/"target="_blank" rel="noopener"><strong>Zero Trust</strong></a>. The idea is simple: <strong>add a new login layer on top of everything</strong>, from Cloudflare itself, to protect each service. That way, someone has to break Cloudflare before they even get a chance to try breaking one of my services.</p>
<h3>1. Sign up for the service<span class="hx:absolute hx:-mt-20" id="1-sign-up-for-the-service"></span>
    <a href="#1-sign-up-for-the-service" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>This is the easy part. For few services and few users (just me), there&rsquo;s a <strong>free</strong> plan, just sign up:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_00-35-57.png" alt="Free Plan"  loading="lazy" /></p>
<h3>2. Create OAuth Credentials in Google Cloud<span class="hx:absolute hx:-mt-20" id="2-create-oauth-credentials-in-google-cloud"></span>
    <a href="#2-create-oauth-credentials-in-google-cloud" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Since I pay for Google Workspace, I decided to use integrated login with Google OAuth, but it can be any other provider, pick whatever you prefer. For this I need to go to my <a href="https://console.cloud.google.com"target="_blank" rel="noopener">Google Cloud console</a> and enter the <strong>API &amp; Services</strong> section. Here we create credentials for an <strong>OAuth client ID</strong> and configure it like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_00-58-02.png" alt="Oauth client config"  loading="lazy" /></p>
<p>The name doesn&rsquo;t matter, it&rsquo;s what shows up on the login screen. What matters are the <strong>Javascript Origins</strong> fields, which must follow this format:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>https://&lt;your-team-name&gt;.cloudflareaccess.com</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the <strong>redirect URIs</strong> field, which is where Google&rsquo;s login screen will redirect to, and it has to point back to our Cloudflare:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>https://&lt;your-team-name&gt;.cloudflareaccess.com/cdn-cgi/access/callback</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Once that&rsquo;s done, it gives you the option to download a <strong>JSON</strong> file with the metadata and secret. What&rsquo;s important is that at the end it gives you an <strong>OAuth Client ID</strong>, which is a URL in the format <code>xxxxxx.apps.googleusercontent.com</code>, and most importantly, a <strong>Client Secret</strong>, which you should store in BitWarden or another secure place.</p>
<h3>4. Creating a Login Method in Zero Trust<span class="hx:absolute hx:-mt-20" id="4-creating-a-login-method-in-zero-trust"></span>
    <a href="#4-creating-a-login-method-in-zero-trust" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Now back to the Zero Trust console on Cloudflare, click &ldquo;Settings&rdquo; at the bottom of the menu, enter &ldquo;Authentication&rdquo;, and create a new <strong>Login Method</strong>:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-04-35.png" alt="Login Method"  loading="lazy" /></p>
<p>After clicking &ldquo;Add new&rdquo;, just fill in the data we brought from Google Cloud:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-07-17.png" alt="New Google Login Method"  loading="lazy" /></p>
<p>Again, use whatever name you want, paste the OAuth Client ID as &ldquo;App ID&rdquo; and the Client Secret, and that&rsquo;s it, now we have Google OAuth integrated as a login method in our Zero Trust.</p>
<h3>5. Creating Policies<span class="hx:absolute hx:-mt-20" id="5-creating-policies"></span>
    <a href="#5-creating-policies" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Now comes the part where I&rsquo;m not sure if I did it entirely right. If I didn&rsquo;t, let me know in the comments below!</p>
<p>My understanding is that we need to create at least two <strong>Policies</strong>, one for Service Auth and another for the Email I want to allow coming in from Google.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-09-06.png" alt="Policies"  loading="lazy" /></p>
<p>To continue, just click &ldquo;Add a Policy&rdquo; and edit the first policy:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-10-42.png" alt="Service Token"  loading="lazy" /></p>
<p>The name can be anything. What matters is that under &ldquo;Action&rdquo; you pick <strong>&ldquo;Service Auth&rdquo;</strong> and under &ldquo;Add Rules&rdquo; you pick <strong>Any Access Service Token</strong> (this is the part I think is wrong, but I couldn&rsquo;t find a better option - I didn&rsquo;t quite understand how Service Token works).</p>
<p>Now we need to create the second policy like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-20-47.png" alt="Email Policy"  loading="lazy" /></p>
<p>This time what matters is having &ldquo;Action&rdquo; as &ldquo;Allow&rdquo; and adding the Rule &ldquo;Emails&rdquo;, picking the email you want to authenticate. In my case, just me.</p>
<h3>6. Creating a new Application<span class="hx:absolute hx:-mt-20" id="6-creating-a-new-application"></span>
    <a href="#6-creating-a-new-application" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Now we can create a new application in Zero Trust:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-22-51.png" alt="Applications"  loading="lazy" /></p>
<p>Click &ldquo;Add an Application&rdquo; and pick the type as <strong>&ldquo;Self-Hosted&rdquo;</strong>:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-26-20.png" alt="Application Basic Information"  loading="lazy" /></p>
<p>My understanding is that each application supports at most 5 hostnames. I have quite a few more than that, so I&rsquo;ll have to create more than one application. The configuration in all of them is the same. Under &ldquo;Basic Information&rdquo; I just registered the subdomains of each container I want to protect.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-28-37.png" alt="Policies"  loading="lazy" /></p>
<p>Going to the <strong>&ldquo;Policies&rdquo;</strong> tab, just click &ldquo;Select existing policies&rdquo; and add the two policies we just created. That&rsquo;s it.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-29-43.png" alt="Login Method"  loading="lazy" /></p>
<p>Finally, under <strong>&ldquo;Login methods&rdquo;</strong>, just pick the Google one we configured earlier. And yes, these are the editing screens of an application I had already created. The new application screen has all of this on a single page, but you get the idea.</p>
<p>And as far as I understand, that&rsquo;s all. Theoretically, everything is already working!</p>
<h3>7. Testing the new Login<span class="hx:absolute hx:-mt-20" id="7-testing-the-new-login"></span>
    <a href="#7-testing-the-new-login" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If nothing went wrong, just hit one of the hostnames we registered in the Zero Trust application, for example <code>https://immich.fabioakita.dev</code>, and we should see this screen first:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-33-41.png" alt="Zero Trust Login"  loading="lazy" /></p>
<p>If this shows up, success! Zero Trust blocked access to my application until I logged in to it first.</p>
<p>Later you can customize the text however you want, but since it&rsquo;s just for me, the &ldquo;welcome&rdquo; copy doesn&rsquo;t matter, because actually, <strong>nobody is welcome here, just ME!</strong></p>
<p>Clicking &ldquo;Google&rdquo; opens the familiar Google login pop-up, and only after that do I get to reach my application&rsquo;s own login underneath. And this applies to every hostname I added to the Zero Trust applications.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/userinter.png" alt="Google Sign In"  loading="lazy" /></p>
<p>Once you log in with Google using the correct user, the session stays valid for 24 hours (which is what we configured in the policies and application). During that window you don&rsquo;t need to log in again on Zero Trust for any of the other applications on my home server. The session is shared across all applications.</p>
<h3>8. Logging out Sessions<span class="hx:absolute hx:-mt-20" id="8-logging-out-sessions"></span>
    <a href="#8-logging-out-sessions" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Once you&rsquo;ve done the first login with your user, you can control that session by going to &ldquo;My team&rdquo; and &ldquo;Users&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-38-38.png" alt="Users"  loading="lazy" /></p>
<p>Clicking on the user, you get a bunch of info and options, in particular see <strong>&ldquo;Revoke Sessions&rdquo;</strong>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-40-31.png" alt="Revoke"  loading="lazy" /></p>
<p>This way we can test the login without waiting 24 hours for the auth token to expire on its own. It&rsquo;s a way to force logout your users. On the Zero Trust free plan, from what I understand, you can have up to 10 users in your organization.</p>
<h3>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I had never used Zero Trust before, so I might have made some mistake in the procedure. Read the documentation too. In principle, it seems to be working. I already created more applications and registered the rest of my hostnames.</p>
<p>Just by using CloudflareD for encrypted tunnels, all traffic reaching my home server goes through a series of Cloudflare protections that include: DDoS protection, URL normalization, Origin Rules, IP Address Rules, WAF (Web Application Firewall), Bots Mitigation, Rate Limiting, Cache, Compression and more.</p>
<p>These layers protect against the main well-known attack types, and they&rsquo;re always adding new rules as each new vulnerability becomes known. Even if you, as a developer, don&rsquo;t know how to protect against everything in your application, there&rsquo;s a chance Cloudflare protects you first. That&rsquo;s why I recommend keeping your domains under Cloudflare&rsquo;s control.</p>
<p>On top of all of that, we also add <strong>Zero Trust</strong>, which guarantees that only after a login with my Google account will it start allowing access further down. And at the end of that there&rsquo;s still one last barrier: the individual login screens of each application running on my home server.</p>
<p>Beyond that, the Cloudflare dashboard has traffic monitoring, analytics and much more. I configured just the basics, but if you want to go deeper, there are plenty of extra policies and rules you can add.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-10_01-53-58.png" alt="Analytics"  loading="lazy" /></p>
<p>For someone to get through all of this and actually break into my home server and my LAN, they&rsquo;d have to be really dedicated and really smart - I can&rsquo;t picture anyone like that wasting so much time just to access a video server 🤣 but they&rsquo;re welcome to try.</p>
]]></content:encoded><category>homelab</category><category>security</category><category>cloud</category></item><item><title>Accessing My Home Server With a Real Domain</title><link>https://akitaonrails.github.io/en/2025/09/09/accessing-my-home-server-with-a-real-domain/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/09/09/accessing-my-home-server-with-a-real-domain/</guid><pubDate>Tue, 09 Sep 2025 17:00:00 -0300</pubDate><description>&lt;p&gt;Now an advanced tip that complements my article about my &lt;a href="https://akitaonrails.com/2024/04/03/meu-netflix-pessoal-com-docker-compose/"target="_blank" rel="noopener"&gt;Home Server&lt;/a&gt;. In short, besides my desktop PC, which now runs Omarchy, I have a mini-PC (a simple Intel NUC, it doesn&amp;rsquo;t need to be very powerful), exclusively to run Docker containers.&lt;/p&gt;
&lt;p&gt;I have a nice Synology DS1821+ that also supports running Docker, but I prefer to keep the NAS doing only NAS duties and avoid risking my data by running other stuff on it. Having a single server that does everything isn&amp;rsquo;t a &amp;ldquo;problem&amp;rdquo; per se, but since I can, I prefer to separate things.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Now an advanced tip that complements my article about my <a href="https://akitaonrails.com/2024/04/03/meu-netflix-pessoal-com-docker-compose/"target="_blank" rel="noopener">Home Server</a>. In short, besides my desktop PC, which now runs Omarchy, I have a mini-PC (a simple Intel NUC, it doesn&rsquo;t need to be very powerful), exclusively to run Docker containers.</p>
<p>I have a nice Synology DS1821+ that also supports running Docker, but I prefer to keep the NAS doing only NAS duties and avoid risking my data by running other stuff on it. Having a single server that does everything isn&rsquo;t a &ldquo;problem&rdquo; per se, but since I can, I prefer to separate things.</p>
<p>This Intel NUC runs a headless Ubuntu Server LTS (no desktop manager), which I access only via SSH (as I explained in the <a href="https://akitaonrails.com/en/2025/09/09/omarchy-2-0-understanding-ssh-and-yubikeys/"target="_blank" rel="noopener">previous post about SSH</a>). On it I run things like Portainer, Plex, Sonarr, Bazarr, qBitTorrent and much more.</p>
<p>The way it&rsquo;s set up, I just added an entry to my desktop&rsquo;s <code>/etc/hosts</code> like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>192.168.0.100 synology.lan
192.168.0.200 plex.lan</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><strong>From now on all my domains and IP addresses will be fake, obviously.</strong></p>
<p>Anyway, this way I can open my browser and go to <code>http://plex.lan:32400</code> to access my Plex, for example. But this has three disadvantages:</p>
<ol>
<li>I can only use these hostnames on my desktop; from my laptop I&rsquo;d need to edit the hosts file too. On top of that, I need to know the ports for each container.</li>
<li>I have no SSL for anything, so all authentication goes in plain text. It&rsquo;s not a huge problem because the traffic is only internal on my local LAN. I haven&rsquo;t been accessing it from outside the house.</li>
<li>If I ever want to access it from outside, none of this works.</li>
</ol>
<p>There are dozens of ways to solve this problem, solutions involving WireGuard, or commercial solutions like TailScale and so on. But there&rsquo;s a reasonably simple solution that solves all the problems at once: <strong>Cloudflare Tunnels</strong>.</p>
<p>The idea is to create secure tunnels via Cloudflare to access my services on my internal LAN <strong>WITHOUT</strong> having to expose ports outside my router or anything like that. My network stays isolated and unchanged.</p>
<h3>1. Registering a Domain<span class="hx:absolute hx:-mt-20" id="1-registering-a-domain"></span>
    <a href="#1-registering-a-domain" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The first recommendation is to register a real domain. Doing this lets you have real SSL certificates that Cloudflare itself manages and renews automatically.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-09_16-52-14.png" alt="Cloudflare Domain"  loading="lazy" /></p>
<p>I have a few domains registered and I&rsquo;m not going to reveal which one I use for my Home Server. I&rsquo;ll use this empty <strong>fabioakita.dev</strong> as an example just for this post.</p>
<p>Domains are cheap, around USD 12 per year, depending on the name you choose. Pick one that&rsquo;s short and easy to type.</p>
<h3>2. Installing Cloudflared<span class="hx:absolute hx:-mt-20" id="2-installing-cloudflared"></span>
    <a href="#2-installing-cloudflared" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>With the domain created, now we need to install the <a href="https://pkg.cloudflare.com/index.html"target="_blank" rel="noopener"><strong>cloudflared</strong></a> service on the Home Server. It&rsquo;s the one that connects to Cloudflare&rsquo;s servers and opens the tunnel into my local server. Click the link for installation instructions, but on an Ubuntu Server 22.04 it goes like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Add cloudflare gpg key</span>
</span></span><span class="line"><span class="cl">sudo mkdir -p --mode<span class="o">=</span><span class="m">0755</span> /usr/share/keyrings
</span></span><span class="line"><span class="cl">curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg <span class="p">|</span> sudo tee /usr/share/keyrings/cloudflare-main.gpg &gt;/dev/null
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Add this repo to your apt repositories</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared jammy main&#39;</span> <span class="p">|</span> sudo tee /etc/apt/sources.list.d/cloudflared.list
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># install cloudflared</span>
</span></span><span class="line"><span class="cl">sudo apt-get update <span class="o">&amp;&amp;</span> sudo apt-get install cloudflared</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Now we need to log in:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># login</span>
</span></span><span class="line"><span class="cl">sudo cloudflared tunnel login
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># add the homelab tunnel</span>
</span></span><span class="line"><span class="cl">sudo cloudflared tunnel create homelab
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># create the config directory</span>
</span></span><span class="line"><span class="cl">sudo mkdir -p /etc/cloudflared</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Use the same credentials you use to log in to the Cloudflare website. If everything goes well, at the end it will create a file at <code>/root/.cloudflared/xxxxx-yyyy-zzzz...json</code>, which holds your credentials, just like an SSH private key, so don&rsquo;t throw it on a USB stick or some cloud drive. Keep it very safe. Let&rsquo;s move it:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>sudo mv /root/.cloudflared/*.json /etc/cloudflared
sudo chmod 600 /etc/cloudflared/*.json</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Finally, we need to create a configuration file <code>/etc/cloudflared/config.yml</code> with all the hostnames we want for each of the containers, pointing to the correct ports. Here&rsquo;s a snippet of my config, modified with fake IPs and the fake domain:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># /etc/cloudflared/config.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">tunnel</span><span class="p">:</span><span class="w"> </span><span class="l">homelab</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">credentials-file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/cloudflared/xxxx-yyyy-....json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">plex.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.254:32400</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">plex.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">immich.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:2283</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">immich.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">overseer.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:5055</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">overseer.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">syncthing.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:8384</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">qbittorrent.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">radarr.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:7878</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">radarr.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">sonarr.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:8989</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">sonarr.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">bazarr.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:6767</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">bazarr.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">prowlarr.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:9696</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">prowlarr.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">portainer.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:9000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">portainer.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">grafana.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://192.168.0.200:3001</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">originRequest</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">httpHostHeader</span><span class="p">:</span><span class="w"> </span><span class="l">grafana.fabioakita.dev</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span><span class="w"> </span><span class="c"># this has to be the LAST ONE</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>As you can see, it&rsquo;s an extremely simple file. I think anyone can understand what it does. Modify it for your own needs.</p>
<p>After saving the file and going back to the command line, you have to manually run this command for each hostname:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo cloudflared tunnel route dns homelab plex.fabioakita.dev</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>But that&rsquo;s a lot of work with this many hostnames, so I found it easier to create a script <code>/etc/cloudflared/add.sh</code> like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># run only once</span>
</span></span><span class="line"><span class="cl"><span class="nv">TUN</span><span class="o">=</span>homelab
</span></span><span class="line"><span class="cl"><span class="nv">CONF</span><span class="o">=</span>/etc/cloudflared/config.yml
</span></span><span class="line"><span class="cl">grep -E <span class="s1">&#39;^[[:space:]]*-[[:space:]]*hostname:&#39;</span> <span class="s2">&#34;</span><span class="nv">$CONF</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="p">|</span> awk <span class="s1">&#39;{print $3}&#39;</span> <span class="p">|</span> tr -d <span class="s1">&#39;&#34;&#39;</span> <span class="p">|</span> tr -d <span class="s2">&#34;&#39;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> -r h<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;adding </span><span class="nv">$h</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    sudo cloudflared tunnel route dns <span class="s2">&#34;</span><span class="nv">$TUN</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$h</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">done</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Now just run: <code>sudo sh /etc/cloudflared/add.sh</code>, <strong>only once!</strong> So make sure your config file is correct first!</p>
<p>Finally, we can configure the service to restart by itself on every boot:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo cloudflared --config /etc/cloudflared/config.yml service install
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now cloudflared</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>3. Accessing your services via the tunnel<span class="hx:absolute hx:-mt-20" id="3-accessing-your-services-via-the-tunnel"></span>
    <a href="#3-accessing-your-services-via-the-tunnel" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If nothing went wrong, you&rsquo;re done! You can go straight to your browser and test it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-09_17-06-40.png" alt="Immich"  loading="lazy" /></p>
<p>How cool is that. Notice in the URL bar I typed <code>https://immich.fabioakita.dev</code> and it just worked. By the way, if you didn&rsquo;t know it, <a href="https://immich.app/"target="_blank" rel="noopener">Immich</a> works identically to Google Photos, except entirely under your control! It has a smartphone app and syncs nicely.</p>
<p>More importantly, notice it&rsquo;s on <strong>HTTPS</strong>, which was provided by Cloudflare for my entire domain. When I log in, it will go through an <strong>encrypted tunnel</strong>. And more than that: even if I&rsquo;m on 5G, away from home, on my phone, I&rsquo;ll be able to access my photos using this address!</p>
<p>From my phone it opens a secure tunnel to Cloudflare, and that <code>cloudflared</code> service on my home server also has a secure tunnel open to Cloudflare, so Cloudflare bridges the two tunnels and done! I have remote access without exposing my server directly to the internet! The best of both worlds!</p>
<p>Don&rsquo;t forget to configure all your services with strong passwords (I generate them via Bitwarden, but any service works, 1Password or whatever):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/screenshot-2025-09-09_17-10-15.png" alt="Radarr General Auth Settings"  loading="lazy" /></p>
<h3>4. Bridge to vlans<span class="hx:absolute hx:-mt-20" id="4-bridge-to-vlans"></span>
    <a href="#4-bridge-to-vlans" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I don&rsquo;t remember exactly why, but my Plex container alone gave me more trouble. That&rsquo;s because it uses a different VLAN than the others. Again, I don&rsquo;t remember why I did this at the time, but it looks like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">plex</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c">#image: plexinc/pms-docker:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">linuxserver/plex:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=America/Sao_Paulo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1026</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1026</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">VA_DRIVER=IHD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PLEX_HW_TRANS_MAX=16</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PLEX_MEDIA_SERVER_OPTS=--network-host</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/data:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/transcode:/transcode</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/Videos:/media</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/dev/dri:/dev/dri</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/dev/bus/usb:/dev/bus/usb</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="l">curl --connect-timeout 15 --silent --show-error --fail http://localhost:32400/identity</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">1m00s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">15s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">1m00s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mem_limit</span><span class="p">:</span><span class="w"> </span><span class="l">4g</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">cpus</span><span class="p">:</span><span class="w"> </span><span class="m">5.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">plex_macvlan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ipv4_address</span><span class="p">:</span><span class="w"> </span><span class="m">192.168.0.254</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">mac_address</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;xx:xx:xx:xx:xx:xx&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">plex_macvlan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">plex_macvlan</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Because of this, <code>cloudflared</code> can&rsquo;t route to that container. The way to fix it is by creating a bridge from that vlan to my ethernet.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo ip link add macvlan0 link enp3s0 <span class="nb">type</span> macvlan mode bridge
</span></span><span class="line"><span class="cl">sudo ip addr add 192.168.0.200/24 dev macvlan0   <span class="c1"># free IP in your LAN</span>
</span></span><span class="line"><span class="cl">sudo ip link <span class="nb">set</span> macvlan0 up</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Obviously, replace <code>enp3s0</code> with the correct name of your ethernet interface. Check with <code>ip addr</code> or <code>ip link</code>, for example. Just by running the commands above, if I go to the browser and type <code>https://plex.fabioakita.dev</code> it should already work. To make this permanent, we need systemd:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># /etc/systemd/network/80-macvlan0.netdev</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">NetDev</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">Name</span><span class="p">=</span><span class="nx">macvlan0</span>
</span></span><span class="line"><span class="cl"><span class="nx">Kind</span><span class="p">=</span><span class="nx">macvlan</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">MACVLAN</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">Mode</span><span class="p">=</span><span class="nx">bridge</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># /etc/systemd/network/80-macvlan0.network</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">Match</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">Name</span><span class="p">=</span><span class="nx">macvlan0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">Network</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">Address</span><span class="p">=</span><span class="mf">192.168</span><span class="p">.</span><span class="mf">0.200</span><span class="err">/</span><span class="mi">24</span>
</span></span><span class="line"><span class="cl"><span class="nx">Gateway</span><span class="p">=</span><span class="mf">192.168</span><span class="p">.</span><span class="mf">0.1</span>
</span></span><span class="line"><span class="cl"><span class="nx">DNS</span><span class="p">=</span><span class="mf">192.168</span><span class="p">.</span><span class="mf">0.1</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And now, just restart the services:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl restart systemd-networkd
</span></span><span class="line"><span class="cl">ip addr show macvlan0</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The &ldquo;correct&rdquo; thing would be to have my Plex configured with <code>network_mode: host</code>, but, again, I don&rsquo;t remember why it wasn&rsquo;t, and I wasn&rsquo;t in the mood to unconfigure everything just to find out. In case anyone has similar setups, this is how you bypass it.</p>
<p>Speaking of Plex, I had heard that you have to be careful with streaming via cloudflared, I don&rsquo;t know if there are limits or extra costs. I found <a href="https://mythofechelon.co.uk/blog/2024/1/7/how-to-set-up-free-secure-high-quality-remote-access-for-plex"target="_blank" rel="noopener">this article</a> that talks more about it but I didn&rsquo;t stop to look into it deeply. The only extra thing I did, on the Cloudflare dashboard, was add a rule to not do <strong>CACHING</strong> on the <code>plex</code> subdomain, to avoid additional costs.</p>
<h3>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>As you can see, it&rsquo;s extremely easy to have a home server, isolate everything with Docker, and then expose your containers via a secure tunnel for remote access. None of this means instant security. Make sure your containers are always up to date using services like <a href="https://github.com/containrrr/watchtower"target="_blank" rel="noopener">Watchtower</a>, make sure each service has strong, random, unique passwords - don&rsquo;t use the same password for everything. Make sure your firewall is well configured and active, even on a LAN.</p>
<p>Many modern Wi-Fi routers let you create a separate VLAN for guest Wi-Fi. That way guests can use your Wi-Fi but can&rsquo;t see your computers, which will be logically isolated on another VLAN. Check your router&rsquo;s documentation. I use a <strong>TP-Link BE1900 Tri-Band Wi-Fi 7</strong>, but for most people it&rsquo;s too expensive and overkill. Do your research. And don&rsquo;t bother asking me about other models: I haven&rsquo;t tested all of them to know the differences.</p>
<p>If you saw names like Radarr, Sonarr and don&rsquo;t know what they mean, read the <a href="https://wiki.servarr.com/"target="_blank" rel="noopener">Servarr Wiki</a>, which consolidates all the documentation for these services in one place. It&rsquo;s the infrastructure that lets you have your own &ldquo;Personal Netflix&rdquo;.</p>
<p>Also, don&rsquo;t ask me why I use Plex and not Jellyfin. It&rsquo;s a personal choice, pick what&rsquo;s best for you. I like Plex, I&rsquo;ve used Jellyfin, and I don&rsquo;t plan to switch.</p>
<p>I hate the idea of paying subscriptions for everything and owning nothing. I think it&rsquo;s money thrown away. I prefer to <strong>OWN</strong> my own things under <strong>MY CONTROL</strong>. It&rsquo;s more expensive, it&rsquo;s more work. Too much convenience means you have control over nothing and always depend on others.</p>
<p>I hate depending on others.</p>
]]></content:encoded><category>homelab</category><category>networking</category><category>containers</category></item><item><title>Accessing Your NAS Using iSCSI Instead of SMB</title><link>https://akitaonrails.github.io/en/2025/04/24/accessing-your-nas-using-iscsi-instead-of-smb/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/04/24/accessing-your-nas-using-iscsi-instead-of-smb/</guid><pubDate>Thu, 24 Apr 2025 01:00:00 -0300</pubDate><description>&lt;p&gt;If you have a NAS or any kind of file server on your network, odds are your default move is to create a shared folder and mount it on your operating system, whether Windows, Mac, or Linux, using the old, tired SMB (Samba) protocol.&lt;/p&gt;
&lt;p&gt;On Linux, this is a massive pain in the ass, because the SMB protocol (built for Windows) doesn&amp;rsquo;t properly carry concepts like ownership (chown) or permissions (chmod). It doesn&amp;rsquo;t understand things like execute permission (everything is executable). For simple stuff like a Downloads, Videos, or Photos directory, it kind of doesn&amp;rsquo;t matter.&lt;/p&gt;</description><content:encoded><![CDATA[<p>If you have a NAS or any kind of file server on your network, odds are your default move is to create a shared folder and mount it on your operating system, whether Windows, Mac, or Linux, using the old, tired SMB (Samba) protocol.</p>
<p>On Linux, this is a massive pain in the ass, because the SMB protocol (built for Windows) doesn&rsquo;t properly carry concepts like ownership (chown) or permissions (chmod). It doesn&rsquo;t understand things like execute permission (everything is executable). For simple stuff like a Downloads, Videos, or Photos directory, it kind of doesn&rsquo;t matter.</p>
<p>But let&rsquo;s say you want to edit source code in projects with Git. It&rsquo;s going to be a nightmare, because SMB overrides the permissions regardless of what&rsquo;s underneath, and every single time it&rsquo;s going to conflict with Git, which will think files were modified (changing permissions counts as a change it tracks), and it&rsquo;ll keep nagging you to commit that, and that will pollute your whole repository (never use Git projects on SMB over the network).</p>
<p>Instead, I&rsquo;m on Linux, my NAS is Linux. The right call is to use a Linux protocol. That protocol is NFS and I showed how I set it up in <a href="https://www.akitaonrails.com/en/2025/04/17/configurando-meu-nas-synology-com-nfs-no-linux"target="_blank" rel="noopener">last week&rsquo;s post</a>.</p>
<p>Anyway, I&rsquo;m using NFS and I have way fewer headaches around file permissions. But then I hit another problem: if you saw my recent posts, you saw I&rsquo;ve been experimenting A LOT with Docker. Spinning up new images like there&rsquo;s no tomorrow. My local 2 TB NVMe filled up fast and started complaining about lack of space. So I thought, <em>&ldquo;Ah, I&rsquo;ll just move the Docker storage directory, /var/lib/docker, to a directory mounted via NFS on my NAS&rdquo;</em></p>
<p>Doing that is very easy:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>sudo mkdir -p /mnt/nfs/docker
sudo chown root:docker /mnt/nfs/docker
sudo chmod 771       /mnt/nfs/docker</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That creates a new directory inside my NAS and applies the correct permissions.</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>sudo systemctl stop docker
sudo mv /var/lib/docker /mnt/nfs/docker
# sudo rm -Rf /var/lib/docker (this will free everything up for me)
sudo ln -s /mnt/nfs/docker /var/lib/docker</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That will free up all the occupied space. I don&rsquo;t need to worry about the images because I can rebuild everything from my Dockerfiles. And I don&rsquo;t need to worry about volumes because I had nothing important that wasn&rsquo;t mounted externally, in real directories. Rule: never store important things in Docker volumes. It&rsquo;s bad practice!</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code># edit /etc/docker/daemon.json
{
  &#34;data-root&#34;: &#34;/mnt/nfs/docker&#34;
}

sudo systemctl start docker</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Done, this changes the configuration from <code>/var/lib/docker</code> to the new <code>/mnt/nfs/docker</code> and after restarting the service, it&rsquo;ll start writing everything there. So the post is over, right?</p>
<p>This is as far as most people go. But if you work with this setup for 2 minutes, you&rsquo;ll feel something is VERY wrong.</p>
<p>I tried building a new image. And it was taking ABSURDLY long. It turns out NFS is indeed faster than SMB, but both were built for file servers, not for operating systems. That makes a huge difference.</p>
<p>These protocols are <strong>FILE-BASED</strong>, every operation is based on files. And that&rsquo;s extremely inefficient.</p>
<p>If you watched my videos on file systems, you should already know they are <strong>BLOCK-LEVEL</strong>. At the filesystem level, you work with fixed-size blocks of bytes, organized in some variation of B-TREE structures (depending on the filesystem). Operations happen at the block level. Files are abstractions over collections of blocks.</p>
<p>When you run a <code>chmod -R</code>, which is a recursive permission change, inside a Dockerfile build, for example, even an empty tree will cause recursion on every entry inside, be it hidden files, ACLs, etc. And it needs to look up every single permission of everything in there. That costs network time (because NFS/SMB are network protocols).</p>
<p>That causes latency. Every permission on every file is an individual remote call, an RPC call. Even on the fastest network, like my 10Gbps one, even if each RPC is under 0.1 ms, it quickly becomes a bottleneck. And that&rsquo;s exactly what started happening.</p>
<p>I used <code>sudo nfsiostat 1 10</code> to check. It was showing this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>write: ops/s=2417  kB/s≈295 MiB/s  avg exe=106 ms  avg queue=102 ms</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>An average of 106 ms per RPC. That&rsquo;s an eternity. Average wait time in the queue of 102 ms. Even if I make just 100 RPC calls, it will cost more than 10 seconds in latency alone. And in an OS build, installing packages with thousands of files, that will literally cost an eternity. Running Docker build on NFS/SMB is simply not viable.</p>
<h3>iSCSI<span class="hx:absolute hx:-mt-20" id="iscsi"></span>
    <a href="#iscsi" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Obviously I&rsquo;m not the first to run into this problem and make the mistake of using a file-based protocol for services with high-volume file churn like the Docker cache.</p>
<p>The right move is to create an iSCSI virtual drive on my NAS. If you&rsquo;ve never heard of it, SCSI is an advanced protocol for drives. Cheap PCs in the 90s and 2000s used IDE/ATA, which is a simpler and cheaper protocol. But the first Macs and UNIX workstations from the 80s and 90s always used SCSI, which was vastly superior.</p>
<p>It was <strong>asynchronous</strong>, with queue control. You could send dozens of commands at once and the protocol was smart enough to reorder those commands for better efficiency (for example, skipping a file read if a delete command came before it). IDE was blocking, one command at a time. A single SCSI BUS could handle up to 16 devices. Parallel ATA was 2 per cable. Ridiculous. SCSI had advanced error recovery and reporting systems. It had &ldquo;enterprise&rdquo; features for hot-swap, power management, timeout behaviors, recovery, clustering, etc. And on top of all that it was agnostic to the transport medium, it could be fiber optic, SAS, or even iSCSI, which is SCSI over Ethernet, which is what we&rsquo;re going to use.</p>
<p>IDE/ATA had none of that. It was the protocol for poor-person PC disks. Real workstations, SUN, IRIX, Silicon Graphics, Macs, used SCSI.</p>
<p>More importantly, iSCSI is a block protocol, not a file protocol. Anyway, this varies by NAS manager, but in the case of Synology DSM, which is what I use, it has an application called SAN Manager, which lets you create an iSCSI drive:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/fraxoryhuszmp4cmr0ldc8h7hiip" alt="iSCSI create"  loading="lazy" /></p>
<p>Just follow the wizard, give it a name, choose the size, enable CHAP if you need extra security (I don&rsquo;t, I&rsquo;m on a controlled local network). That creates the iSCSI drive (think of it like a remote USB drive) and a LUN (Logical Unit Number). A LUN is simply one of many potential &ldquo;virtual disks&rdquo; that a target can present. A LUN is like a numbered slot in a target&rsquo;s storage array. A target is a disk server. On a SAN you can have several virtual disks and the way to organize that is with LUNs. In our case there&rsquo;s no real need to go deep on LUN details.</p>
<p>It takes a while, but when it finishes you just go back to my Manjaro Linux and run:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>yay -S open-iscsi
sudo systemctl enable --now iscsi </code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That brings up the service to start automatically on every boot. Now to discover my drive on the network:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code></code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>❯ sudo iscsiadm -m discovery -t sendtargets -p 192.168.0.xx
192.168.0.xx:3260,1 iqn.2000-01.com.synology:TERACHAD.docker.7af6e...
[2804:1b3:....:fe18:3f7d]:3260,1 iqn.2000-01.com.synology:TERACHAD.docker.7af6e...
[fe80::9209:....:3f7d]:3260,1 iqn.2000-01.com.synology:TERACHAD.docker.7af6e...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Every virtual drive has an <strong>IQN</strong> which is like a &ldquo;URL&rdquo; for the drive. Now we log in:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>❯ sudo iscsiadm -m node --login
Login to [iface: default, target: iqn.2000-01.com.synology:TERACHAD.docker.7af6e9116c1, portal: 192.168.0.21,3260] successful.</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Since I didn&rsquo;t enable CHAP it doesn&rsquo;t ask for a password or anything (in a company it should be enabled, obviously). And done. When you do this I hear on GNOME the same little sound as when you plug a pen drive into the PC and it automatically shows up as a disk. If you run <code>lsblk</code>, it&rsquo;ll show up as a normal <code>/dev/sdX</code> drive. In the GNOME Disks app (or in Windows Disk Management) it really does show up as if it were any other hard drive or SSD. It doesn&rsquo;t behave like a shared folder, it behaves like a real drive. You even have to format it, just like a new pen drive.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/nvgrl3bov311vl4h251dcj1wvchz" alt="Disks"  loading="lazy" /></p>
<p>The formatting itself takes quite a while, because in the process it has to check and map block by block. Since it&rsquo;s over the network, it&rsquo;s slower than on a real drive, obviously. But it&rsquo;s a one-time thing. Even on my 10Gbps network it&rsquo;ll format at around 120 MB/s, and on a 2TB drive that comes out to around 2 hours for a fast format. I forgot you could format with &ldquo;lazy&rdquo;:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>mkfs.ext4 -v -E lazy_itable_init=1,lazy_journal_init=1 /dev/sdX</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Leaving that for next time, now I wait 2 hours. To make sure it mounts automatically at boot, you need to check its <code>node.startup</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>❯ sudo iscsiadm -m node -o show | grep -E &#39;Target:|node.startup&#39;

node.startup = manual
node.startup = manual
node.startup = manual</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It&rsquo;s set to &ldquo;manual&rdquo;, to switch it to &ldquo;automatic&rdquo; you do:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>❯ sudo iscsiadm -m node \
  -T iqn.2000-01.com.synology:TERACHAD.docker.7af6... \
  -o update -n node.startup -v automatic</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With the iSCSI daemon running and after logging in, besides <code>/dev/sda</code>, if you run <code>ls /dev/disk/by-path</code> you&rsquo;ll find something like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>ip-192.168.0.xx:3260-iscsi-iqn.2000-01.com.synology:TERACHAD.docker.7af6exxxxx-lun-1 -&gt; ../../sdX</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Since I&rsquo;m using the AUTOFS service and it &ldquo;steals&rdquo; my <code>/mnt</code> directory, I&rsquo;m going to mount the iSCSI drive via fstab at <code>/media/docker</code> by adding this line at the end of <code>/etc/fstab</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>...
/dev/disk/by-path/ip-192.168.0.xx:3260-iscsi-iqn.2000-01.com.synology:TERACHAD.docker.7af6e9xxxx-lun-1  /media/docker  ext4      _netdev,nofail,x-systemd.automount,x-systemd.requires=iscsid.service,noatime,nodiratime,commit=60  0 2</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then just reload and mount:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>sudo systemctl daemon-reload
sudo mount -a</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And that&rsquo;s it. From now on I have a &ldquo;virtual drive&rdquo; on my remote NAS. Now, let&rsquo;s switch Docker over to start using it. I can reconfigure my Docker to cache images and volumes and everything else directly on that drive. Just edit the <code>/etc/docker/daemon.json</code> file with the mount point:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>{
  &#34;data-root&#34;: &#34;/media/docker/docker&#34;
}</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Don&rsquo;t forget to create the directory on the new drive with the right permissions (and it does accept permissions, because it&rsquo;s a drive formatted with ext4 like any other):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>sudo mkdir -p /media/docker/docker
sudo chown root:docker /media/docker/docker
sudo chmod 771 /media/docker/docker</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Finally, just restart the Docker service, wipe out <code>/usr/lib/docker</code> to free up space on my main NVMe, and done. It should be WAY faster than NFS, though not as fast as a local disk, of course. Even at 10Gbps, the network introduces latency, there&rsquo;s no way around it. But the heaviest part is really the build process, which has a lot of writes. For loading an already-built image, it should be blazing fast. But at least by using a block protocol instead of a file protocol, latency should drop by at least an order of 10x or more.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/xh40fu275xx9q6os00m6j5gyr9w9" alt="docker build"  loading="lazy" /></p>
<p>And is it really faster? <strong>YES</strong>. In this screenshot of the docker build running, on NFS the times on the right were HUNDREDS OF SECONDS for many commands. Now they&rsquo;re back below 1 second like they should be. For all intents and purposes, I&rsquo;m not feeling any difference from what a USB 3.2 Gen 2 external HD would give me.</p>
<p>The MASSIVE difference that a BLOCK-LEVEL virtual drive makes, compared to a FILE-LEVEL NFS/SMB protocol, is brutal, the protocol is gigantic overhead. Incidentally, this is more or less how it works when you go to AWS and hire EBS storage (Elastic BLOCK Store), that&rsquo;s why it&rsquo;s called &ldquo;BLOCK&rdquo;, because it&rsquo;s a BLOCK protocol like iSCSI and not NFS. I hope you learned how much of a difference it makes to understand how protocols and filesystems work. If you haven&rsquo;t watched my videos, there&rsquo;s <a href="https://www.youtube.com/watch?v=lxjBgxmDZAI&amp;list=PLdsnXVqbHDUcM0LTAxqrVrTy6Q7jQprjt&amp;pp=gAQB"target="_blank" rel="noopener">this playlist</a> that explains BLOCKS in detail.</p>
<p>But yes, iSCSI has downsides: being a drive, it cannot, or should not, be shared (think of two PCs sharing the same pen drive, it would be a huge sh*tshow). Block devices were built to be used by a single device. Precisely for sharing, that&rsquo;s why protocols like SMB or NFS exist: to manage concurrent access to the same file over the network.</p>
<p>Another downside: the virtual drive is one big BLOB of bits. Without &ldquo;mounting&rdquo; it, there&rsquo;s no way to see the files. On the NAS server, it doesn&rsquo;t show the files. It&rsquo;s like a VirtualBox .vhd file. You need to &ldquo;turn it on&rdquo; and &ldquo;mount&rdquo; it on the &ldquo;PC&rdquo;. That&rsquo;s why I&rsquo;m still going to use NFS for the usual stuff, like Videos or Downloads. And the virtual drive is exclusively for the Docker cache, in this case. That works well. No other PC accesses it. If I had a second Linux machine that needed a Docker cache, I&rsquo;d have to create a second LUN just for it. Remember, it&rsquo;s a drive. Virtual or not, it behaves like a real drive to your system.</p>
]]></content:encoded><category>homelab</category><category>storage-and-backup</category><category>performance</category></item><item><title>Configuring My Synology NAS with NFS on Linux</title><link>https://akitaonrails.github.io/en/2025/04/17/configuring-my-synology-nas-with-nfs-on-linux/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2025/04/17/configuring-my-synology-nas-with-nfs-on-linux/</guid><pubDate>Thu, 17 Apr 2025 20:40:00 -0300</pubDate><description>&lt;p&gt;3 posts on the same day, but I took the day off to fix my Linux problems, so here is one more note for my future self.&lt;/p&gt;
&lt;p&gt;Everyone who follows me, particularly on &lt;a href="https://www.instagram.com/akitaonrails/"target="_blank" rel="noopener"&gt;Instagram&lt;/a&gt;, knows about my NAS saga. In particular my new Synology DS1821+ with more than &lt;strong&gt;100TB&lt;/strong&gt; (4x 12TB HDs and 4x 20TB HDs, and I already have 4 more 20TB HDs to upgrade in the future when I need to). I use it for a lot of stuff, particularly my &lt;a href="https://www.akitaonrails.com/2024/04/03/meu-netflix-pessoal-com-docker-compose"target="_blank" rel="noopener"&gt;Personal Netflix&lt;/a&gt; that I explained in that other post on how I set it up with Docker.&lt;/p&gt;</description><content:encoded><![CDATA[<p>3 posts on the same day, but I took the day off to fix my Linux problems, so here is one more note for my future self.</p>
<p>Everyone who follows me, particularly on <a href="https://www.instagram.com/akitaonrails/"target="_blank" rel="noopener">Instagram</a>, knows about my NAS saga. In particular my new Synology DS1821+ with more than <strong>100TB</strong> (4x 12TB HDs and 4x 20TB HDs, and I already have 4 more 20TB HDs to upgrade in the future when I need to). I use it for a lot of stuff, particularly my <a href="https://www.akitaonrails.com/2024/04/03/meu-netflix-pessoal-com-docker-compose"target="_blank" rel="noopener">Personal Netflix</a> that I explained in that other post on how I set it up with Docker.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/4egervwxarfvls2uw3kg5k13i3hd" alt="Synology DSM"  loading="lazy" /></p>
<p>In the highlights and posts about it on Insta I already discussed details of this NAS, but to summarize:</p>
<ul>
<li>I prefer Synology because deep down it is a Linux box with the BTRFS filesystem (which I like), which is almost as flexible but easier to use than ZFS. And yes, I know unraid, TrueNAS and others exist. But I think Synology DSM is very competent (there is an open source version if you want), and it does everything I need.</li>
<li>I prefer Plex over Emby or Jellyfin. I have tested them, but Plex&rsquo;s support for TV and mobile apps is much better in my opinion, the player is more stable, and I also think its organization features are more robust.</li>
<li>Why not just use Cloud? First because it is slow. My fiber is only 1Gbps and upload is half of that. Second because my internal network is ultra fast (10Gbps) and much better. Third because nothing in the &ldquo;Cloud&rdquo; has any guarantee, they can block you, they can get hacked, they can delete things by accident. Fourth, everything that is mine, I like it being <strong>really</strong> mine, within my reach, under my total control, with nobody to mess with my property. It costs more, it is a choice. I literally have more and better movies and series than Netflix with content you will not easily find in any streaming service anymore, for example.</li>
</ul>
<p>In fact, I trust &ldquo;cloud&rdquo; so little that I use it but I keep a copy of everything on my local NAS using the Cloud Sync app from the Synology store:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/deednjd3ii774b5b7jdxu1azox3x" alt="Cloud Sync"  loading="lazy" /></p>
<p><strong>&ldquo;Yeah, but what if your NAS dies in some unrecoverable way and you lose EVERYTHING?!&rdquo;</strong></p>
<p>No backup is complete unless it follows the <strong>3-2-1 Mantra</strong>, remember??</p>
<ul>
<li><strong>3 copies, 2 media types, 1 off-site</strong></li>
</ul>
<p>I have some things recorded on Millennial Discs (mdiscs, which are like Blu-Rays but made of inorganic material that never degrades). And I make copies of the most important things from my NAS to Amazon Glacier using the &ldquo;Glacier Backup&rdquo; app that is also in the Synology store.</p>
<p>Glacier is like S3 but much cheaper because it does not have easy access or fast transfer. It is built for backups, where you write once and almost never access again. <strong>ENCRYPTED</strong>, of course.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/y83uw9rtu8p5hgsq0ugyanb5cibj" alt="Glacier Backup"  loading="lazy" /></p>
<p>Notice how little I trust cloud, like dropbox, that I make a copy on my NAS and send it to another cloud. There are things with multiple copies indeed. I do not trust anyone&rsquo;s service, some human will always commit a catastrophic mistake one day, it is just a matter of time.</p>
<p>Oh yes, and I do download a lot of things from torrent and I consider myself a Power User and I know what is malware/virus and what is not, and I know how they work. But I am also human, so all my Windows machines have at least Windows Defender active, Malwarebytes which I also like, and even so I still run yet another antivirus on the NAS as well, which is also in the Synology store. I do not trust anything without redundancy of everything, under my total control.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/bekpbla77t4o4uxcysqks18racwc" alt="antivirus"  loading="lazy" /></p>
<p>More importantly, and the reason I choose BTRFS both on the NAS and on my PC: <strong>snapshots</strong>. It takes snapshots of everything periodically. So, if at some point I run a botched <code>rm -Rf</code>, I lose nothing, I just go back to the previous snapshot and everything will be there, intact. Snapshots are smart, they take almost no space, because in BTRFS the same binary content of identical files points to the same physical location. It does not work like <code>rsync</code> that actually duplicates everything and doubles the space used.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/zhbq7dv8ai8jupqwj3zqvmetq5yk" alt="Synology Snapshots"  loading="lazy" /></p>
<p>On your Linux PC/notebook, with BTRFS, install the Timeshift program to do the same thing. The best part: if one day you update your system and it does not boot or something breaks after an update, it is easy: just reboot and choose the previous snapshot in GRUB and that is it, it will go back exactly as it was before the update. On Arch/Manjaro it is smart and already takes snapshots automatically every time before <code>pacman</code> runs:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/n20yo00ztofrjsu6p6678wmfphdd" alt="Timeshift"  loading="lazy" /></p>
<p>Anyway, another use is for personal code projects on my Linux. On the mini-PC I use on the TV as a media center, I run Windows 11. Because I use it connected to another eGPU with an RTX 4060 and I use Steam for games. Yes, many Steam games run on Linux, there is Bazzite, etc. But there are things that only run on Windows and I preferred convenience. I only use it for games and media, so screw it, it works plug and play.</p>
<p>My other home server mini-PC is where my Docker stuff for media center, torrent, plex server and everything else runs. It is a headless Ubuntu LTS. Plus my handhelds like the Rog Ally X that runs Windows. All of them have access to my Synology NAS, where I expose shared folders with SMB and that is it. Plug and play.</p>
<p>I used to do the same on my Manjaro Linux: mount the folders with SMB. But this has a problem if I want to code something and want to use Git: in SMB there are no chmod attributes (like the +x flag for executable scripts) and every time I do git push, it messes up all the permissions of everything. It is a huge pain. Anyone who has used Git on Windows knows this.</p>
<p>I cannot keep scripts executable in this shared directory. So it makes my life hard on Linux. So I decided to fix this once and for all and do the right thing: stop using SMB and start using NFS, which is the native Linux way to access shared folders on the network from another Linux server (if the server were Windows, then it would have to be SMB, but nobody in their right mind sets up a NAS with Windows Server).</p>
<p>In SMB, the service ignores filesystem ownership and permissions. I create users and assign them to the service, not to the files. Just log in with that user and it will have access to the files. So there is no way to do <code>chown</code> and change ownership, nor <code>chmod</code> to change permissions individually on the files, it is all normalized.</p>
<p>In NFS, the service exposes exactly the ownership and permissions on the filesystem directly, without an ACL middleman to check permissions on the side. So I have the same level of access to those attributes that I have on the local files of my Linux, which is what I want.</p>
<p>But Synology does an annoying thing for some reason. On any Linux, the first user and first group created are usually UID: 1000 and GID: 1000. Try it, go to your terminal and run <code>id [your user]</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>❯ id akitaonrails
uid=1000(akitaonrails) gid=1000(akitaonrails) groups=1000(akitaonrails),998(wheel),996(audio),994(input),...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>So it does not matter whether you do <code>chown akitaonrails:akitaonrails foo.txt</code> or <code>chown 1000:1000 foo.txt</code>. In practice, the identifier number is what matters, not the name.</p>
<p>With NFS, if on the Synology side there is a user with the same UID and GID number, I automatically have permission. But when you create users on Synology, for whatever reason, it starts creating them with IDs above <strong>1020 instead of 1000</strong>. So I have an &ldquo;akitaonrails&rdquo; user on Synology with UID 1026 instead of 1000.</p>
<p>Even being able to mount the shared folder with NFS, if I try to use Git, for example, it detects that my local UID is 1000 and the files have ownership of UID 1026:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>git st fatal: detected dubious ownership in repository at &#39;/mnt/gigachad/Projects/dotfiles&#39; To add an exception for this directory, call: 

	git config --global --add safe.directory /mnt/gigachad/Projects/dotfiles</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>I cannot keep using mixed UIDs, it becomes a mess as bad as SMB. There are mapping tricks and other hacks, but there are only two &ldquo;cleaner&rdquo; solutions:</p>
<ul>
<li>change my Linux UID/GID to also be 1026</li>
<li>create a new user on Synology, change its ID to 1000 and chown all files to this new user.</li>
</ul>
<p>I chose the second option. The first thing is to enable SSH access on the NAS (the ideal is to leave such things off, for security, but I am on a private local network so the risk is minimal):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/jpanifwk7guaxaekpoph0hty61ve" alt="SSH Synology"  loading="lazy" /></p>
<p>At a minimum I always change the default SSH port, out of habit, but it does not really matter. You need to know how to use SSH, I will not teach it here, there is ChatGPT for that.</p>
<p>Now, create a new standard user:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/l6surkfy60whkaqri8m2rzqrq7d2" alt="Novo Usuário"  loading="lazy" /></p>
<p>Let&rsquo;s also make sure the NFS service is on, since I am going to start using it (by default it comes off, with only SMB on because the majority of clients are Windows PCs):</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/tpyix3dfkc0h2i4x4iaad99djffj" alt="Enable NFS"  loading="lazy" /></p>
<p>Now SSH into the NAS and edit the UID and GID, first by editing <code>/etc/passwd</code> and finding a line like:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>akitaonrails-nfs:x:10xx:10xx:...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And changing it to:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>akitaonrails-nfs:x:1000:1000:...</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Now editing <code>/etc/group</code> and the same thing, changing the newly created <code>akitaonrails-nfs</code> group to <code>1000</code> as well. Done, now I have a user with UID 1000 and GID 1000 created. The name is not the same as on my PC but that does not matter, only the numbers.</p>
<p>In the Synology DSM GUI, do not forget to add permissions for each shared folder, for NFS:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/x5q0fmb5u6qxd7ahe7f7nquoy73k" alt="NFS permissions"  loading="lazy" /></p>
<p>Still over SSH, you have to change the ownership and fix the permissions of <strong>ALL FILES</strong> and in my case, with terabytes, it took QUITE a while, just leave it running there:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>sudo chown -R 1000:1000 /volume1/GIGACHAD
sudo chmod -R 0755 /volume1/GIGACHAD</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>If I want to disconnect from SSH and leave it running there (since it is going to take a long time), just run it detached from the SSH session with <code>nohup</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>nohup chown -R 1000:1000 /volume1/GIGACHAD &gt; /tmp/chown.log 2&gt;&amp;1 &amp;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>One thing to be careful about is not doing something like <code>chmod -R -x</code> on everything. First: directories need to have the execute bit and if you remove it, you will no longer be able to navigate directories, so you have to separate fixing directories from files. Second: it is good to apply this fix on directories to make sure no files were left with the executable bit (which can create security problems, imagine random files inside Node modules directories). Third: directories like &ldquo;#snapshot&rdquo; are for filesystem control and are read-only, trying to mess with them will only eat A LOT of time, so it is better to filter them out and not touch them.</p>
<p>Or make a script with this and run it with nohup so you can leave SSH while waiting for it to run (in my case, with terabytes, it takes hours):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>nohup /tmp/fix-terachad-perms.sh &gt; /dev/null 2&gt;&amp;1 &amp;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>There are still some hidden little problems. Still on the Synology SSH you have to change the <code>/etc/exports</code> that NFS uses because it still maps by default to user UID 1026 and 100, you have to change both to 1000:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code># old
sudo cat /etc/exports
Password:

/volume1/GIGACHAD       192.168.0.0/24(rw,async,no_wdelay,no_root_squash,insecure_locks,sec=sys,anonuid=1025,anongid=100)
/volume1/TERACHAD       192.168.0.0/24(rw,async,no_wdelay,crossmnt,no_root_squash,insecure_locks,sec=sys,anonuid=1025,anongid=100</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The problem with messing with system files like this is that you do not know if touching the NFS permissions GUI, or if updates come, will revert it to how it was before. If anyone knows of a more permanent solution, let me know.</p>
<p>And done, with all this, just unconfigure the CIFS/SMB configuration I had and replace it with NFS. I prefer to use <a href="https://wiki.archlinux.org/title/Autofs"target="_blank" rel="noopener">AutoFS</a> to do this. I had an <code>auto.cifs</code> before, which I can now delete. And create a new <code>/etc/autofs/auto.nfs</code> like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>gigachad  -fstype=nfs4,rw,nosuid,noatime 192.168.0.xx:/volume1/GIGACHAD
terachad  -fstype=nfs4,rw,nosuid,noatime 192.168.0.xx:/volume1/TERACHAD</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And replace the <code>auto.cifs</code> line in <code>/etc/autofs/auto.master</code> with this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>/mnt  /etc/autofs/auto.nfs --timeout=60 --ghost --browse</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Now restart the service:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>sudo systemctl daemon-reexec
sudo systemctl restart autofs</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And done, everything mounted with NFS and now I have full support for native Linux ownership and permissions, without the overhead and incompatibilities of crappy SMB. It was a bit scary doing chown and chmod en masse, but it is a NAS with BTRFS. It has RAID to handle data corruption, it has snapshots if I need to revert something. That is the advantage of having safety systems: you can do things knowing that if something goes wrong, there is a way to revert. That peace of mind is priceless.</p>
]]></content:encoded><category>homelab</category><category>linux</category><category>networking</category></item><item><title>My "Personal Netflix" with Docker Compose</title><link>https://akitaonrails.github.io/en/2024/04/03/my-personal-netflix-with-docker-compose/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2024/04/03/my-personal-netflix-with-docker-compose/</guid><pubDate>Wed, 03 Apr 2024 13:30:00 -0300</pubDate><description>&lt;p&gt;Anyone who followed my &lt;a href="https://www.youtube.com/channel/UCib793mnUOhWymCh2VJKplQ"target="_blank" rel="noopener"&gt;YouTube&lt;/a&gt; channel or my &lt;a href="https://www.instagram.com/akitaonrails/"target="_blank" rel="noopener"&gt;Instagram&lt;/a&gt; already knows the saga with my &lt;a href="https://instagram.fcgh13-1.fna.fbcdn.net/v/t51.29350-15/285491936_1978927048966317_5629598792539832539_n.jpg?stp=c0.181.965.965a_dst-jpg_e35_s150x150&amp;amp;_nc_ht=instagram.fcgh13-1.fna.fbcdn.net&amp;amp;_nc_cat=109&amp;amp;_nc_ohc=9AzXyFhsnbcAX_Rf1Ei&amp;amp;edm=AGW0Xe4BAAAA&amp;amp;ccb=7-5&amp;amp;oh=00_AfBLLcqehVZYeDCRMQJ_Ont8CcybMrpmtHtoW4UkpV1GJA&amp;amp;oe=660E928F&amp;amp;_nc_sid=94fea1"target="_blank" rel="noopener"&gt;NAS&lt;/a&gt; (my personal server), my &lt;a href="https://instagram.fcgh13-1.fna.fbcdn.net/v/t51.29350-15/332393994_6261434103896443_128632134976019123_n.webp?stp=c170.267.761.761a_dst-jpg_e35_s150x150&amp;amp;_nc_ht=instagram.fcgh13-1.fna.fbcdn.net&amp;amp;_nc_cat=107&amp;amp;_nc_ohc=GxgcgjHRywEAX-oyW9H&amp;amp;edm=AGW0Xe4BAAAA&amp;amp;ccb=7-5&amp;amp;oh=00_AfAsh8cPFO7E4NzEwOw7MeNrMD4UBzFcL3qaeHT7qYLDtA&amp;amp;oe=660E9C85&amp;amp;_nc_sid=94fea1"target="_blank" rel="noopener"&gt;Synology DS1821+&lt;/a&gt; with almost 80 TB of space.&lt;/p&gt;
&lt;p&gt;Every video from my channel, including the original source files, is there. That alone is terabytes. My whole Steam library is there, around 4 terabytes. All my old games, retro games too, including Xbox 360 and PS3 ISOs. That&amp;rsquo;s another 4 terabytes. My entire Ultra HD (4K BluRay) disc collection &lt;a href="https://instagram.fcgh13-1.fna.fbcdn.net/v/t51.29350-15/412635176_915622043464387_8034096537912791825_n.heic?stp=c513.618.363.363a_dst-jpg_e35_s150x150&amp;amp;_nc_ht=instagram.fcgh13-1.fna.fbcdn.net&amp;amp;_nc_cat=107&amp;amp;_nc_ohc=OOeWxOEhySoAX8yISjX&amp;amp;edm=AGW0Xe4BAAAA&amp;amp;ccb=7-5&amp;amp;oh=00_AfAaINFVuRSfg-isKRLy-fmtXvSTVz-d7Axf1d2jQL6ZvQ&amp;amp;oe=660EA292&amp;amp;_nc_sid=94fea1"target="_blank" rel="noopener"&gt;I ripped&lt;/a&gt; (made a backup), a few more terabytes. Right now I&amp;rsquo;m already using more than 50 terabytes.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Anyone who followed my <a href="https://www.youtube.com/channel/UCib793mnUOhWymCh2VJKplQ"target="_blank" rel="noopener">YouTube</a> channel or my <a href="https://www.instagram.com/akitaonrails/"target="_blank" rel="noopener">Instagram</a> already knows the saga with my <a href="https://instagram.fcgh13-1.fna.fbcdn.net/v/t51.29350-15/285491936_1978927048966317_5629598792539832539_n.jpg?stp=c0.181.965.965a_dst-jpg_e35_s150x150&amp;_nc_ht=instagram.fcgh13-1.fna.fbcdn.net&amp;_nc_cat=109&amp;_nc_ohc=9AzXyFhsnbcAX_Rf1Ei&amp;edm=AGW0Xe4BAAAA&amp;ccb=7-5&amp;oh=00_AfBLLcqehVZYeDCRMQJ_Ont8CcybMrpmtHtoW4UkpV1GJA&amp;oe=660E928F&amp;_nc_sid=94fea1"target="_blank" rel="noopener">NAS</a> (my personal server), my <a href="https://instagram.fcgh13-1.fna.fbcdn.net/v/t51.29350-15/332393994_6261434103896443_128632134976019123_n.webp?stp=c170.267.761.761a_dst-jpg_e35_s150x150&amp;_nc_ht=instagram.fcgh13-1.fna.fbcdn.net&amp;_nc_cat=107&amp;_nc_ohc=GxgcgjHRywEAX-oyW9H&amp;edm=AGW0Xe4BAAAA&amp;ccb=7-5&amp;oh=00_AfAsh8cPFO7E4NzEwOw7MeNrMD4UBzFcL3qaeHT7qYLDtA&amp;oe=660E9C85&amp;_nc_sid=94fea1"target="_blank" rel="noopener">Synology DS1821+</a> with almost 80 TB of space.</p>
<p>Every video from my channel, including the original source files, is there. That alone is terabytes. My whole Steam library is there, around 4 terabytes. All my old games, retro games too, including Xbox 360 and PS3 ISOs. That&rsquo;s another 4 terabytes. My entire Ultra HD (4K BluRay) disc collection <a href="https://instagram.fcgh13-1.fna.fbcdn.net/v/t51.29350-15/412635176_915622043464387_8034096537912791825_n.heic?stp=c513.618.363.363a_dst-jpg_e35_s150x150&amp;_nc_ht=instagram.fcgh13-1.fna.fbcdn.net&amp;_nc_cat=107&amp;_nc_ohc=OOeWxOEhySoAX8yISjX&amp;edm=AGW0Xe4BAAAA&amp;ccb=7-5&amp;oh=00_AfAaINFVuRSfg-isKRLy-fmtXvSTVz-d7Axf1d2jQL6ZvQ&amp;oe=660EA292&amp;_nc_sid=94fea1"target="_blank" rel="noopener">I ripped</a> (made a backup), a few more terabytes. Right now I&rsquo;m already using more than 50 terabytes.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/29mxcuiblyunqnnbtc10sdnqjl3f" alt="My Plex with UHDs"  loading="lazy" /></p>
<p>Before anyone jumps in with opinions, yes, this isn&rsquo;t for everyone. We&rsquo;re talking about 8 drives of 10.9 TB, plus an upgrade of 2 drives of 20 TB, plus another 2 drives of 20 TB for backup in case one of them fails, plus 2 NVMEs of 1 TB each just for cache, plus a 10 Gbps network card. On top of that I keep several online copies of part of this content on Google Drive or Dropbox, and a full backup on Amazon Glacier, for the rare occasion my house catches fire, for example. I&rsquo;m always prepared for any catastrophe.</p>
<p>Synology is one of the best home NAS brands. Yes, you can go way more sophisticated too, just build a real server, with CPUs like AMD EPYC ThreadRipper, using systems like <a href="https://www.truenas.com/"target="_blank" rel="noopener">TrueNAS</a>. But that&rsquo;s too much for me, I don&rsquo;t feel comfortable maintaining it. Like I said, everyone has to know their own limits.</p>
<blockquote class="twitter-tweet" data-media-max-width="560"><p lang="pt" dir="ltr">1o HD de 20TB terminou de se integrar ao RAID! Levou 2 dias inteiros!! Colocando o 2o HD ... de 4 😅 <a href="https://t.co/Rv6dqBQr05">pic.twitter.com/Rv6dqBQr05</a></p>— Akitando.com (@AkitaOnRails) <a href="https://twitter.com/AkitaOnRails/status/1772995683915649435?ref_src=twsrc%5Etfw">March 27, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
<p><em>&ldquo;Why not just keep everything online??&rdquo;</em> For most people that really is the easiest path. Like I said, my case is unusual. You do NOT want to have more than 50 TERAbytes online. My local wired network is 10 Gbps, my fiber internet is a measly 0.5 Gbps (500 Mbps). Streaming high quality video over the internet is horribly slow. I want everything in real time, and for that it has to be a local wired network.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/hejrhguxmbprma7c02hsp4mpr2rq" alt="Synology Dashboard"  loading="lazy" /></p>
<p>That said, I built a second mini-computer, using an Intel NUC, connected to the same wired network as the NAS. It&rsquo;s a Core i7, 32GB of RAM, 2.5Gbps ethernet, with the single purpose of being an empty Ubuntu server (it could be any Linux, I just picked the easiest one) whose job is to run Docker containers.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/byblhfmjthild3vjq4kyif974vt2" alt="Intel Nuc Core i7"  loading="lazy" /></p>
<p>Even this little PC is overkill, way more than necessary. Many will comment that a Raspberry Pi 5 would be enough, and they&rsquo;re not wrong. The best solution is the one you can afford and feel comfortable maintaining. Don&rsquo;t try to copy anyone&rsquo;s setup exactly, study what fits you best. And don&rsquo;t waste time bikeshedding other people&rsquo;s setups, build your own.</p>
<p>Anyway, I decided to share with you all the docker-compose files I&rsquo;m using to bring up the docker containers on this server. Here&rsquo;s the <a href="https://github.com/akitaonrails/plex_home_server_docker"target="_blank" rel="noopener">GitHub repository</a>.</p>
<p><em>WARNING:</em> don&rsquo;t forget you need to edit the directories in the scripts. It&rsquo;s configured to access my NAS network mount point, which is <code>/mnt/terachad</code>. Fix it to point to the right places on your machine.</p>
<p><em>WARNING 2:</em> there are a lot of details I&rsquo;m not covering in this article. This <a href="https://www.reddit.com/r/pirataria/comments/18ch7bt/guia_do_streaming_dom%C3%A9stico_automatizado_sonarr/"target="_blank" rel="noopener">Reddit forum</a> has more details and more discussion. Depending on your specific doubts or problems, it may be answered there.</p>
<p>So you don&rsquo;t get lost in the middle of so many services, here&rsquo;s the full picture of what we&rsquo;re going to build, from the request all the way to watching on the TV, including the protection layers that keep malware and wrong downloads out of your library:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260905220142_home-server-arquitetura-en.png" alt="Full architecture of the Plex home server, from request to playback"  loading="lazy" /></p>
<p>The idea is this: you request in one place (Seerr), the system finds it (Prowlarr and the indexers), downloads it with protection (qBittorrent and SABnzbd, plus the security layers), organizes it on the NAS (with subtitles from Bazarr) and you watch it on any screen (Plex and Navidrome). Let me start explaining the main ones one by one.</p>
<h2>Portainer and Utilities<span class="hx:absolute hx:-mt-20" id="portainer-and-utilities"></span>
    <a href="#portainer-and-utilities" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/4g7nani6heneielnqirwt8xba9iq" alt="Portainer Dashboard"  loading="lazy" /></p>
<p>Here&rsquo;s the snippet (see the full thing in the <a href="https://github.com/akitaonrails/plex_home_server_docker/blob/master/kaizoku-docker-compose.yml"target="_blank" rel="noopener">repository</a>):</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">portainer_data</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">portainer</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">portainer/portainer-ce:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">portainer_data:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/var/run/docker.sock:/var/run/docker.sock</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s1">&#39;9000:9000&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><a href="https://www.portainer.io/"target="_blank" rel="noopener">Portainer</a> is a visual manager for the resources managed by the Docker service on your machine. Its purpose is more for me to visually &ldquo;glance&rdquo; and check if any container has a problem, or to test some quick little config tweak before editing the docker compose file.</p>
<p>In the <a href="https://github.com/akitaonrails/plex_home_server_docker/blob/master/utils-docker-compose.yml"target="_blank" rel="noopener">utils-docker-compose.yml</a> file there are other services like Organizr that I installed but haven&rsquo;t stopped to configure yet, nor decided if I really want to keep. There&rsquo;s Librespeed, which works like those internet speed tests, but for the local network. There&rsquo;s <a href="https://github.com/containrrr/watchtower"target="_blank" rel="noopener">Watchtower</a>, which monitors image updates for all Docker containers, downloads them and automatically restarts with new versions, to keep all containers always up to date.</p>
<p>And to reach your local services from outside home, there are easy VPN options like <a href="https://tailscale.com/"target="_blank" rel="noopener">Tailscale</a> or <a href="https://www.zerotier.com/"target="_blank" rel="noopener">ZeroTier</a>, with little configuration. Yes, yes, before someone runs to the comments, you can set up OpenVPN from scratch, but trust me, these options are infinitely more &ldquo;plug and play&rdquo; and possibly safer, because everyone installs it, but few have the discipline to keep it maintained and updated all the time. These days I actually ended up moving to a Cloudflare tunnel to expose my services, but to get started, a Tailscale does the job really well.</p>
<h2>MakeMKV and HandBrake<span class="hx:absolute hx:-mt-20" id="makemkv-and-handbrake"></span>
    <a href="#makemkv-and-handbrake" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To build your personal movie library, legitimately, the correct way is to own BluRay or UHD discs and &ldquo;rip&rdquo; them. If that&rsquo;s your case, I recommend buying disc reader models like the <a href="https://www.amazon.com/Pioneer-BDR-XS07S-Silver-Revision-Blu-ray/dp/B081R74KVW"target="_blank" rel="noopener">Pioneer BDR-XS07S</a>. You have to be VERY careful because not every reader works.</p>
<p>Check the <a href="https://forum.makemkv.com/forum/viewforum.php?f=16"target="_blank" rel="noopener">MakeMKV forum</a> for the most recommended models. Some models got firmware updates that stop them from being used to rip BluRay. You need to find models that either weren&rsquo;t updated, or that aren&rsquo;t hard to firmware-downgrade. The ideal is to buy models everyone already knows work, and for that you have to dig through forums like that one.</p>
<p><a href="https://www.makemkv.com/"target="_blank" rel="noopener">MakeMKV</a>, as the name says, &ldquo;Makes MKV video files&rdquo;, which is the good old Matroska video envelope format, one of the most versatile (because it supports multiple streams not just of audio, but of subtitles too).</p>
<p><em>Crash course:</em> one thing is the codec that does the encoding of the video streams and audio streams. For example, H.265 or AV1 are video codecs. MP3 or AAC are audio codecs. Now we need to package these two streams (or more, if there are multiple dubs, for example) into an &ldquo;envelope&rdquo;, a &ldquo;container&rdquo;, a &ldquo;file format&rdquo;, like &ldquo;.mp4&rdquo; or &ldquo;.mov&rdquo; files. Matroska is a file envelope format that&rsquo;s very flexible, allows multiple streams of everything, including subtitles, which is why it&rsquo;s so widely used for sharing over torrent.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/0swbtawzxepqgd4r88cfnbg97ne9" alt="MakeMKV"  loading="lazy" /></p>
<p>With this container, you can access the app&rsquo;s graphical interface through the browser (it&rsquo;s like a remote Windows client, like VNC). Just have the Blu-Ray reader connected via USB to your computer and it should find it. It will use the <a href="https://forum.makemkv.com/forum/viewtopic.php?t=18856"target="_blank" rel="noopener">libredrive</a> library to decrypt the Blu-Ray data streams, which is why the firmware versions being compatible (already cracked) matters.</p>
<p>Many people don&rsquo;t know that we call everything &ldquo;Blu-Ray&rdquo; but in reality Blu-Ray is for movies encoded in Full HD, which is 1080p. The 4K versions (2160p) come as UHD, which is <strong>Ultra HD</strong>. The Blu-Ray case is usually blue, the UHD ones are black, you want the UHD ones whenever possible, of course. And I have a huge collection:</p>
<blockquote class="twitter-tweet" data-media-max-width="560"><p lang="pt" dir="ltr">"onde eu tô gastando tanto espaço??"<br><br>Boa parte é fazendo Rip (backup) de todos os meus UHD (BluRay 4K). E isso porque estou re-encodando em bitrate menor depois de ripar kkkk <a href="https://t.co/T1yPa6EIcV">pic.twitter.com/T1yPa6EIcV</a></p>— Akitando.com (@AkitaOnRails) <a href="https://twitter.com/AkitaOnRails/status/1772698430063731135?ref_src=twsrc%5Etfw">March 26, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
<p>The advantage of an app like MakeMKV is being able to decrypt the video stream and copy it bit by bit exactly as it is on the UHD, the best quality possible. This is what&rsquo;s called REMUX (remuxing), which is extracting the stream from the container and putting it in another container without re-encoding. You&rsquo;ve probably seen it in some torrent files, it&rsquo;s the best quality. But it means it will use the same space as on the disc, a range of 50 to 80 GIGAbytes per movie. Every dozen movies will eat up almost 1 terabyte. Depending on how many movies you want to have, your free space will run out very fast.</p>
<p>To minimize this, we can recode or &ldquo;re-encode&rdquo;. It&rsquo;s like when we take a RAW photo and re-encode it to a lower quality format, like PNG or even JPEG. If you know where to look, you&rsquo;ll find the compression artifacts, but overall, it&rsquo;s worth it. In my case, for example, encoding to H.265 10-bit, QP 15 Constant Quality.</p>
<p>The best option is to have an NVIDIA graphics card, with hardware video encoding support via NVENC, and use the best video encoding software: <a href="https://handbrake.fr/"target="_blank" rel="noopener">Handbrake</a>. In my case, I pick the pre-configured <strong>Matroska H.265 MKV 4K60fps</strong> profile and switch it to use the NVENC encoder on my RTX 4090, which speeds up encoding several times over.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/vcjscocbej7d64mo6gnvu97rcoiz" alt="HandBrake"  loading="lazy" /></p>
<p>In docker compose, just like MakeMKV, I can bring up this app for browser access, like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">handbrake</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">jlesage/handbrake</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;5801:5800&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">AUTOMATED_CONVERSION=0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">HANDBRAKE_GUI=1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DARK_MODE=1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=America/Sao_Paulo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;/home/akitaonrails/handbrake:/config:rw&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;/home/akitaonrails:/storage:ro&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;/mnt/terachad/Videos/BluRay:/watch:rw&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;/mnt/terachad/Videos/BluRayOptimized:/output:rw&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/dev/dri:/dev/dri</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Your PC has at least an Intel graphics card with hardware encoding support via QSV, or better, an NVIDIA one with NVENC support. In both cases, we give the container access to that by mapping the <code>/dev/dri</code> device. Without it, encoding goes through the CPU, and it takes orders of magnitude longer.</p>
<p>Keep in mind that software encoding is slower but has better quality than Intel QSV, which, despite being fast, always gives me inferior video quality. NVENC is the best of both worlds: excellent quality and very fast.</p>
<p><strong>WARNING:</strong> in my case, I want the best quality possible, that&rsquo;s why I back up UHDs, that&rsquo;s why even over torrent I download BR-DISC, which is a UHD backup, and that&rsquo;s absurdly heavy as I explained. So I manually redo the encoding to the Matroska H.265 profile, as I also explained.</p>
<p>Many people don&rsquo;t want this hassle, so be sure to read the <a href="https://www.reddit.com/r/pirataria/comments/18ch7bt/guia_do_streaming_dom%C3%A9stico_automatizado_sonarr/"target="_blank" rel="noopener">Reddit forum</a>, which explains how to configure the next services to not download huge files like this. I won&rsquo;t show it here, because it&rsquo;s not my case.</p>
<h2>QBitTorrent<span class="hx:absolute hx:-mt-20" id="qbittorrent"></span>
    <a href="#qbittorrent" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No matter how much Blu-Ray and UHD you own or can buy, a lot of stuff doesn&rsquo;t even exist on physical disc. In that case, your only option is BitTorrent. And yes, piracy is <strong>illegal</strong>, I am <strong>not encouraging piracy</strong> blah blah blah&hellip;</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/ar374pd43442x45030482kqhzq17" alt="QBitTorrent"  loading="lazy" /></p>
<p>Every country has its own law, go research it. I&rsquo;ll assume everyone knows what they&rsquo;re doing and it&rsquo;s not my problem. No point commenting about this, I won&rsquo;t go on about it. The web is already stuffed with material about it, go to Google and search yourself.</p>
<p>The first thing we want is to install <a href="https://www.qbittorrent.org/"target="_blank" rel="noopener">QBitTorrent</a>, probably the best and most complete app to download torrents, which also has a web interface and works via Docker:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">qbittorrent</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">linuxserver/qbittorrent:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=America/Sao_Paulo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">WEBUI_PORT=8080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/qbittorrent/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Downloads/torrents:/downloads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s1">&#39;8080:8080&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s1">&#39;62609:62609&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It&rsquo;s important to remember to generate a random port in the QBitTorrent settings, as shown in the image below. Any port between 1025 and 65535 should work, but your network might block some port range in that interval, so you have to test. Search web forums, and be careful because a lot of online tips are already outdated.</p>
<p>You need to understand the minimum about networking, understand whether you&rsquo;re behind a NAT, how to map external ports, whether you have an active firewall blocking ports, etc.</p>
<p>If the torrent isn&rsquo;t downloading, &ldquo;it might be&rdquo; this. I put it in quotes because it&rsquo;s only a possibility. So click &ldquo;random&rdquo; to pick another port, edit the docker compose config to map the new port and restart the container.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/vb3jnft1drvqsjjreo2bycp1y0e5" alt="QBitTorrent Port Listen"  loading="lazy" /></p>
<p>For some reason the saving management comes all set to Manual, it&rsquo;s important to change it to automatic:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/jqk5vdpxthahdwi2q5y1edgyxxa6" alt="QBitTorrent Saving Management"  loading="lazy" /></p>
<p>Another detail is to tell QBitTorrent to re-announce downloads to the trackers when the port changes. For that, enable the &ldquo;Reannounce to all trackers when IP or port changed&rdquo; option, otherwise downloads might not restart.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/yoo525n36cc08u6mijoxzwpgg79e" alt="QBitTorrent Reannounce"  loading="lazy" /></p>
<p>Now the most important setting in this whole section, and one I learned the hard way: <strong>block executables at download time</strong>. One fine day I found nine &ldquo;completed&rdquo; torrents in my library whose only content was a Windows executable of almost 1 GB, no video at all. And worse, the release name was squeaky clean, stuff like <code>Reacher S04E07 1080p WEB H264-CAKES</code> or <code>Rick and Morty S09E10 ... EDITH.scr</code>. You look at the name and swear it&rsquo;s the episode. The <code>.exe</code> or <code>.scr</code> was hidden in the file inside.</p>
<p>A name filter doesn&rsquo;t catch this, because the name is clean. The defense that actually works is blocking the extension at write time. In QBitTorrent, go to Options, Downloads, and enable &ldquo;Excluded file names&rdquo; with this list:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>*.exe
*.scr
*.bat
*.cmd
*.msi
*.lnk
*.com
*.vbs
*.pif</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>With that, the malicious file simply never gets written to disk. The torrent &ldquo;completes&rdquo; with nothing useful, Sonarr or Radarr notice the import failed, dump that release into the blocklist and go looking for another one on their own. The malware never touches the disk, and you don&rsquo;t even pass the garbage forward by seeding it. This is the first layer of defense, and the most effective.</p>
<p>Finally, how do I search for things to download? I can manually go to Google or DuckDuckGo or straight to sites like the PirateBays of the world and look for &ldquo;.torrent&rdquo; files or magnet links. To understand what that is, watch my video about <a href="https://www.youtube.com/watch?v=iAA8NrfQtHo"target="_blank" rel="noopener">Cryptography in Practice</a>, where I explain everything about Torrent too.</p>
<p>QBitTorrent supports search plugins. It comes with a few enabled. In the bottom right corner, there&rsquo;s the &ldquo;Search plugins&rdquo; button. We want to add the plugin that covers everything: the <a href="https://github.com/qbittorrent/search-plugins/wiki/How-to-configure-Jackett-plugin"target="_blank" rel="noopener">Jackett</a> service.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/dvndok2h1lgavk0igv24dazi2pwp" alt="Jackett plugin"  loading="lazy" /></p>
<p>Notice it wants a host named &ldquo;jackett&rdquo; enabled on port 9117, and for that we have this snippet in the docker compose:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">jackett</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/linuxserver/jackett:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=America/Sao_Paulo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/jackett/downloads:/downloads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/jackett/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s1">&#39;9117:9117&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It brings up a service where we can access the web interface like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/p2f8xj5cemh9g48f8sayg52os2ts" alt="Jackett Web UI"  loading="lazy" /></p>
<p>It&rsquo;s a service that consolidates dozens of torrent search sites in one place, can test whether they&rsquo;re online and, more importantly, provides an API key in the top right corner, which we should copy and edit into the QBitTorrent <a href="https://github.com/qbittorrent/search-plugins/wiki/How-to-configure-Jackett-plugin"target="_blank" rel="noopener">plugin config</a>. With that you can search directly, getting results like this:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/q8tbrlvwuoj2ilehs05kbfj40cs4" alt="QBitTorrent Search Engine"  loading="lazy" /></p>
<p>Notice in the right column that lists where these results were pulled from: it&rsquo;s coming from the Jackett service.</p>
<p>An important heads-up, because this changed over time: Jackett here is for THAT, the manual searches you do yourself in QBitTorrent. Don&rsquo;t fall for the temptation of wiring Jackett straight into Sonarr and Radarr. The automated part is going to be managed by Prowlarr, which I explain later, and it&rsquo;s Prowlarr that feeds the services&rsquo; indexers. I keep Jackett running only for manual searches and, at most, as a bridge for some tracker Prowlarr doesn&rsquo;t support natively, like RuTracker, which Prowlarr can consume through Jackett.</p>
<h2>Radarr (Movies)<span class="hx:absolute hx:-mt-20" id="radarr-movies"></span>
    <a href="#radarr-movies" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Searching manually works, but there are better ways: let a service do the searching and manage the download for you, and to download movies, we can use the <a href="https://radarr.video/"target="_blank" rel="noopener">Radarr</a> service:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/d9fz9nc4dc8mgcaowpo5xul6w71e" alt="Radarr Web UI"  loading="lazy" /></p>
<p>For that, we have this snippet in the docker compose:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">radarr</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/linuxserver/radarr:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">qbittorrent</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=America/Sao_Paulo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/radarr/appdata/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Downloads/torrents:/downloads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Videos/radarr/movies:/movies</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Videos/radarr/anime:/anime</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s1">&#39;7878:7878&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Notice I mapped different directories for western movies and anime movies, that&rsquo;s a preference of mine, you can organize it however you want. As I warned before, don&rsquo;t copy and paste exactly what I&rsquo;m showing here: adjust the directories for your machine.</p>
<p>The main thing is that once it&rsquo;s started, we can navigate the web interface to the &ldquo;Settings&rdquo; option. There you&rsquo;ll find &ldquo;Download Clients&rdquo; and we can register the QBitTorrent web service:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/o3bkso3dmxppqk7qkbxc5miwt3ul" alt="Radarr QBitTorrent"  loading="lazy" /></p>
<p>I didn&rsquo;t show it, but in the QBitTorrent settings there&rsquo;s a place to set the admin password to access its API via web. Don&rsquo;t forget to set it there and use the same password here. That way Radarr can control the movie downloads directly with QBitTorrent, without you having to get involved.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/hx4xnogjlt7i4hjkpkrniqgboaui" alt="QBitTorrent Web UI"  loading="lazy" /></p>
<p>There&rsquo;s a setting in Radarr I recommend doing right away, because it ships with a silly hole: by default, EVERY quality definition comes with a minimum size of zero. That lets a tiny fake file passing itself off as 4K slip through. I&rsquo;ve been a victim: a <code>Superman (2025) WEBDL-2160p.iso</code> of 598 MB came in as if it were the movie and stayed showing in Plex for months. Inside the ISO was a <code>.exe</code> of 597 MB in disguise.</p>
<p>The fix is to go to Settings, Quality, and set a size floor on each quality definition. I use roughly this, in MB per minute of video:</p>
<ul>
<li>720p (HDTV/WEBDL/WEBRip/Bluray): 3</li>
<li>1080p (HDTV/WEBDL/WEBRip): 5, Bluray 1080p: 8, Remux 1080p: 25</li>
<li>2160p (HDTV/WEBDL/WEBRip): 10, Bluray 2160p: 15, Remux 2160p: 50</li>
</ul>
<p>It&rsquo;s low enough to accept a good lean x265 encode, and high enough to refuse a &ldquo;4K&rdquo; under 1 GB, which can only be a trap. One annoying detail: Radarr has a bug where saving everything at once doesn&rsquo;t persist the minimum size, so you have to save quality by quality, one at a time.</p>
<h2>Sonarr (TV Shows)<span class="hx:absolute hx:-mt-20" id="sonarr-tv-shows"></span>
    <a href="#sonarr-tv-shows" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Same thing as Radarr, but for TV shows, we have the <a href="https://sonarr.tv/"target="_blank" rel="noopener">Sonarr</a> service:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/ky9rxjko4m5mfordc84fn6so7xlx" alt="Sonarr Web UI"  loading="lazy" /></p>
<p>This service is fantastic. It finds your shows, cross-referencing online databases with details like seasons, each episode&rsquo;s name and lots of other metadata. So it can not only download old episodes, but also keeps a schedule to download shows that are still airing. Every week a new episode shows up on its own:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/xf3cj62rrzl30q34qnf1f629fmm3" alt="Sonarr Show"  loading="lazy" /></p>
<p>And same as Radarr: in Settings and Download Clients, I can register my QBitTorrent, then Sonarr handles downloading the episodes:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/qxxuy0wa90se9x8alm9pi7ycmzt0" alt="Sonarr QBitTorrent"  loading="lazy" /></p>
<p>And to bring up the service, in the docker compose we have this snippet:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sonarr</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/linuxserver/sonarr:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">qbittorrent</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=America/Sao_Paulo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/sonarr/appdata/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Videos/sonarr:/tv</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Downloads/torrents:/downloads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s1">&#39;8989:8989&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Same as Radarr, I mapped different folders for anime and non-anime, which is a matter of your organization preference.</p>
<p>Both in Sonarr and Radarr, there&rsquo;s a setting that became mandatory for me after I started getting caught by traps: a Release Profile that blocks release names known to be booby-trapped. In Settings, Profiles, I keep a &ldquo;Must Not Contain&rdquo; list with terms like these:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>BROADCAST
FULL HD
.scr
.exe
SODAPOP
REACHERSON</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Each of these has a story. <code>BROADCAST</code>, <code>SODAPOP</code> and <code>REACHERSON</code> are groups that pack split RARs with a <code>Sample/</code> folder that jams the import (I caught almost 60 GB of junk from a fake <code>Welcome to Derry</code> in one go). <code>FULL HD</code> is the naming signature of the ones carrying <code>.exe</code>. And <code>.scr</code>/<code>.exe</code> in the name is literal. A heads-up for anyone copying this: Radarr, under the hood, uses the same <code>ignored</code> field as Sonarr, even though the interface calls it &ldquo;Must Not Contain&rdquo;, so configure the same list in both.</p>
<p>There&rsquo;s still a class no filter catches on its own: pre-air fakes. A <code>Reacher S04E07</code> shows up with zero seeds for an episode that only airs next week. Sonarr doesn&rsquo;t reject a release dated before the air date, so I clean those out by hand. The good news is that, with the QBitTorrent executable block from above, even when one of those downloads, it comes in empty and gets discarded automatically.</p>
<h2>Downloads that get stuck<span class="hx:absolute hx:-mt-20" id="downloads-that-get-stuck"></span>
    <a href="#downloads-that-get-stuck" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A classic everyday problem: the torrent gets stuck forever in &ldquo;downloading metadata&rdquo; or &ldquo;stalled&rdquo;, with zero peers, and Sonarr never gives up on it on its own. I&rsquo;ve had an episode sitting behind a dead magnet for five days while the good release passed by, waiting for a turn that never came.</p>
<p>The solution I set up is a simple script running once a day (a cron or a systemd timer, doesn&rsquo;t matter). It queries the Sonarr, Radarr and Lidarr queue via the API, finds the items stuck in &ldquo;downloading metadata&rdquo; or &ldquo;stalled with no connections&rdquo; for more than 72 hours, and removes them with a single command:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>DELETE /api/v3/queue/{id}?removeFromClient=true&amp;blocklist=true</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>That deletes the dead download, dumps the release into the blocklist so it won&rsquo;t grab it again, and the app goes after another source automatically. The trick is being careful to NOT touch a download that&rsquo;s merely slow (under 72h) nor an item waiting for a manual import, otherwise you delete good stuff by mistake. The first time I ran it, it cleared out nineteen zombie downloads in one shot.</p>
<h2>Prowlarr (indexer)<span class="hx:absolute hx:-mt-20" id="prowlarr-indexer"></span>
    <a href="#prowlarr-indexer" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For both Radarr and Sonarr to know where to pull the show and movie torrents from, they need to search public indexers or trackers (or private ones, if you have good contacts, I don&rsquo;t, don&rsquo;t even ask). For that we have <a href="https://prowlarr.com/"target="_blank" rel="noopener">Prowlarr</a>. This is the most important service of all, because if this one doesn&rsquo;t work, nothing downloads properly.</p>
<p>It took me a while to reach a good configuration, but today I have a simple rule that solves it: Prowlarr is the only place that manages indexers. Sonarr, Radarr and Lidarr should NOT have any indexer registered directly, only the ones Prowlarr syncs. Every indexer I had registered by hand inside Radarr was a source of headaches, including a &ldquo;Lime Torrents&rdquo; that was exactly the one that served me that Superman ISO malware. I deleted them all and left only Prowlarr feeding the three.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/a1xmx4qhm0i554d0vwsij051ivnz" alt="Prowlarr"  loading="lazy" /></p>
<p>Notice it lists dozens of indexers, or trackers. It&rsquo;s similar to Jackett. The difference is that Jackett is for manual searches you&rsquo;ll do yourself, straight from QBitTorrent. Prowlarr&rsquo;s indexers, on the other hand, get configured straight into Sonarr and Radarr. Here&rsquo;s where the confusion starts, let&rsquo;s understand it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/5fu7qbgxgr4qf5kzzjyma1zevr9u" alt="Sonarr Indexes"  loading="lazy" /></p>
<p>Both in Sonarr and Radarr, besides Download Clients, where we can configure QBitTorrent, there&rsquo;s also this &ldquo;Indexers&rdquo; option, where we can configure which trackers to search for the media.</p>
<p>Think of a tracker like a public mini-Google, it&rsquo;s where people register their torrents when they want to share. Torrents aren&rsquo;t magic and don&rsquo;t appear out of nowhere. Someone has to create a record somewhere. But there isn&rsquo;t just &ldquo;one&rdquo; place: it&rsquo;s a distributed network and any participant can put up a tracker if they want. It&rsquo;s as if you could put up your own miniature Google, just to register torrent files.</p>
<p>It&rsquo;s a pain to keep managing this. Some small trackers decide to go offline, new ones appear, configs get changed, and we have to keep managing this manually, both in Sonarr and Radarr. That&rsquo;s what Prowlarr is for: to manage the trackers for you. Let&rsquo;s understand how.</p>
<p>In the Prowlarr settings, we start by configuring where to find our Sonarr and Radarr services:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/t5opcjgeutzt3l4omy6wkk4gbw98" alt="Prowlarr Apps"  loading="lazy" /></p>
<p>For example, this is my Sonarr config. Notice the hostname is &ldquo;sonarr&rdquo; and that works because in my docker compose I bring all services up on the same VLAN, and by default docker registers the service name configured in the YAML file as the hostname. If you don&rsquo;t understand Docker, be sure to watch my videos about <a href="https://www.youtube.com/watch?v=85k8se4Zo70"target="_blank" rel="noopener">containers</a>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/d95y3u96q4tw9q9hs5u5q3eq26zf" alt="Prowlarr Sonarr"  loading="lazy" /></p>
<p>We also need to configure access to QBitTorrent:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/179tso31cnwvyxan9ltvgibeu8sm" alt="Prowlarr QBitTorrent"  loading="lazy" /></p>
<p>This other one, <strong>SABnzbd</strong>, uses another, older download protocol, based on USENET newsreaders. Yes, that old Usenet. Those who know, know. I keep torrent as the primary, but I keep SABnzbd configured as a second source, because a lot of content only exists in those older groups. If you&rsquo;re going to use it, apply the same extension protection as QBitTorrent: in the <code>sabnzbd.ini</code> file, in the <code>[misc]</code> section, set <code>unwanted_extensions = exe, scr, bat, cmd, msi, lnk, com, vbs, pif</code> and <code>action_on_unwanted_extensions = 2</code>, which tells it to abort the whole download if one of those shows up. That way Sonarr and Radarr see the failure and look for another source.</p>
<p>Finally, many trackers today implement some kind of bot protection using Cloudflare captcha, you know that thing that goes &ldquo;are you a human?&rdquo;. To get past that, we need the FlareSolverr service, which we can bring up in the docker compose like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">flaresolverr</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/flaresolverr/flaresolverr:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/flaresolverr/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s1">&#39;8191:8191&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In Prowlarr it enters as an &ldquo;indexer proxy&rdquo;. You register FlareSolverr pointing to <code>http://flaresolverr:8191/</code>, create a tag for it, and mark only the indexers that sit behind Cloudflare with that tag. Those then start solving the captcha under the hood, and the others don&rsquo;t even need to go through it:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/7949bp73gbb49994tynwuil9249h" alt="Prowlarr FlareSolvrr"  loading="lazy" /></p>
<p>We&rsquo;re not done yet. On the main screen, you go enabling the indexers that make sense for you. Don&rsquo;t enable everything: it gets heavy and, worse, some indexers serve more traps than good content. Check each one&rsquo;s tags. Some indexers are porn-only, some are only Russian or Chinese content, those I skip.</p>
<p>The ones that serve me well today are <strong>EZTV</strong> and <strong>1337x</strong> (both behind Cloudflare, so they depend on FlareSolverr), <strong>YTS</strong> for movies, and <strong>AnimeTosho</strong> plus <strong>Nyaa</strong> for anime. In total I&rsquo;m left with somewhere between 18 and 21 active indexers per app. And more important than enabling the good ones is disabling the bad ones: I turned off <strong>LimeTorrents</strong> and <strong>TorrentDownload</strong>, because, together, they generated more than two hundred downloads and almost no usable import, on top of being a recurring source of malware and fakes. Fewer good indexers is worth more than a pile of bad ones.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/cnl4yefcsvas84m6eslb3in7jmq9" alt="Add Indexer"  loading="lazy" /></p>
<p>At the end, it&rsquo;s good to click &ldquo;Test All Indexers&rdquo; to check which ones are still active or not. And finally, we can click &ldquo;Sync App Indexers&rdquo;, which will register all the indexers we know are working into Sonarr and Radarr. Get it? Prowlarr will configure both with the most up-to-date indexers, and remove the ones that are no longer active. We don&rsquo;t need to configure each of them manually.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/if86fzdhieaqt5fxpbozfrh0x13k" alt="Sync App"  loading="lazy" /></p>
<p>It&rsquo;s important to understand this, because otherwise you&rsquo;ll see Sonarr and Radarr not managing to download anything and won&rsquo;t know why: but it&rsquo;s because the indexers are wrong or obsolete or insufficient. Spend a good amount of time reading the <a href="https://prowlarr.com/"target="_blank" rel="noopener">Prowlarr</a> documentation.</p>
<h2>Plex (Private Netflix)<span class="hx:absolute hx:-mt-20" id="plex-private-netflix"></span>
    <a href="#plex-private-netflix" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>To watch everything you download, there are several options. If it&rsquo;s just for you, you can just download everything to an external drive or something, and watch directly from your PC or laptop. Depending on your Smart TV or game console, you can also connect your drive or flash drive via USB and watch directly.</p>
<p>If you want something a bit more sophisticated, and have a spare PC or Raspberry Pi, you can install Sonarr, Radarr, and share the downloads directory over the network, using protocols like CIFS, which is for Windows shared folders. Again, many Smart TVs and consoles support mounting network shared folders.</p>
<p>But if you want something friendlier and more sophisticated, you can literally have your own &ldquo;Private Netflix&rdquo;, with a graphical interface similar to any streaming platform, showing thumbnails, descriptions, movie or show details, a video player and all that, and even suggestions of what to watch, movie suggestions based on what you&rsquo;re watching, actor info and everything else.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/uvfh2i3ppr5mgrt1xl1r6kjgtvdb" alt="Plex"  loading="lazy" /></p>
<p>There are several projects that do this. One of them, which the community likes more for being 100% open, is <a href="https://jellyfin.org/"target="_blank" rel="noopener">Jellyfin</a> (derived from the old Emby). I recommend testing it, it has everything most people need. Just map the Sonarr and Radarr download folders and it will index everything and organize it for you. Then, from any PC, just access the web interface and it all just works.</p>
<p>But I personally prefer <a href="https://www.plex.tv/"target="_blank" rel="noopener">Plex</a>, which is older, more closed (which is why some people don&rsquo;t like it), but because it offers <a href="https://support.plex.tv/articles/202526943-plex-free-vs-paid/"target="_blank" rel="noopener">paid plans</a> with exclusive features, the level of polish is clearly better. The interface is much prettier, and looks more &ldquo;professional&rdquo;. Jellyfin, as good as it is, still has that look like a backend programmer made it, with a lot of ill will toward UX and design.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/gh6kum745hoedx34km4rggpfubjd" alt="Plex details"  loading="lazy" /></p>
<p>On top of that, to watch on a Smart TV or Android TV, you need an app on the Google Play Store. Jellyfin doesn&rsquo;t have an app for everything, some TVs, I don&rsquo;t remember if LG or Samsung, don&rsquo;t even have the app available. So you have to side-load. Not friendly. Plex, on the other hand, is available in way more places, so it tends to be more &ldquo;plug and play&rdquo;, similar to downloading the Netflix or Amazon Prime app.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/zvipl6wce0rruosctykjnou6ayu0" alt="Plex Skip Intro"  loading="lazy" /></p>
<p>Plex also has features that &ldquo;just work&rdquo;, trivial things you expect in a player, like a &ldquo;skip intro&rdquo; button or &ldquo;resume from where you stopped&rdquo; or &ldquo;skip to the next episode&rdquo; or &ldquo;search and change subtitle&rdquo;. That&rsquo;s what interfaces like these offer you, so the usability really is a lot like watching Netflix. You don&rsquo;t get that with a generic PC player like a VLC.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/mev5extpv8lgv7k1lhsq0bcm8i46" alt="Plex Subtitles"  loading="lazy" /></p>
<p>Bringing it up is easy, here&rsquo;s my docker compose snippet:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">plex</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">plexinc/pms-docker:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=America/Sao_Paulo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">VA_DRIVER=IHD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PLEX_HW_TRANS_MAX=16</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/plex/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/plex/data:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/plex/transcode:/transcode</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Videos:/media</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/dev/dri:/dev/dri</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/dev/bus/usb:/dev/bus/usb</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">network_mode</span><span class="p">:</span><span class="w"> </span><span class="l">host</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>There&rsquo;s a small detail many people don&rsquo;t understand: Plex does <em>transcoding</em> of the videos. That is, it will re-encode the video before sending it to the app or browser that will play it, and that&rsquo;s heavy, it&rsquo;s video stream conversion. If your server isn&rsquo;t strong enough, your video will stutter when playing.</p>
<p>In reality, it&rsquo;s more complicated than that. There&rsquo;s a protocol called &ldquo;Direct Play&rdquo; or &ldquo;Direct Stream&rdquo;. There&rsquo;s an <a href="https://support.plex.tv/articles/200250387-streaming-media-direct-play-and-direct-stream/"target="_blank" rel="noopener">article that explains this</a> on the Plex site. If the PC, SmartTV, console, etc. support the codec of the video in question, in theory Plex will just pass the bits straight through, without trying to convert anything. Understand: not every device can play any video codec.</p>
<p>Most content nowadays is encoded in H.264 or H.265 or even AV1, which is newer and more efficient. The problem, let&rsquo;s say you want to play it on a really old PC or a PS4. They&rsquo;ll support H.264, maybe H.265 if you&rsquo;re lucky, but certainly not AV1. So it would be super slow to try to play using only the CPU. The right thing is to have hardware instructions to play AV1, especially on an old CPU.</p>
<p>So Plex asks the device: <em>&ldquo;do you support AV1?&rdquo;</em> The player will say <em>&ldquo;nope, I&rsquo;m weak.&rdquo;</em> So Plex will transcode from AV1 to H.264, for example. All of this is transparent to you.</p>
<p>But then you&rsquo;ll notice that when playing the video it stutters, pauses all the time. It could be a bandwidth problem on your network, or it could be a problem of your Plex server being too weak, or of it not being configured for hardware transcoding, and brute-forcing it using only the CPU.</p>
<p>Watch <a href="https://www.youtube.com/watch?v=j4a1SgUWh1c"target="_blank" rel="noopener">my video about compression</a>, where I explain codecs, but in short, the right thing is that whatever little PC you&rsquo;re using for your Plex, it should have at least a minimum of hardware video encoding capability. Most have at least H.264 capability, like the <strong>Intel QSV</strong> instructions, check if your CPU has that support. It only won&rsquo;t have it if it&rsquo;s too old, like something more than 10 years back. If it&rsquo;s a strong server, with an NVidia GPU, it&rsquo;ll have things like <strong>NVENC</strong>, which supports H.265. If it&rsquo;s a modern Intel CPU, it&rsquo;ll support <strong>AV1</strong>.</p>
<p>In any case, on Linux, these instructions should be exposed on the <code>/dev/dri</code> device, which is why we need to map it into the docker container as I did above. Check that this device exists.</p>
<p>If everything is correct, in Plex you&rsquo;ll have this option to configure the &ldquo;Transcoder&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/q6y2swsu9u0uhzggskx3bimnxcou" alt="Plex Transcoder"  loading="lazy" /></p>
<p>We want to make sure the quality is set to &ldquo;Make my CPU hurt&rdquo; and that the &ldquo;Use hardware acceleration when available&rdquo; option is enabled too, so it will use <code>/dev/dri</code> to accelerate transcoding. And that makes a BRUTAL difference in speed. If you&rsquo;re having stuttering playback, it&rsquo;s very likely the transcoding.</p>
<p>When the device, your smartphone or your TV, supports the exact codec used in the video file we want to watch, in theory, Plex can pass the stream straight through, without transcoding, which is the ideal world, because then there&rsquo;s no processing at all. That&rsquo;s Direct Play. But for that the video files need to be in a certain codec, in a certain configuration. But even in a supported codec, if your TV is weak and doesn&rsquo;t support 4K, and asks Plex for Full HD (1080p), then Plex will transcode from H.264 4K to H.264 1080p, for example. That&rsquo;s why I say it&rsquo;s more complicated than it seems.</p>
<p><strong>IMPORTANT DETAIL:</strong> Recently I got confused playing some of my videos. I have the best hardware, both for the Plex server, with hardware acceleration, and an Android TV to play it which is my NVIDIA Shield Pro, on a wired network, etc. But some videos kept stuttering. And in the end the culprit wasn&rsquo;t hardware, or config, or any of that, it was the damn subtitle:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/z8wlwg5nze02ubi9m8dbkxlqqnin" alt="Plex PGS Subtitle problem"  loading="lazy" /></p>
<p>Anyone who&rsquo;s had to download subtitles has probably seen the most common format, which is <strong>.SRT</strong> (SubRip). Some who ventured further have seen other formats, like SSA (SubStation Alpha), TTML (Timed Text Markup Language), VTT (Web Video Text Track), VOBSUB, etc.</p>
<p>Yes, there&rsquo;s a LOT more than SRT. In particular, if you do like me and rip UHDs and extract the subtitle tracks from the disc itself, apps like MakeMKV can download them in VOBSUB format (which is ok) or the cursed <strong>PGS</strong> (Presentation Graphic Stream Subtitle Format).</p>
<p>Most subtitle tracks are made to be sent to the player in parallel, and separate, from the video track. Just like the audio track goes separately. The player receives a video track, an audio track, and plays the two in sync. Same with the subtitle track, in the case of SRT or SSA or TTML files.</p>
<p>But PGS is different: it has graphical capabilities! I&rsquo;m guessing here, but if you&rsquo;ve seen colored, animated subtitles, that move or position themselves at absolute spots on screen (like sitting exactly on top of a billboard in the video, for example), then there has to be a step to rasterize and render that graphical subtitle <strong>ON TOP</strong> of the video stream. That&rsquo;s a heavier encoding step, meaning it&rsquo;ll be <strong>HEAVY</strong> to play in real time.</p>
<p>The solution: use Plex to search for a new subtitle, in SRT format, and ignore the PGS subtitle. Just doing that stopped the stuttering when playing.</p>
<h2>Seerr (formerly Overseerr)<span class="hx:absolute hx:-mt-20" id="seerr-formerly-overseerr"></span>
    <a href="#seerr-formerly-overseerr" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>With just Sonarr and Radarr, plus Prowlarr&rsquo;s indexers, it&rsquo;s already enough to search and download everything. But there&rsquo;s another app that makes discovering new stuff to download a lot easier. It&rsquo;s called <a href="https://seerr.dev/"target="_blank" rel="noopener">Seerr</a>, and it&rsquo;s the evolution of what used to be Overseerr. The Overseerr project merged with Jellyseerr, the fork that served Jellyfin, and the two became one: Seerr, which now serves Plex, Jellyfin and Emby in the same place. If you still hear people talk about &ldquo;Overseerr&rdquo; out there, this is it.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/scl2gpzjir3g0hga7p1ip50cs54w" alt="Seerr"  loading="lazy" /></p>
<p>It goes to free online databases (like the IMDBs of the world) and keeps an eye on everything new coming out, everything that&rsquo;s &ldquo;trending&rdquo;. If I want to find something new, it&rsquo;s a good one. Even things I already know I want, I just search in Seerr and click &ldquo;Request&rdquo;:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/4rcrqhxpooq14nip3zj3m7epo7p3" alt="Seerr Request"  loading="lazy" /></p>
<p>I configure the integration with Sonarr and Radarr in the settings, so it knows who to send the request to. It all works integrated. I just need to use the Seerr interface to request things and I can forget about Sonarr and Radarr running in the background.</p>
<h2>Bazarr (subtitles)<span class="hx:absolute hx:-mt-20" id="bazarr-subtitles"></span>
    <a href="#bazarr-subtitles" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I don&rsquo;t need it, but my girlfriend sometimes prefers watching with Portuguese subtitles. And it&rsquo;s a pain because there isn&rsquo;t always one, or what exists is low quality. By the way, let me warn you that both official and unofficial subtitles, I&rsquo;m tired of seeing wrong translations. My fun is finding the errors in the subtitle. If you depend on subtitles, know that many important meanings are actually wrong. Always prefer the original.</p>
<p>Back in the day you had to go manually to sites like <a href="https://www.opensubtitles.org/en/search"target="_blank" rel="noopener">OpenSubtitles.org</a>, download the &ldquo;.SRT&rdquo; file, rename it the same as the video file name and keep testing manually to see if it was in sync or not.</p>
<p>There are two ways to solve this. One is through Plex itself, which has the option to search subtitles in various languages and keep testing them straight from the player interface. It helps a lot because it skips the step of having to sit at the PC, manually downloading files. As I explained in the previous section, in my case, since the UHD backup subtitles were extracted in PGS format, I need to search in SRT format so it doesn&rsquo;t stutter when playing.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/ok05g5tdrevvaywqfqmjwy0we3d6" alt="Plex Subtitles"  loading="lazy" /></p>
<p>Another way is to try downloading the subtitles automatically beforehand. This isn&rsquo;t perfect because there are several versions of subtitles, some made for DVD, others for BluRay, some even for the more popular &ldquo;CAM&rdquo; versions, which are torrents of videos recorded straight in the theater with a smartphone, the crappiest version of all.</p>
<p>It&rsquo;s very important to rename the video files in the right format, with the metadata well organized, as explained in the <a href="https://www.reddit.com/r/pirataria/comments/18ch7bt/guia_do_streaming_dom%C3%A9stico_automatizado_sonarr/"target="_blank" rel="noopener">Reddit forum</a> I mentioned at the start of the article. That helps Bazarr match the right subtitle to the right file. And a heads-up: Brazilian Portuguese is one of the languages with the fewest subtitles. It&rsquo;s easier to find them in French, Italian or Russian than in Portuguese.</p>
<p>To make this easier we have the <a href="https://www.bazarr.media/"target="_blank" rel="noopener">Bazarr</a> service:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/mjziydblv9rh4yowhuodgxycjzf6" alt="Bazarr Web UI"  loading="lazy" /></p>
<p>In the Bazarr config, we point it to our local Sonarr and Radarr services, so it finds out every time a new show episode or a new movie appears, and schedules a subtitle search:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/rhiefnojwsullwi1m9swhxds6ocj" alt="Bazarr integration"  loading="lazy" /></p>
<p>The main thing is to configure the Providers, which are the sites the subtitles come from. This is where the difference between &ldquo;finds nothing&rdquo; and &ldquo;finds almost everything&rdquo; lives. The five that give me the best results are: <strong>opensubtitlescom</strong>, <strong>tvsubtitles</strong>, <strong>yifysubtitles</strong> (movies only), <strong>animetosho</strong> (for anime, no account needed) and <strong>gestdown</strong> (an Addic7ed proxy for English, also no account). One gotcha that cost me hours: the OpenSubtitles.com provider wants your <strong>username, not your email</strong>. If you put the email, it errors out with &ldquo;you cannot consume this service&rdquo;, and still keeps you grounded for 12 hours even after fixing it.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/8lzq8umwyjgg40abb1tlvtvt8nxu" alt="Bazarr Providers"  loading="lazy" /></p>
<p>To bring up the service, in the docker compose, we have this snippet:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">bazarr</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/linuxserver/bazarr:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=America/Sao_Paulo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Videos/radarr:/movies</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Videos/sonarr:/tv</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/bazarr/appdata/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s1">&#39;6767:6767&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Here we need to map the radarr and sonarr folders so it knows where to write the subtitles. One detail that trips a lot of people up: map the exact same subpaths that Radarr and Sonarr use (in my case, <code>/movies</code> and <code>/anime</code>), otherwise Bazarr keeps complaining that it &ldquo;can&rsquo;t find the video file for analysis&rdquo; across the whole library.</p>
<p>The last step, and the one that finally made Bazarr useful for me, is the language profile. I created a &ldquo;Brazilian Portuguese with English fallback&rdquo; profile: it lists <code>pb</code> first and <code>en</code> after, with the cutoff on <code>pb</code>. In practice, Bazarr searches for both, downloads whichever shows up first, and considers the item resolved once a Portuguese subtitle exists. If pt-BR never appears, English stays as the backup.</p>
<p>And the trick, which cost me dearly: <strong>every item needs a language profile assigned</strong>. Bazarr silently ignores any movie or show without a profile. When I went to check, there were 140 shows and 201 movies with no profile at all, silently ignored ever since they were added to the library. Marking the profile as default only covers what you add from then on; for the library that already exists, you have to assign the profile in bulk, all at once, to everything already there.</p>
<p>There&rsquo;s also a sibling problem to this one, but on the audio side. Every now and then Sonarr or Radarr download a release whose only audio is a foreign dub: an Italian one, a French one, a Russian one. And no subtitle saves wrong audio. For that I use a Custom Format in both, the &ldquo;foreign-language-only&rdquo; one, which recognizes foreign-dub markers (ITALIAN, FRENCH, RUS, GERMAN and so on) and gives a score of -500, which makes the release get refused at search time. A detail I learned the hard way: a Russian release came with the whole title in Cyrillic, without any &ldquo;RUS&rdquo; in Latin letters, and slipped right past. So the filter also needs to catch any Cyrillic character.</p>
<p>After I fixed the providers, moved the entire library onto that profile and turned on this audio filter, Bazarr stopped being the most useless service in my stack and became one that resolves most cases on its own. When something&rsquo;s still missing, then yes I hunt for it by hand through Plex, but today that&rsquo;s the exception. Just to reiterate what I said before: Brazilian Portuguese subtitles are among the scarcest out there, so even with everything set up right, sometimes there just isn&rsquo;t one.</p>
<h2>Lidarr (Music)<span class="hx:absolute hx:-mt-20" id="lidarr-music"></span>
    <a href="#lidarr-music" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m not much of a music person. I have a few MP3s I downloaded decades ago and, day to day, I play the Spotify Top 50 playlist and that&rsquo;s fine. But the same logic as Sonarr and Radarr applies to music, so I ended up setting it up too: the service is called <a href="https://lidarr.audio/"target="_blank" rel="noopener">Lidarr</a>.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/20260905215028_lidarr-library.png" alt="Lidarr Web UI"  loading="lazy" /></p>
<p>It catalogs your artists, cross-references discography databases and goes after the missing albums, marking each artist as &ldquo;Monitored&rdquo; to look for new releases on its own, the same way Sonarr does with show episodes. In the screenshot above you can see my library with everything in &ldquo;Lossless&rdquo;, which is the quality profile I configured.</p>
<p>In the docker compose it&rsquo;s the same pattern as the others:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">lidarr</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">ghcr.io/linuxserver/lidarr:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">qbittorrent</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=America/Sao_Paulo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/home/akitaonrails/lidarr/appdata/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Music:/music</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/mnt/terachad/Downloads/torrents:/downloads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s1">&#39;8686:8686&#39;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The configuration is identical in spirit to Radarr and Sonarr. In Settings and Download Clients I register the same QBitTorrent, but using its own category called <code>lidarr</code>, so it doesn&rsquo;t mix the music downloads with the movie and show ones. The library root folder points to <code>/music</code>, and the quality profile can be left open (the &ldquo;Any&rdquo;, which grabs whatever shows up) or locked to &ldquo;Lossless&rdquo; when you only want FLAC, which is my case.</p>
<p>And the best part: I don&rsquo;t configure the indexers by hand. Prowlarr, which I explained above, also registers Lidarr as an application and syncs the music-category indexers on its own. It&rsquo;s the same &ldquo;Sync App Indexers&rdquo; it does for Sonarr and Radarr, now for music too.</p>
<p>To listen, Plex itself already plays music: just create a Music-type library pointing to that folder, and on the phone the Plexamp app is great just for this. And, the same way I have Kavita as &ldquo;Plex for manga&rdquo;, for music you can also use <a href="https://www.navidrome.org/"target="_blank" rel="noopener">Navidrome</a>, an open source streaming server like a private Spotify, which indexes the same <code>/music</code> folder (read-only) and has several compatible phone apps. Lidarr downloads and organizes, Navidrome or Plex serve.</p>
<h2>Other Services<span class="hx:absolute hx:-mt-20" id="other-services"></span>
    <a href="#other-services" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In <a href="https://github.com/akitaonrails/plex_home_server_docker/tree/master"target="_blank" rel="noopener">my repository</a> there are other Docker Compose files for my local infra. I won&rsquo;t explain them in detail, but just so you know:</p>
<p>To block banners, ads, trackers and even malware sites across the whole network, I use filtered DNS. For a long time I ran a local <a href="https://pi-hole.net/"target="_blank" rel="noopener">Pi-Hole</a> for that, which is a home DNS server with built-in blocking. It works great, but it needs maintenance. These days I switched to a hosted service, <a href="https://nextdns.io/"target="_blank" rel="noopener">NextDNS</a>, which does the same ad, tracker and malware blocking, except I don&rsquo;t have to maintain any server, and it comes with encrypted DNS out of the box.</p>
<p>And why does encrypted DNS matter? A DNS query, by default, is an open text protocol. Your provider knows exactly everything you browse. I prefer not to hand them that data for free. With NextDNS the queries go out encrypted and still pass through the blocklist I choose. It doesn&rsquo;t replace an anti-virus, but it makes it much harder for third parties to build a fingerprint of my browsing habits, and it&rsquo;s a lot less work than the Pi-Hole I used to run.</p>
<p>Now, in the <a href="https://github.com/akitaonrails/plex_home_server_docker/blob/master/kaizoku-docker-compose.yml"target="_blank" rel="noopener">kaizoku-docker-compose.yml</a> file there&rsquo;s another service like Sonarr for TV, Radarr for movies, which is <a href="https://github.com/oae/kaizoku"target="_blank" rel="noopener">Kaizoku</a> for manga. If you read manga online, you&rsquo;ve probably seen many sites getting shut down over copyright, intellectual property and all that. Eventually, the online manga you read will disappear. With Kaizoku we can preserve them locally too.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/iucprcsmn9emkzzurs7z2fstcsjm" alt="Kaizoku"  loading="lazy" /></p>
<p>Kaizoku is only for downloading. To read, we need a reader, the same way we have Plex for video, we have <a href="https://www.kavitareader.com/"target="_blank" rel="noopener">Kavita Reader</a> to read the downloaded manga. Just like Plex, on the first setup you just point it to the folder where Kaizoku downloads everything, then Kavita handles organizing the library, downloading covers and other metadata. It&rsquo;s literally Plex for Manga.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/bxuz95wo3luc308lz1dos9bf2kcr" alt="Kavita"  loading="lazy" /></p>
<p>I haven&rsquo;t explored this service much yet. Either there are still things to refine in it, or I just don&rsquo;t know how to configure it right, but not everything downloads covers correctly. See how it doesn&rsquo;t look nice. If you have tips about Kavita and Kaizoku, be sure to share them below.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/bcrh2ifucs86fupzk8gqa38yx5iq" alt="Kavita Reader"  loading="lazy" /></p>
<p>For the actual reading, it&rsquo;s a lot like a Kindle Web or other online manga sites. You can read page by page, or &ldquo;scroll&rdquo; the whole chapter, it remembers where you stopped and basic stuff like that. It could be a good project to contribute to, if you feel like practicing with open source.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;m very happy with the current state of file-sharing services. Don&rsquo;t waste too much time overthinking. Just install them and have fun. The code for everything is open, if you&rsquo;re a programmer, go dig through the code and learn something new, and who knows, even contribute.</p>
<p>I&rsquo;m not into apocalyptic theories, conspiracy theories, or anything like that. I just consider that whatever I don&rsquo;t have physical control over, I <strong>don&rsquo;t have</strong>. If what &ldquo;is mine&rdquo; is under a third party&rsquo;s control, then &ldquo;it isn&rsquo;t mine&rdquo;. That&rsquo;s the principle. I want everything that&rsquo;s mine under my sole and exclusive control, period. It doesn&rsquo;t matter if it&rsquo;s less convenient, it doesn&rsquo;t matter if it&rsquo;s more expensive. It&rsquo;s a matter of principle: mine is mine and that&rsquo;s final.</p>
<p>Besides, on the streaming platforms, not everything exists. A lot of stuff from past decades was simply erased from memory. Many don&rsquo;t even remember anymore, especially those who didn&rsquo;t see it back then. I feel nostalgia for TV shows, movies, that don&rsquo;t exist online. Many don&rsquo;t even exist on physical media. There are movies that after VHS never even came out on DVD. There are DVDs that never came out as Blu-Ray. There&rsquo;s stuff on physical disc that will never come to streaming. There&rsquo;s stuff on streaming that will be erased soon, and many won&rsquo;t even notice.</p>
<p>What&rsquo;s mine, is mine. They can erase as much as they want. What&rsquo;s mine, no one erases. If you value what&rsquo;s yours, start exploring these options. It doesn&rsquo;t have to be a giant setup like mine. Any spare external drive is enough to start. Start small, grow little by little. I&rsquo;ve been collecting this stuff for decades! I didn&rsquo;t start today.</p>
]]></content:encoded><category>homelab</category><category>containers</category><category>audio-and-video</category></item></channel></rss>