Switching distros
Move a host to another distro, or another bootc image, while your home and FormalSpec's store carry over, and get a disk onto a layout that holds several roots.
The system block is the only part of a spec that names a distro. Packages from FormalSpec's own sources, Flatpaks, files, secrets and user services are the same on every distro, so moving a host to another distro means changing that block and installing the new root. FormalSpec installs the new distro beside the running one, on the same disk, and both stay bootable until you delete the old one.
Before you start
These commands repartition disks, convert filesystems, delete whole roots and change which system your machine boots. Read this section before you run any of them.
bootc images and fspec switch have only run against test roots so far. fspec disk, fspec switch and fspec forget have not been through enough real machines to trust with data you cannot restore. Run them on a machine you can reinstall, with a backup you have checked.
| Command | What it destroys | What protects you |
|---|---|---|
fspec disk init <device> | Everything on the disk | You type the device name. A disk with data needs --backup |
fspec disk migrate | Changes partitions and moves files in place | You type the disk name for each destructive step, and it needs a recent snapshot or --backup |
fspec disk migrate <device> | Converts an ext4 root to btrfs | You type the device name. It needs --backup or --no-backup |
fspec switch --to-image --yes | Replaces the running system and reinitialises /boot | --yes |
fspec switch --reset-var | Deletes everything in /etc and /var that is not kept, at the next boot | Removing the pending marker cancels it |
fspec forget <distro> | Deletes that root and its boot entries | Refuses the running root, and refuses unless this root owns boot |
fspec switch --distro, --beside | Nothing outside the new root | Refuses a name that already exists |
Have these ready before a switch:
- A backup of everything you care about, on another disk.
- A way to boot something else: the installer ISO on a USB stick, which is a live Linux system with
fspecand every bootstrapper on it. - Physical access to the machine, or a console, in case it does not come back on the network.
Recover from a failed switch covers what to do when a new root does not boot.
What carries over
FormalSpec never manages personal files. It only touches paths it declared or attributed to a package. Declare protect to make it refuse any action under your homes:
host(
name = "desktop",
platform = "x86_64-linux",
system = fedora(packages = ["NetworkManager", "sudo"]),
users = [user("alex", uid = 1000, shell = "fish")],
protect = ["/home", "/var/home"],
persist = ["/var/lib/libvirt"],
modules = [...],
)
protecthas no default: the list is exactly what you declare. Afile,secretor packagedatapath under an entry fails the plan with<path> is under protected path /home. Your own home stays usable, since the spec manages files there; other users' homes stay refused.uidonuser(...)pins the account's UID, so file ownership in the shared home is right in every root.persistlists paths thatfspec switch --reset-varkeeps on an image host.
On the default disk layout, /home and the store at /opt/fspec are shared subvolumes that every root mounts. A new root finds your home and every store package already there, and does not download the store again.
Switch between bootc images
On an image host, a different distro is a different base image.
Change
basein the host'simage(...):system = image(base = "ghcr.io/ublue-os/aurora:stable"),- Commit and push. The
fspec-imagesworkflow builds the new image and opens a pull request with its digest. Merge the pull request, pull it, and apply:
git pull fspec applyApply runs
bootc switchand stages the new image.- Reboot. The machine boots the new distro, and the old one is still in the boot menu.
Your home, Flatpaks, store packages and user services sit outside the image and carry over.
Reset /etc and /var to the new image
bootc keeps /etc and /var across a switch, so configuration from the old distro stays. To start from the new image's defaults:
After the apply that staged the new image, run:
$ sudo fspec switch --reset-var /etc and /var reset at the next boot; remove /var/lib/fspec/reset-var/pending to cancel- Reboot.
The running system cannot reset its own /etc, so the command only stages the reset. It writes a keep list, a script and a pending marker under /var/lib/fspec/reset-var/, and enables fspec-reset-var.service, which runs early in the next boot. The script deletes everything under /var and /etc that is not kept, copies /usr/etc over /etc, and removes pending last, so an interrupted run repeats on the next boot.
Always kept:
| Path | Why |
|---|---|
/var/lib/fspec, /opt/fspec, /var/opt/fspec | FormalSpec's state and store |
/home, /var/home, /var/roothome | The homes |
/etc/passwd, /etc/shadow, /etc/group, /etc/gshadow, /etc/subuid, /etc/subgid | Accounts and passwords. Apply recreates declared users, but not their passwords |
The host's persist paths | You asked for them |
To cancel before you reboot:
sudo rm /var/lib/fspec/reset-var/pending
Switch to a classic distro
fspec switch --distro installs a classic distro as a new root beside the running one. It takes fedora, arch, debian, ubuntu, void or aerynos. The disk must be on one of the supported layouts; fspec disk plan gets it there.
Change the host's
systemto the target distro, with that distro's package names:system = void(packages = ["base-system", "NetworkManager", "dbus", "sudo"]),The new root is built from this block. Nothing is read from a second declaration.
Check that the switch fits the disk. Run as root,
planprints the layout, which root owns boot, and whether the target fits, instead of the usual plan:$ sudo fspec plan host desktop declares void and this machine runs fedora: `fspec switch --distro void` installs it beside this root disk: the default layout boot is owned by fedora, this root switch --distro void: fits this diskInstall the new root:
sudo fspec switch --distro void --host desktopFedora needs
--release <number>. For Debian and Ubuntu,--releasenames the suite; the defaults arestableandnoble. The running system needs the target's bootstrapper on itsPATH:dnf,pacstrap,debootstrap,xbps-install, or for AerynOS the staticmossfrom AerynOS's os-tools releases.When it finishes:
void is installed beside fedora: reboot and pick "Void (fspec)" from the boot menu. Run `fspec switch --adopt-boot` there to hand boot over before you `fspec forget` this root- Reboot and pick the new entry from the boot menu.
Log in and apply. The switch applied only the system scope, so this first apply in the new root writes your user files and services:
fspec applyMake the new root own boot, so it starts by default:
sudo fspec switch --adopt-bootWhen you no longer need the old root, delete it from the new one:
sudo fspec forget fedora
The command refuses --distro for a host whose system does not already declare that distro.
Who owns boot
Exactly one root owns boot: the one the machine starts by default. On the default layout that is the default line in /efi/loader/loader.conf, on ZFS the pool's bootfs, and on the BIOS layout the root whose GRUB the boot sector loads. The first switch on a machine records the running root as the owner.
fspec switch --adopt-boot, run from the root you booted, makes it the owner:
| Layout | What it does |
|---|---|
| Default | Writes default <distro>-* to loader.conf and runs bootctl set-default "", so no EFI variable outranks it |
| ZFS | zpool set bootfs=rpool/ROOT/<distro> rpool |
| BIOS | Installs this root's GRUB to the boot sector and writes a menu that lists every other root, then records this root as the owner |
On a bootc root, bootupd owns the bootloader, and --adopt-boot refuses.
Delete an old root
fspec forget <distro> deletes a root that fspec switch created, or the NixOS that a migration kept in @nixos. It removes the root's kernels and boot entries from the ESP, or its folder in the shared boot partition, then deletes the root with btrfs subvolume delete --recursive or zfs destroy -r. Every system file the old distro left behind goes with it.
It refuses:
- the root you booted;
- any root while the running root does not own boot. Run
fspec switch --adopt-bootfirst, so the bootloader's default never goes with a deleted root.
fspec forget aerynos also deletes /efi/EFI/aerynos, and fspec forget nixos deletes EFI/nixos and the nixos-*.conf entries.
Install beside the running root
--beside <name> installs a new root into the subvolume @<name> on the running root's btrfs filesystem and leaves everything else alone: the running root, loader.conf, the other roots' entries and the EFI variables. It works on a disk that is not on the default layout yet. It needs EFI, a btrfs root, and systemd-boot on an ESP mounted at /efi, /boot/efi or /boot. It refuses a name that already exists.
| Host declares | Command | Installs |
|---|---|---|
image(...) | fspec switch --to-image --beside <name> | The locked image, with bootc install to-filesystem in podman |
tumbleweed() | fspec switch --beside <name> | openSUSE Tumbleweed at the snapshot fspec.lock pins, with zypper in the official Tumbleweed container |
- Lock what you install. An image host needs a digest (
fspec update --images), a Tumbleweed host a snapshot (fspec update). Install:
sudo fspec switch --beside tw --host laptopBoot it once, without making it the default. The command prints the entry to use:
sudo bootctl list sudo bootctl set-oneshot tw-<kernel>.conf rebootbootctl listshows the entry's exact name. For an image, the first entry is<name>-ostree-1.conf.
The new root keeps the identity of the root --inherit @<subvolume> names, by default the running one: its ssh host keys, NetworkManager connections, /var/lib/tailscale, and the host's declared users with their UID, groups, home and password hash. The running root's home subvolume is mounted in the new root as /var/home.
A trial boot counts tries. The new entry gets three, and a boot that fails in the initramfs reboots instead of waiting at a shell. A boot is blessed only once sshd listens and tailscale status reports the node online. A Tumbleweed root waits for tailscale only when the inherited root had tailscale state. An unblessed boot reboots after ten minutes, and a hung one after 60 seconds through the watchdog. After three failed tries, systemd-boot goes back to the default entry. Making the new root the default is a separate step you take by hand once it has booted and been blessed. An entry set as default boots even after it has run out of tries.
--device <btrfs partition> with --inherit @<root> installs Tumbleweed from a live system that does not run from that filesystem. --pull-from <repository> pulls an image from a plain-HTTP registry mirror; the new root still updates from the locked repository.
Convert a classic machine to an image
sudo fspec switch --to-image --yes
This replaces the running classic system with the host's locked bootc image, using bootc install to-existing-root. It reinitialises /boot, so it needs --yes. It needs an image(...) host with a digest in fspec.lock, refuses a machine that already runs bootc, and needs the default EFI layout: bootc cannot install to a ZFS root or the shared BIOS boot partition. Prefer --to-image --beside <name>, which leaves the running system in place.
Disk layouts
Several roots share one disk and one bootloader. A switch needs the disk on one of three layouts.
Default
The one fspec disk init creates and the one to use:
| Part | What it is |
|---|---|
| GPT partition table | |
| 2 GiB EFI system partition (FAT32) | Mounted at /efi in every root, shared by every distro and by Windows if it is there |
| LUKS2 container (optional) | Holds the rest of the disk |
| One btrfs filesystem | Subvolumes @<distro> (one per root), @home, @swap (holding swapfile), @snapshots, and @fspec mounted at /opt/fspec |
| systemd-boot on the ESP | Each distro's kernels go to the ESP under its own name, so roots never overwrite each other's kernels |
Each root keeps its own /boot for its packages, and its own kernel tooling writes the ESP: kernel-install with unified kernel images on Fedora, Debian and Ubuntu, a mkinitcpio preset on Arch, a kernel hook fspec installs on Void, and blsforme on AerynOS. Each root's kernel command line comes from its /etc/kernel/cmdline.
ZFS
A pool rpool with rpool/ROOT/<distro> boot environments, rpool/home and rpool/fspec, booted by ZFSBootMenu from the ESP. Debian, Ubuntu and Void ship the ZFS module; Arch, Fedora and AerynOS are refused on ZFS. The distros do not sign out-of-tree ZFS modules, Ubuntu excepted, so Secure Boot, and with it EAC Linux v2, only works with Ubuntu on ZFS. plan says so. ZFS takes no --luks, since it encrypts datasets itself.
BIOS
For machines without UEFI or with an MBR disk: GRUB in BIOS mode and a shared ext4 boot partition mounted at /var/lib/fspec/boot in every root, each root using its own folder in it as /boot. There are no unified kernel images, and no Secure Boot, so EAC cannot work. AerynOS is refused.
What each distro needs
| Distro | Smallest ESP | ZFS | BIOS |
|---|---|---|---|
| Fedora | 1 GiB | No | Yes |
| Arch | 512 MiB | No | Yes |
| Debian | 512 MiB | Yes | Yes |
| Ubuntu | 512 MiB | Yes | Yes |
| Void | 256 MiB | Yes | Yes |
| AerynOS | 1 GiB | No | No |
A switch also checks that the ESP or the shared boot partition has room for the new distro's kernels, and refuses with the fix when it does not.
Get a disk onto a supported layout
See what is needed
sudo fspec disk plan
disk plan prints the numbered steps that bring the running disk onto a supported layout, and marks the destructive ones. It changes nothing. On a disk that is already there:
this disk is on the default layout; nothing to migrate
Migrate in place
sudo fspec disk migrate
disk migrate runs the steps that can be done on the running system: adding the shared subvolumes and moving /home and /opt/fspec into them, moving a root at the top level into @<distro>, putting the root's kernels on the ESP, and carving a 2 GiB ESP out of the btrfs partition when the old one is too small. The new ESP keeps the old one's partition GUID and FAT serial, so firmware entries and every root's fstab keep finding it. The old ESP stays as a copy.
disk migrate does not encrypt an existing disk, and does not shrink a btrfs filesystem inside LUKS. Both need fspec disk init on another disk.
After it finishes, reboot and run fspec disk plan again. It lists what the move left behind, such as the old root's files and the copies of /home and /opt/fspec, as destructive steps.
Convert an ext4 root
An ext4 root converts to btrfs only while it is not mounted:
- Boot a live system, such as the installer ISO.
See the steps:
sudo fspec disk plan /dev/nvme0n1p2Convert, naming a backup:
sudo fspec disk migrate /dev/nvme0n1p2 --backup /mnt/backup/desktop.tarIt keeps the filesystem UUID, fixes the root's
fstabline and rebuilds its initramfs and GRUB configuration.- Boot the converted root and run
sudo fspec disk migratefor the rest of the steps.
Start from a blank disk
sudo fspec disk init /dev/sdb --luks --distro debian --host server
disk init partitions and formats a disk from scratch, creates the shared subvolumes or datasets, installs the bootloader, and with --distro and --host installs the first root the same way switch --distro does.
| Flag | Default | Meaning |
|---|---|---|
--layout | default | default, zfs or bios |
--luks | off | Put the btrfs filesystem in LUKS2 |
--distro, --host | none | Install this distro as the first root, from this host's declaration |
--release | per distro | Fedora release number, or Debian or Ubuntu suite |
--swap | 4 | Size of the swapfile in @swap, in GiB |
--backup | none | A backup of the disk's contents; required when the disk is not blank |
It refuses a partition (pass the whole disk), a disk with anything mounted, and a disk smaller than 16 GiB.
Safety checks
Every destructive step:
asks you to type the device name back:
This erases everything on /dev/sdb. Type /dev/sdb to continue:Anything else stops it with
you typed, not /dev/sdb; nothing was changed;- refuses a mounted target, except for the steps built for one (resizing btrfs and moving subvolumes run on the mounted filesystem);
- checks for a backup first: a read-only snapshot under
@snapshotstaken in the last 24 hours, or--backup <path>naming a file or directory modified in the last 24 hours. A blank disk needs neither.
The backup check only looks at the timestamp. It does not check that the backup is complete or that it restores.
Migrate a NixOS ext4 machine in place
A NixOS root on ext4 can move onto the default layout without a reinstall. NixOS stays bootable the whole time, and a failure puts it back on ext4 as it was.
NixOS writes its fstab and initrd from its configuration, and every older generation mounts / as ext4. So you build the new system first, and fspec converts the disk under it.
Prerequisites:
- A NixOS system built to mount
/as btrfssubvol=@nixoson the same filesystem UUID,/homefrom@homeand/swapfrom@swap, copied into the ext4 root's/nix/store(nix copy --to). - A systemd-boot entry
loader/entries/fspec-live*.confon the ESP that boots a live system, such as the installer ISO, from the disk. fspec does not write this entry. - The EFI variable
LoaderEntryDefaultcleared:bootctl set-default "".
Steps:
- Boot the
fspec-liveentry. The NixOS root must not be mounted. See the steps:
sudo fspec disk plan /dev/nvme0n1p2 --nixos-system /nix/store/<hash>-nixos-system-desktop-<version>Run them:
sudo fspec disk migrate /dev/nvme0n1p2 --nixos-system /nix/store/<hash>-nixos-system-desktop-<version> --backup /mnt/backup/desktop.tarType the device name when asked.
--no-backupconverts without one, for a disk with nothing to save.- Reboot. The new NixOS generation boots from
@nixos. From the booted
@nixos, see what is left to clean up:sudo fspec disk planIt lists deleting the old top level with
ext2_saved, the copy of/homein@nixosand its old/swapfile. Deletingext2_savedends the way back to ext4, so do it last, once you trust the new layout.
What the migration does:
- Makes
fspec-live*the default entry first, so a reset during the migration comes back into the live system. - Converts with
btrfs-convert --uuid copy, keepingext2_saved, snapshots the top level into@nixos, and creates@home,@swap,@snapshotsand@fspec. - Runs NixOS's own
switch-to-configuration bootin a chroot, so NixOS writes its entry. The older generations cannot boot btrfs, so their entries move toloader/entries.ext4/on the ESP. - Gives the new entry three tries. A failed initrd reboots and uses a try; a failed mount after the initrd still reaches
sshdinstead of an emergency shell. After three failed boots, systemd-boot falls through tofspec-live. Once NixOS marks a boot good, those trial options are dropped again.sudo fspec disk unmaskdrops them by hand. - Starts
fspec-compress.service, which recompresses@homeand@nixoswith zstd until the filesystem is 80% full or the live system reboots.
How a failed NixOS migration rolls back
Every step records its undo, synced to disk before the step runs, in fspec/disk-journal on the ESP. When a step fails, disk migrate rolls back at once: it runs btrfs-convert -r and puts the ESP's loader, EFI/systemd and EFI/BOOT back, so NixOS boots from ext4 as before. The error ends with rolled back: <device> is ext4 again, with NixOS as it was.
When the migration was killed or the machine reset, boot fspec-live again. disk plan reports the interrupted run:
an interrupted migration left <esp>/fspec/disk-journal; `fspec disk migrate /dev/nvme0n1p2` rolls it back
Run that command to roll it back.
After a successful migration, and until you delete ext2_saved, btrfs-convert -r <device> from a live system still goes back to ext4.
Recover from a failed switch
The new root does not boot. Reboot, and pick the previous root from the systemd-boot menu. A switch --distro never changes the old root, and a --beside install never changes the default entry, so the old root boots as before. After three failed tries, a trial entry stops being picked on its own.
No root boots. Boot the installer ISO from USB, or the fspec-live entry if your ESP has one. From there, mount the btrfs filesystem with subvolid=5 to reach every root, your home in @home and the snapshots in @snapshots.
An image update broke the system. Pick the previous deployment in the boot menu, or run sudo bootc rollback and reboot.
A --reset-var is staged by mistake. Remove /var/lib/fspec/reset-var/pending before you reboot.
A NixOS migration failed. See How a failed NixOS migration rolls back.
How a switch works
fspec switch --distro reads the machine (lsblk, findmnt, /sys/firmware/efi, the running root and its kernel command line), then:
- Checks the target distro against the layout, and stops with the fix if it does not fit. On the BIOS layout, it refuses unless the running root owns boot.
- Creates the root,
@<distro>orrpool/ROOT/<distro>, and mounts it under/run/fspec/switch/. - Bootstraps the base system from the running OS with
dnf --installroot,pacstrap -K,debootstrap,xbps-install -rormoss -D, including the layout's boot packages. - Writes the new root's
fstab,crypttab,/etc/kernel/cmdlineand kernel tooling configuration, and copiesresolv.conf. The command line keeps the running kernel's machine options, such asconsole=andnet.ifnames=0, with only the root swapped. Copies the running
fspecto/usr/local/bin/fspecin the new root and the spec to/var/lib/fspec/spec, binds the shared/home,/opt/fspecand/efiin, and runs the system scope of the apply in a chroot:chroot <new root> /usr/local/bin/fspec --spec /var/lib/fspec/spec/spec.star apply --local --scope system --host <host>The new distro's own backends do the work. The
fspecbinary must run in the fresh root, so it has to be a static build. A glibc or Nix build fails there.- Runs the distro's kernel tooling once, which puts its kernels where the layout boots them.
- Unmounts, also after a failure.
Under --root, the probes are skipped and the default layout is assumed, so the commands are logged to <root>/var/log/fspec-commands.log without running.
Troubleshooting
host desktop does not declare arch as its system. Edit system = to arch(...) first: the new root is built from that block
Change the host's system to the target distro before you run switch --distro.
cannot switch to fedora: <reason>
The disk does not fit the target. The reason names the fix, for example the ESP is 512 MiB and fedora needs 1024 MiB; run fspec disk migrate to move it to a 2 GiB one.
this disk is not on a supported layout: <reasons>; run fspec disk plan for the steps
Run sudo fspec disk plan and follow its steps.
fedora needs --release <number>, the Fedora release to install
Pass --release, for example --release 44.
boot is owned by fedora, not this root (void); run fspec switch --adopt-boot from void first
forget runs only from the root that owns boot. Run sudo fspec switch --adopt-boot there.
fedora is the system you booted; boot another distro before forgetting it
Boot the root you keep, then forget the other one.
this is a bootc deployment, where bootupd owns the bootloader; switch --adopt-boot does not handle bootc roots yet
On a bootc root, set the default entry by hand.
no image is staged; change image(base = ...), run fspec apply, then switch --reset-var
--reset-var resets to the staged image. Stage one first.
--beside installs the host's image with --to-image, or a host that declares tumbleweed()
--beside without --to-image installs Tumbleweed only. For another classic distro, use --distro.
<device> is smaller than 16 GiB, too small for a 2 GiB ESP and a root
Use a larger disk.
<device> holds data; name a backup taken in the last 24 hours with --backup
disk init erases the disk. Take a backup and name it, or use a blank disk.
the backup <path> is <n> hours old; take a new one first
Backups older than 24 hours do not count.
<device> holds NixOS, whose fstab and initrd come from its configuration: build a system that mounts / as btrfs subvol=@nixos, copy it into this root's /nix/store, and name it with --nixos-system
See Migrate a NixOS ext4 machine in place.
the ESP has no fspec-live entry to boot while the migration runs; install the live entry first
Add a systemd-boot entry that boots a live system from the disk, named fspec-live*.conf.
the EFI variable LoaderEntryDefault outranks loader.conf; clear it with bootctl set-default "" first
Run sudo bootctl set-default "".