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/releases/latest/download/bootc-migrate-x86_64-unknown-linux-gnu.tar.gz
tar xzf bmc.tar.gz
sudo install -m755 bootc-migrate /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:latest
podman cp bmc-extract:/usr/local/bin/bootc-migrate .
podman rm bmc-extract
sudo install -m755 bootc-migrate /usr/local/bin/
β¦or build from source (needs Rust)
git clone https://github.com/tuna-os/bootc-migrate
cd bootc-migrate
cargo build --release
sudo install -m755 target/release/bootc-migrate /usr/local/bin/
2. Dry-run β makes no changes, just checks your system is ready:
sudo bootc-migrate \
--target-image ghcr.io/projectbluefin/dakota:stable --dry-run
3. Migrate (~5β25 min depending on cache/network):
sudo bootc-migrate \
--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 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 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 · 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 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 \
--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.
Optionally, preview what Phase 4's /etc merge will see before running it:
sudo bootc-migrate etc-drift
Lists every path where your live /etc has diverged from the OSTree factory
default (added/modified/removed/type-changed), read-only. Useful for
spotting a stale customization you no longer need before it carries forward
into the migrated system.
3. Run the migrationβ
sudo bootc-migrate \
--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) 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 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.
Automatic rollback subcommandβ
To return to the original OSTree deployment directly from the command line:
sudo bootc-migrate rollback --reboot
# or via the universal re-base CLI:
sudo bootc-rebase rollback --reboot
This verifies prerequisites, re-orders UEFI BootOrder so the OSTree entry (Fedora/GRUB) takes top priority, and reboots immediately into the OSTree deployment.
Manual firmware recoveryβ
If the system fails to boot into composefs or NVRAM state is interrupted:
- 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
Pre-migration diagnostic snapshots and logs are automatically recorded under /var/log/bootc-migrate/ on every run (preflight-*.json and migration.log) so boot configuration can be manually reconstructed if NVRAM is ever wiped.
After running bootc-migrate 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 undo (removes composefs boot artifacts, keeps object store) or sudo bootc-migrate 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.
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β
A Cargo workspace with three crates (see ROADMAP.md for why):
crates/bootc-migrate-coreβ the capability library everything else is built from: phases, preflight/readiness,/etcmerge (mergetc), OSTree object scan, registry streaming, transaction (commit/undo), target-image capability scan (scan), cross-base UID/GID remap (remap), UEFI boot-entry audit (boot_audit), DE config stash/restore (de_migrate), types.crates/bootc-migrateβ the protected MVP binary described above. CLI surface (clap),commit/undo/rollbacksubcommands, the TUI wizard. Its four E2E cells are untouchable regression gates β this binary's behavior doesn't change as new capability lands inbootc-rebase.crates/bootc-rebaseβ the universal re-base engine binary; see below.tests/run-e2e.shβ QEMU E2E harness exercising both binaries.
bootc-rebase β the universal re-base engineβ
bootc-migrate above does one proven thing: OSTree β ComposeFS.
bootc-rebase is the generalization β a routing table over
backend Γ strategy that will eventually cover every bootc re-base shape
(same-backend image swaps, cross-backend conversions, bootloader changes,
cross-distro-family moves, desktop-environment switches). It's newer and less
battle-tested than the MVP binary; treat subcommands marked skeleton or
read-only below as previews, not yet full features.
cargo build --release -p bootc-rebase
| Subcommand | What it does | Status |
|---|---|---|
scan <image> | Registry-streamed capability probe of a target image β composefs/ostree readiness, fs-verity requirement, transient root/etc, bootloader payload, desktops, base OS identity, sysusers, initramfs flavor, filesystem expectation, and a Compatible: YES/NO verdict with reasons. --json for machine output. | Done |
rebase --target-image <image> | Re-base the running system, routing on --source-backend/--target-backend through the strategy table below. --plan prints the route and exits without touching the system. | Implemented for ostreeβcomposefs (the MVP pipeline), composefsβcomposefs (image swap), and ostreeβostree (native bootc switch, with cross-base UID/GID remap when host and target disagree on distro family β pass --accept-cross-base to proceed past the report) |
rollback [--reboot] | Re-order UEFI BootOrder back to the previous deployment. | Done |
boot-entries [--json] | Enumerate and classify UEFI boot entries: dead (loader path missing), generic-label, duplicate, firmware-managed. Read-only β reports what could be cleaned up; removes nothing. | Read-only audit; interactive cleanup + distro-branding rename not yet implemented (#31) |
de-migrate stash|restore | Move a user's desktop-environment config (GNOME dconf/gnome-shell, KDE kdeglobals/plasma/β¦) into or out of a stash directory around a cross-DE re-base β union of paths per issue #68, never deletes. --run-hooks executes pre-switch.d/post-switch.d scripts with REBASE_FROM_DE/REBASE_TO_DE/REBASE_STASH_DIR/REBASE_HOME set. --dry-run previews without touching anything. | Stash/restore mechanics + hook contract done; target-image DE detection and wiring into rebase itself are not yet implemented (depends on the cross-base hardening milestone landing first) |
migrate-bootloader --to systemd-boot | GRUB2 β systemd-boot conversion, standalone of a backend re-base. | Not implemented β the subcommand exists and always refuses; only the pure BLS-entry/kernel-arg/entry-token logic it will use has landed. Live ESP populate + NVRAM cutover + the kernel-install resync hook (without which a flipped system would silently boot stale kernels) are deliberately deferred pending explicit sign-off and a dedicated E2E cell β see #65 for the full implementation plan |
rebase's routing table (crates/bootc-rebase/src/routing.rs is the single
source of truth the CLI consults before touching anything):
| From β \ To β | ostree | composefs |
|---|---|---|
| ostree | OstreeDeploy (native bootc switch) | CoreMigration (this repo's proven phase 0β5 pipeline) |
| composefs | planned, not implemented | ImageSwap |
Roadmapβ
Full milestone plan, current status per issue, and design decisions live in ROADMAP.md β start there for "what's next" and "why did we choose X over Y."
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.