A writeup of converting an Oracle Cloud Ubuntu 26.04 ARM VM from ext4 to Btrfs in place.

WARNING: The operations in this post involve an in-place filesystem conversion — if anything goes wrong, data cannot be recovered. My VM was a blank machine with no important data, which is the only reason I felt comfortable doing this. If your machine has anything you can’t afford to lose, make sure you have a full backup first (Oracle Cloud Boot Volume Backup, rsync to a remote, dd image, etc.), and verify it’s actually restorable before proceeding. A failed filesystem conversion with no backup means total data loss — there’s no undoing it.

Background

Oracle Cloud free ARM instance, 150 GB disk, with a single ext4 root partition as the system disk. I wanted transparent compression, snapshots, and subvolume management from Btrfs, but without reinstalling the OS.

Approach: initramfs Hook + btrfs-convert

The core idea: you can’t convert the root partition while it’s mounted, so the plan is to use the local-premount stage of initramfs — at that point the root partition hasn’t been mounted yet — and run btrfs-convert there.

1. Install btrfs-progs

sudo apt install btrfs-progs

2. Create the initramfs Hook

Bundle the btrfs-convert and btrfs binaries into the initramfs:

# /etc/initramfs-tools/hooks/btrfs-convert
#!/bin/sh
set -e
PREREQ=""
prereqs() { echo "$PREREQ"; }
case $1 in prereqs) prereqs; exit 0;; esac
. /usr/share/initramfs-tools/hook-functions
copy_exec /usr/bin/btrfs-convert /usr/bin
copy_exec /usr/bin/btrfs /usr/bin

Run the conversion before the root filesystem is mounted. Note that btrfs-convert generates a new UUID, so the script also needs to update the GRUB config — otherwise the kernel won’t find root on reboot and you’ll drop into an initramfs shell:

# /etc/initramfs-tools/scripts/local-premount/btrfs-convert
#!/bin/sh
set -e
PREREQ=""
prereqs() { echo "$PREREQ"; }
case $1 in prereqs) prereqs; exit 0;; esac
ROOT_DEV="/dev/sda1"
FSTYPE=$(blkid -s TYPE -o value "$ROOT_DEV" 2>/dev/null || true)
if [ "$FSTYPE" = "ext4" ]; then
    OLD_UUID=$(blkid -s UUID -o value "$ROOT_DEV")
    echo ">>> Converting $ROOT_DEV from ext4 to btrfs..."
    btrfs-convert "$ROOT_DEV"
    btrfs filesystem label "$ROOT_DEV" cloudimg-rootfs
    NEW_UUID=$(blkid -s UUID -o value "$ROOT_DEV")
    # Update the UUID in the GRUB config; /boot is a separate partition and needs to be mounted first
    if [ "$OLD_UUID" != "$NEW_UUID" ]; then
        echo ">>> UUID changed: $OLD_UUID -> $NEW_UUID, updating GRUB..."
        mkdir -p /tmp/boot
        mount /dev/sda16 /tmp/boot
        sed -i "s/$OLD_UUID/$NEW_UUID/g" /tmp/boot/grub/grub.cfg
        umount /tmp/boot
    fi
    echo ">>> Conversion complete."
fi

3. Update fstab and the GRUB Default Config

Switch fstab to mount by LABEL= (unaffected by UUID changes):

LABEL=cloudimg-rootfs  /  btrfs  defaults,noatime,compress=zstd  0 0

It’s also worth switching to LABEL in /etc/default/grub, so a future update-grub doesn’t write the old UUID back in.

4. Rebuild initramfs and Reboot

sudo update-initramfs -u -k all
sudo reboot

Subvolume Layout

After conversion, all data sits in the top-level subvolume (subvolid=5). Following the common Arch layout, create subvolumes and migrate the data:

SubvolumeMount PointPurpose
@/Root, primary snapshot target
@home/homeIndependent from root snapshots
@log/var/logExclude logs
@cache/var/cacheExclude cache
@tmp/var/tmpExclude temp files
# Mount the top-level subvolume
mount -t btrfs -o subvolid=5 /dev/sda1 /mnt/btrfs-top

# Create subvolumes
for sv in @ @home @log @cache @tmp; do
    btrfs subvolume create /mnt/btrfs-top/$sv
done

# Migrate data with rsync
rsync -aAX --exclude='/home/*' --exclude='/var/log/*' \
    --exclude='/var/cache/*' --exclude='/var/tmp/*' \
    --exclude='/proc/*' --exclude='/sys/*' --exclude='/dev/*' \
    --exclude='/run/*' --exclude='/tmp/*' \
    /mnt/btrfs-top/ /mnt/btrfs-top/@/

