index
In-place migration utility that converts an OSTree-backend bootc system
(e.g. Bluefin) into a ComposeFS-backend bootc system (e.g. Dakota), without
reinstalling and without losing /home, /var, /etc customizations,
flatpaks, container storage, or user accounts.
Migrate Bluefin β Dakota (quick start)β
The common case: Bluefin stable (btrfs) β Dakota stable. Five steps. Your old OSTree deployment stays in the boot menu as a fallback the whole time.
β οΈ Back up anything you can't afford to lose first. This rewrites how your system boots. It preserves
/home,/var,/etc, flatpaks, container storage, and user accounts β but treat it as risky until you've rebooted and confirmed everything works. It's reversible until you runcommit(step 5).
1. Get the migrator. Download the latest prebuilt binary (x86_64; for arm64
swap in aarch64-unknown-linux-gnu):
curl -fsSL -o bmc.tar.gz \
https://github.com/tuna-os/bootc-migrate-composefs/releases/latest/download/bootc-migrate-composefs-x86_64-unknown-linux-gnu.tar.gz
tar xzf bmc.tar.gz
sudo install -m755 bootc-migrate-composefs /usr/local/bin/
β¦or pull the container image
A minimal image ships the same binary, useful when GitHub Releases is rate-limited/blocked, or to COPY --from= it into another Containerfile:
podman create --name bmc-extract ghcr.io/tuna-os/bootc-migrate-composefs:latest
podman cp bmc-extract:/usr/local/bin/bootc-migrate-composefs .
podman rm bmc-extract
sudo install -m755 bootc-migrate-composefs /usr/local/bin/
β¦or build from source (needs Rust)
git clone https://github.com/tuna-os/bootc-migrate-composefs
cd bootc-migrate-composefs
cargo build --release
sudo install -m755 target/release/bootc-migrate-composefs /usr/local/bin/
2. Dry-run β makes no changes, just checks your system is ready:
sudo bootc-migrate-composefs \
--target-image ghcr.io/projectbluefin/dakota:stable --dry-run
3. Migrate (~5β25 min depending on cache/network):
sudo bootc-migrate-composefs \
--target-image ghcr.io/projectbluefin/dakota:stable
4. Reboot β the new composefs entry is the default. If anything looks wrong, pick the old Bluefin / OSTree entry in the boot menu to get straight back.
sudo systemctl reboot
5. Confirm, then make it permanent:
cat /proc/cmdline | grep -o 'composefs=[0-9a-f]*' # confirms composefs boot
sudo bootc-migrate-composefs commit # one-way; removes the OSTree fallback
β οΈ Note: Phase 4 copies
/varto the composefs side. After migration, the two/vartrees are independent β changes you make on the composefs side won't be reflected if you roll back to OSTree (and vice versa). Commit only when you're satisfied with the new system.
That's it. For flags, rollback, troubleshooting, and the full phase-by-phase
breakdown, see Usage β end-to-end walkthrough.
On Bluefin LTS (XFS) or systems with LVM / LUKS / a dedicated /var
partition, the tool handles those automatically β see
docs/filesystem-support.md.
Status: CI-validated, released, and proven on real hardware. Four E2E scenarios β btrfs, ext4, LUKS+XFS, and LVM-on-LUKS with a dedicated
/varβ run in CI on every push tomain(migration, commit, deep-clean, andbootc status/upgrade --checkall green). Prebuilt binaries are on the Releases page. Don't point this at a machine you can't reinstall, but the core path is stable.
Interactive wizard (TUI)β
Prefer a guided walkthrough over flags? Run the tool with no --target-image
(or tui explicitly) to launch a terminal wizard that walks through target
image selection, options, a plain-English review of what's about to happen,
and a live phase-by-phase progress view with scrollable logs:
sudo bootc-migrate-composefs tui

