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:
| Subvolume | Mount Point | Purpose |
|---|---|---|
@ | / | Root, primary snapshot target |
@home | /home | Independent from root snapshots |
@log | /var/log | Exclude logs |
@cache | /var/cache | Exclude cache |
@tmp | /var/tmp | Exclude 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 +Cor mount withnodatacow), or use a separate ext4/XFS partition entirelySQLite: 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;"