rsync -aAX /mnt/btrfs-top/home/ /mnt/btrfs-top/@home/
rsync -aAX /mnt/btrfs-top/var/log/ /mnt/btrfs-top/@log/
rsync -aAX /mnt/btrfs-top/var/cache/ /mnt/btrfs-top/@cache/
rsync -aAX /mnt/btrfs-top/var/tmp/ /mnt/btrfs-top/@tmp/

# Set the default subvolume
btrfs subvolume set-default /mnt/btrfs-top/@

Update fstab with individual mount entries for each subvolume, add rootflags=subvol=/@ to GRUB, and reboot.

Transparent Compression Results

After conversion, run btrfs filesystem defragment -r -czstd / on the whole disk so existing files also benefit from compression:

Type       Perc     Disk Usage   Uncompressed Referenced
TOTAL       55%      2.2G         4.0G         4.0G
none       100%      1.0G         1.0G         1.0G
zstd        40%      1.2G         2.9G         2.9G

4 GB of data compressed down to 2.2 GB, with zstd achieving a 40% compression ratio.

Ongoing Maintenance

Ubuntu’s btrfs-progs doesn’t include systemd timers, so you’ll need an extra package:

sudo apt install btrfsmaintenance
sudo systemctl enable --now btrfs-scrub.timer btrfs-balance.timer

Configure /etc/default/btrfsmaintenance:

  • scrub: monthly (verify data integrity)
  • balance: monthly (reclaim fragmented space)
  • Mount points set to “auto” to cover all Btrfs mount points automatically

Final Mount Options

noatime,compress=zstd:3,discard=async,space_cache=v2

discard=async and space_cache=v2 are enabled by default in the kernel, so they don’t need to be spelled out in fstab.

Podman + Btrfs

If you’re running containers on Btrfs, Podman is the natural fit — it natively supports btrfs as a storage driver. Set it in /etc/containers/storage.conf:

[storage]
driver = "btrfs"

Compared to Docker’s default overlay2, the btrfs driver makes each image layer its own btrfs subvolume, with copy-on-write handled natively between layers — no overlayfs union mounts needed. Practical benefits:

  • Easier deduplication: btrfs supports block-level dedup, so running bees or duperemove will automatically merge identical content shared across multiple images into a single physical copy — significant space savings on machines with many images
  • Per-container snapshots: each container’s subvolume can be snapshotted independently, making it easy to back up or roll back a specific container’s state at much finer granularity than a full-disk snapshot
  • Unified with the host filesystem: no loopback devices or separate partitions needed — container storage and the host share the same btrfs pool, which makes space management much more flexible

bees Deduplication in Practice

bees isn’t in Ubuntu’s official repos, so you’ll need to build and install it yourself. I allocated 256 MB for the hash table, and here are the results:

Processed 156002 files, 76692 regular extents (93035 refs), 64930 inline.
Type       Perc     Disk Usage   Uncompressed Referenced
TOTAL       50%      1.8G         3.5G         5.1G
none       100%      911M         911M         1.1G
zstd        34%      941M         2.6G         3.9G

Compared to the earlier numbers before any containers (4.0 GB referenced / 2.2 GB disk usage): after adding container images, referenced grew to 5.1 GB, yet disk usage actually dropped from 2.2 GB to 1.8 GB. That’s deduplication at work — the many shared layers across container images were merged into a single physical copy, and on top of that the zstd compression ratio improved further from 40% down to 34%, so the actual disk footprint is now lower than it was right after the Btrfs conversion.

Caveat: Btrfs with Databases and VM Disk Images

Btrfs’s copy-on-write semantics are a poor fit for VM disk images and most databases. VM images (qcow2, raw) and database data files involve heavy random small writes — CoW causes severe fragmentation and performance degradation. Recommendations:

  • VM disk images / traditional databases (PostgreSQL, MySQL, etc.): place them on a dedicated subvolume with CoW disabled (chattr +C or mount with nodatacow), or use a separate ext4/XFS partition entirely

  • SQLite: SQLite’s default rollback journal mode suffers from double write amplification on CoW filesystems, but enabling WAL mode works just fine — WAL uses append-only writes which don’t conflict with CoW. To enable:

    PRAGMA journal_mode=WAL;
    

    This only needs to be run once — the setting is persisted in the database file and subsequent connections will use WAL mode automatically. You can also do it from the command line:

    sqlite3 your.db "PRAGMA journal_mode=WAL;"