The wizard defaults to --dry-run and only builds the equivalent CLI
invocation shown on the Review screen β it doesn't need root just to browse;
root is required once you press Enter to actually run a migration.
Architectureβ
flowchart TB
%% ββ Source: what we migrate from ββββββββββββββββββββββββββββ
subgraph SRC["Source · OSTree-backed Bluefin"]
direction TB
S_USR["<b>/</b> — OSTree hardlink farm<br/>/usr/etc · /ostree/repo object store"]
S_ETC["<b>/etc</b><br/>live, 3-way-merge source"]
S_VAR["<b>/ostree/deploy/<n>/var</b><br/>user state"]
S_BOOT["<b>/boot/loader/entries</b><br/>GRUB BLS · ostree-*"]
end
%% ββ The migration tool: six phases, one command βββββββββββββ
subgraph BIN["bootc-migrate-composefs · 6 phases (0–5)"]
direction TB
P0["<b>Phase 0 · Preflight</b><br/>ESP size · NVRAM · reflink"]
P1["<b>Phase 1 · OSTree import</b> (optional)<br/>reflink objects → composefs store"]
P2["<b>Phase 2 · OCI pull</b><br/>target image → composefs store"]
P3["<b>Phase 3 · EROFS seal</b><br/>build · seal · capture config digest"]
P4["<b>Phase 4 · Stage deploy</b><br/>3-way /etc merge (sealed mount)<br/>symlink prune · identity-DB union<br/>/var copy · .origin (tini)"]
P5["<b>Phase 5 · Bootloader</b><br/>sd-boot from sealed mount<br/>BLS entries on ESP · NVRAM"]
P0 --> P1 --> P2 --> P3 --> P4 --> P5
end
%% ββ Target: what we migrate to ββββββββββββββββββββββββββββββ
subgraph DST["Target · ComposeFS-backed Dakota"]
direction TB
D_CFS["<b>/composefs/</b><br/>images/ (EROFS) · objects/ · streams/"]
D_STATE["<b>/state/deploy/<verity>/</b><br/>etc/ · <verity>.origin"]
D_VAR["<b>/state/os/default/var</b><br/>bind-mounted as /var by initramfs"]
D_BOOT["<b>/EFI/Linux/bootc_composefs-<verity>/</b><br/>vmlinuz · initrd<br/>/loader/entries/*.conf · systemd-bootx64.efi"]
end
%% ββ Data flows across the lanes βββββββββββββββββββββββββββββ
S_USR -- "ostree object reflinks" --> P1
P2 --> D_CFS
S_ETC -- "current / old / new merge" --> P4
P4 --> D_STATE
S_VAR -- "verbatim copy<br/>(containers · flatpaks · machine-id)" --> D_VAR
P3 -- "sealed config digest<br/>(not rootfs verity)" --> P4
P3 --> P5
P5 -- "copy from sealed mount" --> D_BOOT
P5 -. "efibootmgr · Linux Boot Manager" .-> NVRAM(["UEFI NVRAM"])
RUN["<b>Booted Dakota</b><br/>/ = composefs overlay (RO)<br/>/etc writable ← state/<br/>/var writable ← state/os/default/var"]
DST -. "reboot → systemd-boot → kernel<br/>cmdline composefs=<verity>" .-> RUN
%% ββ Lane colours ββββββββββββββββββββββββββββββββββββββββββββ
classDef src fill:#e3f0ff,stroke:#3b82c4,color:#0b2545;
classDef bin fill:#fff4e0,stroke:#d9920b,color:#5a3a00;
classDef dst fill:#e4f7e7,stroke:#3ca34a,color:#06311a;
classDef run fill:#f3e8ff,stroke:#8b5cf6,color:#2e1065;
class S_USR,S_ETC,S_VAR,S_BOOT src;
class P0,P1,P2,P3,P4,P5 bin;
class D_CFS,D_STATE,D_VAR,D_BOOT dst;
class RUN,NVRAM run;
Key insight: Phase 3 runs bootc internals cfs oci seal which prints the
sealed manifest's config digest. Phases 4 and 5 pass that sealed config
digest (not the rootfs verity) to bootc cfs oci mount β the overlay then
exposes real file content for /etc, kernel, initrd, systemd-boot, and kernel
modules, eliminating the need to re-stream OCI layers at runtime.
What it doesβ
Six phases (numbered 0β5 to match the console output), run as one command:
- Phase 0 β Preflight β free-space, reflink/CoW, UEFI, NVRAM-writable, ESP capacity.
- Phase 1 β OSTree import (optional) β reflinks existing OSTree file
objects into the composefs object store so the pull in Phase 2 is mostly
dedup. Skipped with
--skip-import. - Phase 2 β OCI pull β
bootc internals cfs oci pullof the target bootc image into the composefs store. - Phase 3 β EROFS seal β builds and seals the EROFS image, capturing the sealed config digest that Phases 4 and 5 mount.
- Phase 4 β Stage deploy β 3-way
/etcmerge (read from the sealed mount, no registry streaming), identity-DB line-union, dangling/usr/*symlink pruning,/vardata copy intostate/os/default/var, and.origin(boot_digest, manifest_digest) written via tini. - Phase 5 β Bootloader β copies
systemd-bootx64.efifrom the sealed mount to the ESP (no registry streaming), writes BLS entries, and registersLinux Boot Managerin UEFI NVRAM. The original GRUB entry is left as a rollback escape hatch.
After a successful reboot into the composefs entry, bootc-migrate-composefs commit removes the OSTree fallback and makes composefs permanent.
Usage β end-to-end walkthroughβ
Before you start. This tool rewrites bootloader state and copies the entire
/var. Don't run it on a machine you can't reinstall in a pinch. Until you runcommit, it's reversible β but a fresh backup is still cheap insurance.
1. Decide your targetβ
The migration takes a --target-image β the composefs-backed bootc image
you want to end up on. Today the validated path is Bluefin β Dakota:
ghcr.io/projectbluefin/dakota:stable # default target
If you're migrating a different OSTree-backed system (Aurora, Silverblue),
point --target-image at the composefs-flavored equivalent.
2. Check readiness with a dry-runβ
sudo bootc-migrate-composefs \
--target-image ghcr.io/projectbluefin/dakota:stable \
--dry-run
Things to confirm in the report:
Booted OSTree backend: Yesβ required; ifNothe tool refuses to run.UEFI Boot Mode: Yes+NVRAM writable: Yesβ required for the systemd-boot path; on BIOS-only or locked NVRAM pass--bootloader grub2.ESP Free Space: β₯ 150 MBβ we copysystemd-bootx64.efifrom the target image onto the ESP.Reflink (CoW) Support: Yesβ btrfs and XFS both support reflink.ComposeFS free space: β₯ 1.1 Γ ostree_repo_sizeβ the composefs object store is built by reflinking your existing OSTree objects.
3. Run the migrationβ
sudo bootc-migrate-composefs \
--target-image ghcr.io/projectbluefin/dakota:stable
Expect ~5β10 minutes on warm caches, ~15β25 minutes on a cold pull. Six phase headers (0β5) print as it goes:
| Phase | What's happening | Why it might take a while |
|---|---|---|
| 0 β Preflight | Same checks as --dry-run | seconds |
| 1 β OSTree import (optional) | Reflinks existing OSTree file objects into the composefs object store so Phase 2 mostly dedups | tens of seconds to a few minutes; skip with --skip-import |
| 2 β OCI pull | bootc internals cfs oci pull of the target image | minutes (network-bound) |
| 3 β EROFS image | Builds + fs-verity-signs the composefs metadata image | seconds |
| 4 β Stage deploy | 3-way /etc merge (from sealed mount), dangling-symlink prune, identity-DB line-union, /var copy to state/os/default/var, .origin file written | ~1 minute |
| 5 β Bootloader | Copies systemd-boot from mounted image, writes BLS entries, registers NVRAM | ~30s |
When it ends with === MIGRATION COMPLETED === the on-disk state is
ready. Reboot:
sudo systemctl reboot
4. Validate the composefs bootβ
Log in (your existing accounts and SSH keys still work) and check:
cat /proc/cmdline # must contain composefs=<hex>
bootc status # should report the composefs deployment
bootc status --json | jq .status.booted.composefs # non-null
Spend a day on it. Run your usual workflow β flatpaks, dnf, containers,
homebrew, GNOME extensions, whatever. Everything that lived under /home,
/var, and /etc on Bluefin should be where you left it. If something
is missing or broken, you can roll back (see below).
A login banner (/etc/motd.d/85-bootc-migrate-composefs) reminds you to run
commit on every login until you do, so a live migration doesn't sit
forgotten in this dual-boot state indefinitely. It clears itself once
commit runs β or once undo runs, since at that point there's nothing
left to commit.
5. Make it permanent (one-way)β
Once you trust the new system:
sudo bootc-migrate-composefs commit
This removes the OSTree fallback from the ESP, drops GRUB2 boot artifacts, and reclaims ~14 GiB of OSTree object store. The systemd-boot entry becomes the sole default with timeout 0.
Flagsβ
| Flag | Purpose |
|---|---|
--dry-run | Print every action; touch nothing |
--skip-import | Skip phase 1 (faster when target image is mostly new content) |
--bootloader grub2 | Stay on GRUB2 instead of installing systemd-boot |
--skip-preflight | Bypass preflight checks (don't, unless you know exactly why) |
--force | Proceed past non-fatal warnings |
Rollback / recoveryβ
Until you run commit, the migration is reversible. The previous OSTree
deployment stays bootable:
- Phase 5 only adds the systemd-boot composefs entry; it never deletes the
existing
/boot/loader/entries/ostree-*.conffiles. - The original
/ostree/deploy/<n>/deploy/<commit>.0/rootfs and/ostree/deploy/<n>/var/stay on disk. - Phase 4 copies
/vartostate/os/default/var; the OSTree side's/varis independent of the composefs side's after migration. - We push
Linux Boot Manager(systemd-boot) to the front of NVRAMBootOrderbut theFedorashim entry (which boots GRUB β OSTree) remains listed.
To boot back into OSTree-Bluefin:
- Power on; tap the firmware boot-menu key (commonly F12, F8, or Esc).
- Pick the
Fedoraentry. GRUB will show the originalostree:0menu. - Boot it. You land on the pre-migration system with its
/varand/etcintact.
Or, from a working composefs login, one-shot:
sudo efibootmgr -v | grep -E 'Fedora|Linux Boot Manager'
sudo efibootmgr --bootnext <Boot####-of-Fedora>
sudo systemctl reboot
After running bootc-migrate-composefs commit, the OSTree fallback is removed
from the ESP and rollback becomes a fresh install. The E2E test exercises the
full round-trip (composefs β OSTree β composefs) on every run.
What's preservedβ
Validated end-to-end (21+ assertions per run; see tests/run-e2e.sh):
- /var data β
/var/lib/*,/var/log/*,/var/cache/*, containers, flatpak system installs, machine-id, hidden dirs and symlinks - User homes β
/var/home/<user>/, dotfiles, project trees, SSH keys (with.sshmode preserved so StrictModes still accepts your keys), wallpapers, GNOME extensions, dconf user db, glib gsettings keyfile, homebrew Cellar, per-user flatpak installs - /etc state β
/etc/sudoers.d/*,/etc/hostsedits, customsshd_config.d/*, custom config files added under/etc/, in-place edits to image-shipped files (/etc/hostname),/etcsymlinks - Accounts β
/etc/passwd,/etc/shadow,/etc/groupline-union merged so users you added survive and users the target image needs (messagebus, polkitd, β¦) get added
What's intentionally not carried forward:
- OSTree/rpm-ostree state markers (
.updated,.rpm-ostree-shadow-mode-fixed2.stamp) - GRUB2 config files (
grub2.cfg,grub2-efi.cfg,/etc/grub.d/) β the target uses systemd-boot - Source-image
/etcfiles the target image removed (e.g.sshd_config.d/40-redhat-crypto-policies.confwhich references/etc/crypto-policies/paths Dakota doesn't ship)
Troubleshootingβ
| Symptom | Likely cause | Fix |
|---|---|---|
| Phase 0 refuses with "System is not booted into an OSTree deployment" | You're already on composefs (or a non-bootc system) | Nothing to do |
| Phase 2 fails with ENOSPC mid-pull | /sysroot/composefs is tight on the 1.1Γ heuristic | Free space or grow the partition, then rerun |
Post-reboot cat /proc/cmdline shows ostree= not composefs= | Firmware ignored the new NVRAM entry, or OVMF loaded Fedora\shim instead | Use firmware boot menu to pick Linux Boot Manager; if that fails, fall back to OSTree and report the firmware quirk |
bootc status says "No manifest_digest in origin" | You're on an old build of this tool | Update to main β version info is on the first line of the migration log |
| SSH key auth broken post-migration | Permissions changed during /var copy | Boot OSTree fallback and chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys |
| GNOME boots but session settings (wallpaper, accent) look wrong | dconf database needs recompile | dconf update as your user, or log out + back in |
| Migration went wrong and you want to undo it | Something failed mid-migration | Run sudo bootc-migrate-composefs undo (removes composefs boot artifacts, keeps object store) or sudo bootc-migrate-composefs undo --full (full cleanup including object store); then reboot into OSTree |
Requirementsβ
- Booted on an OSTree-backed bootc system (Bluefin, Aurora, Silverblueβ¦)
- UEFI firmware with writable NVRAM (for the systemd-boot path; GRUB2 fallback works on BIOS)
- Btrfs or XFS sysroot with reflink/CoW support
- ESP with β₯150 MB free
- β₯
1.1 Γ ostree_repo_sizefree on/sysroot/composefs(no reflink: 1.5Γ) - Outbound registry access for
bootc internals cfs oci pull(Phase 2 fetches the target image; Phases 4β5 read artifacts from the sealed mount, so no runtime registry access is needed after Phase 2)
Buildingβ
cargo build --release
Drops a single binary at target/release/bootc-migrate-composefs.
Requires Rust 1.85+ and a Linux host with libxkbcommon-dev.
End-to-end testsβ
A QEMU-based E2E harness lives in tests/run-e2e.sh. It installs Bluefin
into a disk image, runs the migration against a registry mirror of the
Dakota target image, reboots, and validates the full round-trip.
sudo ./tests/run-e2e.sh
Overridable via env: BASE_IMAGE, TARGET_IMAGE, DISK_SIZE,
FILESYSTEM, SKIP_SETUP. The CI matrix runs four scenarios: btrfs (default), XFS+ext4-loopback, LUKS+XFS+crypt,
and LVM-on-LUKS with a dedicated /var.
Layoutβ
src/main.rsβ CLI surface (clap),commitandundosubcommandssrc/preflight.rsβ environment validationsrc/migration/mod.rsβ five-phase orchestrator (monolithic; uses rustix)src/composefs.rsβ wrapsbootc internals cfssrc/ostree.rsβ OSTree object scan + reflink importsrc/mergetc.rsβ 3-way/etcmergesrc/migration/bootloader.rsβ BLS entry generation (systemd-boot + GRUB2)src/migration/esp.rsβ ESP device detection, auto-mount, efibootmgrsrc/migration/phases.rsβ phase-specific helpers (registry extraction, initrd rebuild, kernel modules)tests/run-e2e.shβ QEMU E2E harness
Contributingβ
Contributions are welcome β see CONTRIBUTING.md for setup and
REVIEW.md for the code-review expectations. Run just check (clippy,
rustfmt, unit tests, shellcheck) before opening a PR. AI-assisted contributions
should follow AGENTS.md.
Licenseβ
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.