<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Arch on Code is cheap, let&#39;s talk</title>
    <link>https://blog.ferstar.org/en/tags/arch/</link>
    <description>Code is cheap, let&#39;s talk</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en</language>
    <copyright>© 2026 ferstar · [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)</copyright>
    <lastBuildDate>Mon, 28 Sep 2026 11:00:00 +0800</lastBuildDate>
    <ttl>60</ttl><atom:link href="https://blog.ferstar.org/en/tags/arch/index.xml" rel="self" type="application/rss+xml" /><image>
      <url>https://blog.ferstar.org/site-logo.png</url>
      <title>Code is cheap, let&#39;s talk</title>
      <link>https://blog.ferstar.org/</link>
    </image>
    
    <item>
      <title>Losslessly Carrying a Long-Served Arch System to New Hardware with btrfs send/receive</title>
      <link>https://blog.ferstar.org/en/posts/btrfs-send-receive-system-migration/</link>
      <pubDate>Mon, 28 Sep 2026 11:00:00 +0800</pubDate>
      
      <guid isPermaLink="true">https://blog.ferstar.org/en/posts/btrfs-send-receive-system-migration/</guid>
      <description>Dreading a reinstall just because you swapped disks or machines? This post documents the complete workflow of online-migrating an Arch system with read-only btrfs snapshots plus send/receive: the four UUID touchpoints, boot rebuild, and the hardware-difference checklist you need for a true cross-machine move.</description><content:encoded><![CDATA[<blockquote><p>I am not a native English speaker; this article was translated by AI.</p>
</blockquote><p>I have an Arch system that has followed me since my ThinkPad days. Years of accumulated package lists, zsh setup, and dotfiles make a reinstall a genuinely painful prospect. In 2023 I did two full system migrations: one disk swap on the same machine, one from a SATA drive to NVMe. Both used the same approach: <strong>btrfs read-only snapshots plus send/receive</strong>. This post lays out the exact sequence I used, along with what to watch out for when moving to genuinely different hardware.</p>

<h2 class="relative group">Why not rsync
    <div id="why-not-rsync" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-not-rsync" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>rsync moves files, but it cannot carry three things that matter on btrfs:</p>
<ul>
<li><strong>Subvolume structure</strong>. My layout has five sibling subvolumes: <code>@</code>, <code>@home</code>, <code>@var@cache</code>, <code>@var@log</code>, <code>@var@lib@docker</code>. After rsync they become plain directories; the subvolume boundaries are gone.</li>
<li><strong>Data verification</strong>. Every btrfs block has a checksum. send/receive streams filesystem instructions, so the data is trustworthy the moment it lands — no post-copy comparison pass needed.</li>
<li><strong>Compression</strong>. My drives are mounted with <code>compress-force=zstd</code>. send/receive preserves filesystem semantics instead of decompressing and recompressing at the target the way rsync would.</li>
</ul>
<p>And since snapshots are copy-on-write, taking a read-only snapshot is near-instant while the system keeps running — that is what makes this an online migration.</p>

<h2 class="relative group">Step 0: clean the junk before snapshotting
    <div id="step-0-clean-the-junk-before-snapshotting" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#step-0-clean-the-junk-before-snapshotting" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The snapshot ships the whole subvolume — garbage carried over is still garbage, only slower to send and more crowded on the target. Before snapshotting, it is worth walking through:</p>
<ul>
<li>Package caches: <code>pacman -Sc</code>, plus the AUR helper’s build cache (e.g. <code>~/.cache/yay</code> — usually the biggest offender)</li>
<li>Orphaned packages: <code>pacman -Rns $(pacman -Qtdq)</code></li>
<li>System journals: <code>journalctl --vacuum-size=100M</code></li>
<li>User caches (browser caches under <code>~/.cache</code> and the like) and the trash</li>
</ul>
<p>Clean first, then snapshot — the transferred volume drops noticeably. The gig-plus of AUR build cache I cleaned out last time would otherwise have been shipped along for free.</p>

<h2 class="relative group">The complete sequence
    <div id="the-complete-sequence" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-complete-sequence" aria-label="Anchor">#</a>
    </span>
    
</h2>
<pre class="not-prose mermaid">
flowchart LR
    A[Mount source/target] --> B[RO snapshot per subvolume]
    B --> C[send piped to receive]
    C --> D[Promote snapshots to writable subvolumes]
    D --> E[Replace UUIDs: fstab/timeshift/grub]
    E --> F[Mount ESP, register boot entry]
    F --> G[sync, unmount, reboot into new disk]
</pre>

<p>One set of mount options used throughout:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">compress-force=zstd,noatime,ssd,space_cache=v2</span></span></code></pre></div></div>
<p><strong>Step 1, mount source and target:</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">sudo mount -t btrfs -o compress-force=zstd,noatime,ssd,space_cache=v2 /dev/sda2 /tmp/src
</span></span><span class="line"><span class="cl">sudo mount -t btrfs -o compress-force=zstd,noatime,ssd,space_cache=v2 /dev/nvme0n1p4 /tmp/dst</span></span></code></pre></div></div>
<p><strong>Step 2, take a read-only snapshot of every subvolume</strong> (send only accepts read-only snapshots):</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">for i in $(ls -d @* | grep -v _ub); do sudo btrfs subvolume snapshot -r $i ${i}_ro; done</span></span></code></pre></div></div>
<p><strong>Step 3, send each one:</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">sudo btrfs send @_ro | sudo btrfs receive /tmp/dst
</span></span><span class="line"><span class="cl">sudo btrfs send @var@log_ro | sudo btrfs receive /tmp/dst
</span></span><span class="line"><span class="cl">sudo btrfs send @var@cache_ro | sudo btrfs receive /tmp/dst
</span></span><span class="line"><span class="cl">sudo btrfs send @var@lib@docker_ro | sudo btrfs receive /tmp/dst</span></span></code></pre></div></div>
<p><strong>Step 4, promote the snapshots.</strong> What receive produces are read-only subvolumes named <code>@_ro</code> and so on. Take a writable snapshot of each to get the real <code>@</code>, then delete the <code>_ro</code> intermediates:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">for i in $(ls -d *_ro); do
</span></span><span class="line"><span class="cl">  sudo btrfs subvolume snapshot $i $(echo $i | cut -d '_' -f 1)
</span></span><span class="line"><span class="cl">  sudo btrfs subvolume delete $i
</span></span><span class="line"><span class="cl">done</span></span></code></pre></div></div>
<p><strong>Step 5, replace UUIDs</strong> — the most error-prone part of the whole flow. The new partition has a new UUID; look it up with <code>blkid</code>, then sed-replace the old one everywhere. There are four places to touch:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">etc/fstab                     # mount table
</span></span><span class="line"><span class="cl">etc/timeshift/timeshift.json  # snapshot tool
</span></span><span class="line"><span class="cl">boot/grub/grub.cfg            # boot config
</span></span><span class="line"><span class="cl">boot/grub/grub-btrfs.cfg      # btrfs snapshot boot menu</span></span></code></pre></div></div>
<p><strong>Step 6, handle the ESP and boot entry:</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">sudo mount /dev/nvme0n1p1 /tmp/dst/@/boot/efi
</span></span><span class="line"><span class="cl">sudo efibootmgr -c -d /dev/nvme0n1 -p 1 -L "Arch" -l '\efi\boot\bootx64.efi'</span></span></code></pre></div></div>
<p><strong>Step 7, wrap up and switch over:</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">sync
</span></span><span class="line"><span class="cl">sudo umount /tmp/dst/@/boot/efi
</span></span><span class="line"><span class="cl">sudo umount /tmp/dst
</span></span><span class="line"><span class="cl">reboot
</span></span><span class="line"><span class="cl">sudo efibootmgr -v   # verify the boot entry after reboot</span></span></code></pre></div></div>

<h2 class="relative group">This whole process is online
    <div id="this-whole-process-is-online" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#this-whole-process-is-online" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Worth emphasizing: the migration <strong>requires no downtime</strong>. The snapshot is instant; the system keeps running normally no matter how long send/receive takes; the fstab and grub edits happen on copies that live on the target disk, unrelated to the running system. The only interruption is the final reboot into the new disk — and booting from a swapped disk requires a reboot anyway, so that is not really a migration cost.</p>
<p>A few writes that land on the old disk after the snapshot (fresh logs and the like) are intentionally left behind. For a home machine that loss is irrelevant, so I did not bother with incremental send (<code>send -p</code>) to bridge the gap. Only when migrating a database that cannot stop would you need the two-phase approach: full send first, then one incremental pass right before cutover.</p>

<h2 class="relative group">Moving to genuinely different hardware
    <div id="moving-to-genuinely-different-hardware" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#moving-to-genuinely-different-hardware" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Both migrations above were disk swaps on the same machine, which skips the hardest part: <strong>hardware differences</strong>. The <a href="https://wiki.archlinux.org/title/Migrate_installation_to_new_hardware"  target="_blank" rel="noreferrer">ArchWiki migration guide</a> says this well; when switching machines, walk through:</p>
<ul>
<li><strong>CPU vendor change</strong> (Intel↔AMD): swap the microcode package (<code>intel-ucode</code>/<code>amd-ucode</code>)</li>
<li><strong>GPU vendor change</strong>: swap the graphics driver</li>
<li><strong>Boot mode differences</strong>: the target machine’s UEFI setup may differ (CSM off, Secure Boot state); create a new ESP if needed and re-register the boot entry</li>
</ul>
<p>Two improvements I plan to make next time:</p>
<ol>
<li><strong>Regenerate fstab and grub config instead of hand-sedding them.</strong> Use <code>genfstab -U /mnt</code> for the mount table, and run <code>grub-mkconfig -o /boot/grub/grub.cfg</code> in a chroot on the target. A hand-edited grub.cfg always risks being overwritten by a future grub upgrade.</li>
<li><strong><code>btrfs send --proto 2 --compressed-data</code></strong> (needs btrfs-progs 6.0+ and kernel 6.0+; any current Arch qualifies). It transfers zstd-compressed blocks directly without a decompress-recompress round trip, which saves real time on cross-machine transfers.</li>
</ol>
<p>One more trap worth writing down: btrfs snapshots are <strong>not recursive</strong>. A nested subvolume appears as an empty directory inside a snapshot. My five subvolumes are all siblings at the top level so this never bites me, but if I ever nest a subvolume inside <code>@home</code>, it needs separate handling before send.</p>
]]></content:encoded>
      
    </item>
    
  </channel>
</rss>
