abupdate_web_logo

A/B Linux OS update using RAUC and GRUB

v1.1.0

Table of contents

Overview

This repository is a guide on how to build an A/B update system with RAUC and GRUB2 on x86_64 UEFI platforms and how to set up Linux Cockpit with the A/B Update page. An A/B update mechanism makes it possible to update the OS without booting into a separate minimal OS (e.g. a recovery system), and keeps the device bootable if an update turns out to be broken. It has been tested on an ASUS AAEON board with an Intel x86_64 CPU.

Versions

Table 1 - Document versions.

Version Release date What’s new
1.0.0 14.08.2026 - Initial v1.0.0 release.
1.1.0 20.08.2026 - Added a guide on how to enable the network and set a static IP.
- Added a guide on how to create a home directory.
- Added a guide on how to install Linux Cockpit and a custom page for A/B Update.

Initial setup on the device

Partition the disk

This guide uses four partitions on a single GPT disk: the ESP, the two rootfs slots and /home. Repartitioning destroys everything on the disk, and it cannot be done on the disk you are running from, so boot from a live or rescue system first.

sgdisk comes from the gdisk package:

sudo sgdisk --zap-all /dev/sda
sudo sgdisk \
  -n 1:0:+512M -t 1:ef00 -c 1:esp \
  -n 2:0:+6G   -t 2:8300 -c 2:slotA \
  -n 3:0:+6G   -t 3:8300 -c 3:slotB \
  -n 4:0:0     -t 4:8300 -c 4:home \
  /dev/sda
sudo partprobe /dev/sda

ef00 is the EFI system partition type code, which is what the firmware looks for, and 8300 is the one for a plain Linux filesystem. The -c values are only labels.

Create the filesystems:

sudo mkfs.vfat -F 32 /dev/sda1
sudo mkfs.ext4 /dev/sda2
sudo mkfs.ext4 /dev/sda3
sudo mkfs.ext4 /dev/sda4

Both slots should be the same size, and each of them has to be at least as large as the rootfs image built later in this guide, which is 6 GiB. The ESP is 512 MiB here, the same size as the esp.img of the bootloader image. /dev/sda4 takes whatever is left and holds /home.

If you write the bootloader image with dd, it replaces the filesystem on /dev/sda1, and slot B is replaced by the clone of slot A, so mkfs on those two only leaves the partitions in a usable state until then. The one that really needs its own filesystem is /dev/sda4: /home is mounted from the fstab with an fsck pass, and an unformatted partition there drops the boot into emergency mode, which in turn keeps rauc-mark-good from running.

The fstab further down needs the PARTUUID values of these partitions. sudo blkid prints them.

Set up the bootloader

There are two ways to set up the bootloader. The first one is to install it on a live device:

mkdir /tmp/grub
sudo mount /dev/sda1 /tmp/grub
sudo grub-install --target=x86_64-efi \
  --efi-directory=/tmp/grub --boot-directory=/tmp/grub \
  --bootloader-id=ab --no-nvram --recheck
sudo efibootmgr -c -d /dev/sda -p 1 -L "AB GRUB" -l '\EFI\ab\grubx64.efi'

The second one is to create a bootloader image:

dd if=/dev/zero of=esp.img count=512 bs=1M
mkfs.vfat -F 32 esp.img
mkdir /tmp/grub
sudo mount -o loop esp.img /tmp/grub
sudo mkdir -p /tmp/grub/EFI/ab
sudo grub-mkimage --format=x86_64-efi --output=/tmp/grub/EFI/ab/grubx64.efi --prefix='(hd0,1)/grub' part_gpt part_msdos chain boot ext2 fat test search search_fs_uuid configfile echo linux normal loadenv
sudo mkdir /tmp/grub/grub

Instead of sda1 and 1 you can use something else, for example sda4 and 4. The number of the ESP partition appears in several places, and all of them have to agree: /dev/sda1 in the mount and dd commands, -p 1 in efibootmgr, (hd0,1) in the --prefix of grub-mkimage, and the /etc/grub line of the fstab below. GRUB counts partitions from 1, so /dev/sda4 is (hd0,4).

Set up the bootloader environment:

sudo grub-editenv /tmp/grub/grub/grubenv set A_OK=0 B_OK=0 A_TRY=0 B_TRY=0 ORDER="A B"

Put the following content into /tmp/grub/grub/grub.cfg:

# set default menuentry (Slot A) and timeout (5s)
default=0
timeout=5
terminal_output console

any_ok=0

set ORDER="A B"
set A_OK=0
set B_OK=0
set A_TRY=0
set B_TRY=0
load_env

# select bootable slot
for SLOT in $ORDER; do
    if [ "$SLOT" == "A" ]; then
        INDEX=0
        OK=$A_OK
        TRY=$A_TRY
        A_TRY=1
    fi
    if [ "$SLOT" == "B" ]; then
        INDEX=1
        OK=$B_OK
        TRY=$B_TRY
        B_TRY=1
    fi
    if [ "$OK" -eq 1 -a "$TRY" -eq 0 ]; then
        default=$INDEX
        any_ok=1
        break
    fi
done

# reset booted flags in case both sides have failed to boot
if [ "$any_ok" -eq 0 ]; then
    if [ "$A_OK" -eq 1 -a "$A_TRY" -eq 1 ]; then
        A_TRY=0
    fi
    if [ "$B_OK" -eq 1 -a "$B_TRY" -eq 1 ]; then
        B_TRY=0
    fi
fi

save_env A_TRY B_TRY

CMDLINE="panic=60 quiet"

menuentry "Slot A (OK=$A_OK TRY=$A_TRY)" {
    linux (hd0,2)/boot/vmlinuz root=/dev/sda2 $CMDLINE rauc.slot=A
    initrd (hd0,2)/boot/initrd.img
}

menuentry "Slot B (OK=$B_OK TRY=$B_TRY)" {
    linux (hd0,3)/boot/vmlinuz root=/dev/sda3 $CMDLINE rauc.slot=B
    initrd (hd0,3)/boot/initrd.img
}

Then execute sudo umount /tmp/grub.

initrd is optional; add it if your OS needs it.

If you created an image, write it to your device and add the boot entry:

sudo dd if=esp.img of=/dev/sda1 bs=4M status=progress conv=fsync
sudo efibootmgr -c -d /dev/sda -p 1 -L "AB GRUB" -l '\EFI\ab\grubx64.efi'

Both commands have to run on the target device. efibootmgr writes the entry into the firmware of the machine it runs on, so running it where the image was built adds an entry there instead. Another way is to skip the entry altogether and copy grubx64.efi to EFI/BOOT/BOOTX64.EFI inside the image while it is still mounted, because the firmware looks for that path on its own.

Remember that the image must not be larger than the target partition.

Set up the OS

Mount your slot A. It can be either a partition or an image.

Creating an ext4 image:

dd if=/dev/zero of=disk.img count=6144 bs=1M
mkfs.ext4 -F disk.img

The image must not be larger than the slot partition it is written to, and both slots have to be able to hold it. RAUC writes such an image to the slot as is, so the filesystem keeps the size of the image: on a larger partition the remaining space stays unused until you grow the filesystem with sudo resize2fs /dev/sda2. The simplest way to avoid that is to make the image the same size as the slot.

Here is a partition mount example:

mkdir /tmp/slotA
sudo mount /dev/sda2 /tmp/slotA

Here is an image mount example:

mkdir /tmp/slotA
sudo mount -o loop disk.img /tmp/slotA

Create a minimal OS (Ubuntu):

sudo debootstrap --components=main,universe --arch=amd64 noble /tmp/slotA

Create the config directories:

sudo mkdir -p /tmp/slotA/etc/rauc /tmp/slotA/etc/grub /tmp/slotA/mnt/rauc

Put the following content into /tmp/slotA/etc/rauc/system.conf:

[system]
compatible=rauc-demo-x86
bootloader=grub
mountprefix=/mnt/rauc
bundle-formats=-plain
grubenv=/etc/grub/grub/grubenv

[keyring]
path=demo.cert.pem

[slot.rootfs.0]
device=/dev/sda2
type=ext4
bootname=A

[slot.rootfs.1]
device=/dev/sda3
type=ext4
bootname=B

Instead of sda2 and sda3 you can use something else. The slot devices also appear in several places, and all of them have to agree: device= in this file, (hd0,2) and (hd0,3) together with root=/dev/sda2 and root=/dev/sda3 in the menuentries of the grub.cfg above, and the mount, e2image and tune2fs commands in this guide.

Add a systemd service to /tmp/slotA/etc/systemd/system/rauc-mark-good.service:

[Unit]
Description=RAUC Good-marking Service
ConditionKernelCommandLine=|rauc.slot
Wants=rauc.service
After=rauc.service dbus.socket
RequiresMountsFor=/etc/grub

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/rauc status mark-good

[Install]
WantedBy=multi-user.target

Generate the keys:

openssl req -x509 -newkey rsa:4096 -nodes -keyout demo.key.pem -out demo.cert.pem -subj "/O=rauc Inc./CN=rauc-demo"

The -subj values are only an example: put your own /O= and /CN= there. You can also change the key and certificate names.

After that, copy demo.cert.pem:

cp demo.cert.pem /tmp/slotA/etc/rauc/demo.cert.pem

Modify the fstab at /tmp/slotA/etc/fstab:

PARTUUID=766fa522-8195-4568-b4cb-527e95d3a6f0  /home  ext4  defaults,nodev,nosuid  0  2
PARTUUID=0666bffd-d8ec-4475-83d2-dcd0a6688fa0  /etc/grub  vfat  umask=0077  0  0

You can get the PARTUUID values for fstab via sudo blkid. You can also add other partitions, or just use /dev/sdaN instead of PARTUUID:

/dev/sda4  /home  ext4  defaults,nodev,nosuid  0  2
/dev/sda1  /etc/grub  vfat  umask=0077  0  0

Chroot into the slot:

cd /tmp/slotA
sudo mount -t proc /proc proc/
sudo mount -t sysfs /sys sys/
sudo mount -o bind /dev dev/
sudo mount -o bind /run run/
sudo chroot /tmp/slotA /bin/bash

Install the packages:

apt update
apt install linux-image-generic
apt install netplan.io network-manager systemd-resolved
apt install grub-common rauc rauc-service

Enable the rauc-mark-good service:

systemctl enable rauc-mark-good.service

Create the file /etc/netplan/01-network-manager-all.yaml to enable networking:

network:
  version: 2
  renderer: NetworkManager

To enable an SSH connection, you need to:

  1. Install OpenSSH:
apt install openssh-server openssh-client
  1. Generate a UUID:
uuidgen
# Example output: 11418e32-9af0-11f1-969d-00155d9773ef
  1. Put the following content into /etc/netplan/90-NM-your-uuid.yaml:
network:
  version: 2
  ethernets:
    NM-your-uuid:
      renderer: NetworkManager
      match:
        name: "your-network-interface"
      addresses:
      - "192.168.0.200/24"
      dhcp6: true
      wakeonlan: true
      networkmanager:
        uuid: "your-uuid"
        name: "Wired connection 1"
        passthrough:
          connection.autoconnect-priority: "-999"
          connection.timestamp: "1786092581"
          ethernet._: ""
          ipv6.addr-gen-mode: "default"
          ipv6.ip6-privacy: "-1"
          proxy._: ""

Example /etc/netplan/90-NM-11418e32-9af0-11f1-969d-00155d9773ef.yaml:

network:
  version: 2
  ethernets:
    NM-11418e32-9af0-11f1-969d-00155d9773ef:
      renderer: NetworkManager
      match:
        name: "enp2s0"
      addresses:
      - "192.168.0.200/24"
      dhcp6: true
      wakeonlan: true
      networkmanager:
        uuid: "11418e32-9af0-11f1-969d-00155d9773ef"
        name: "Wired connection 1"
        passthrough:
          connection.autoconnect-priority: "-999"
          connection.timestamp: "1786092581"
          ethernet._: ""
          ipv6.addr-gen-mode: "default"
          ipv6.ip6-privacy: "-1"
          proxy._: ""

You can also change the IP address 192.168.0.200 to another one, for example, 192.168.0.150.

If you need to add a user, run the following commands:

useradd -s /bin/bash -G sudo user_name
passwd user_name

The home directory is not created here (there is no -m flag) because /home is a separate partition that is mounted only on the running system. Create the directory after the first boot.

Exit the chroot via exit and unmount everything:

sudo umount /tmp/slotA/run /tmp/slotA/dev /tmp/slotA/sys /tmp/slotA/proc
sudo umount /tmp/slotA

If you are doing the initial setup on a live device, clone slot A to slot B. e2image works on the block devices themselves, and both slots must be unmounted, so boot from a live or rescue system if slot A is the rootfs you are currently running from:

sudo e2image -ra -p /dev/sda2 /dev/sda3

The clone also copies the filesystem UUID, so assign a new one to slot B to keep both slots distinguishable:

sudo tune2fs -U random /dev/sda3

Also remember that you can create .img files for the rootfs and the bootloader. After that you can write the images to your live device via dd:

sudo dd if=disk.img of=/dev/sdaN bs=4M status=progress conv=fsync

One-shot boot for testing

# Show all EFI entries and find the number of "AB GRUB"
sudo efibootmgr
# Put your number instead of 0000
sudo efibootmgr -n 0000

After the first boot

If you added a new user, create the home directory on the mounted /home partition:

sudo mkhomedir_helper user_name

It creates /home/user_name.

Create update

If you did the initial setup on a live device, clone the rootfs into an .img file first, because that clone is what future updates are built from. Otherwise you already have the .img file.

The slot must not be mounted while it is copied, otherwise the image catches the filesystem mid-write and comes out inconsistent. Boot from a live or rescue system, the same as for the slot cloning in Set up the OS, and copy the partition:

sudo dd if=/dev/sda2 of=disk.img bs=4M status=progress conv=fsync

e2image does the same job faster, because it copies only the blocks that are in use and leaves the rest of the file sparse:

sudo e2image -ra -p /dev/sda2 disk.img

Chroot into your image. After that you can add, remove or modify files.

Exit the chroot and unmount it.

mkdir update1
mv disk.img update1/disk.img
touch update1/manifest.raucm

Put the following content into update1/manifest.raucm:

[update]
compatible=rauc-demo-x86
version=demo.update1
build=11.08.2026;12:16

[bundle]
format=verity

[image.rootfs]
filename=disk.img

You can write any data in the version and build fields. More information about the data available in the manifest is here.

Also copy demo.cert.pem and demo.key.pem to your work folder (not the update folder), and build the bundle:

rauc --cert demo.cert.pem --key demo.key.pem bundle update1/ update1-11.08.2026-1.raucb

After that you will get the update file update1-11.08.2026-1.raucb.

Install update

RAUC can install an update not only from a local file: a URL works in place of a file path as well.

Via rauc

Just execute:

sudo rauc install path/to/generated_file.raucb

After installing an update, you need to reboot the device.

Via D-Bus

More information about using the D-Bus API is here.

sudo busctl call de.pengutronix.rauc / de.pengutronix.rauc.Installer InstallBundle sa{sv} "path/to/generated_file.raucb" 0

After installing an update, you need to reboot the device.

Linux Cockpit with the A/B Update page

Install Cockpit

Cockpit is set up in two places, and it is worth keeping them apart. The package and the A/B Update page belong in the slot image, so that every update carries them. The certificate is made on the running device instead, because its private key must not end up in the image or in every update bundle built from it.

Install the package in the chroot of slot A, entered the same way as in Set up the OS:

apt install cockpit

Cockpit is socket-activated and listens on port 9090. Enable the socket so that it starts on the next boot:

systemctl enable cockpit.socket

--now is left out because nothing can be started inside a chroot. On a running device the equivalent command is sudo systemctl enable --now cockpit.socket.

Once the device has booted, the web console is available at https://your-device-ip:9090. It uses a self-signed certificate, so the browser shows a warning on the first visit.

To get rid of the warning, add your own certificate. Cockpit uses the alphabetically last .cert file in /etc/cockpit/ws-certs.d, so the 10- prefix wins over the generated 0-self-signed.cert.

Execute the following commands on the running device, once per device - the /etc/cockpit/ws-certs.d directory and the cockpit-ws group come with the package. Do not do this in the chroot; otherwise the private key ends up in the slot image and in every update bundle built from it. Note that /etc/cockpit is part of the rootfs slot, so an update replaces the certificate along with the rest of the slot: either repeat these commands after an update, or keep the pair on the /home partition and copy it into place at boot.

sudo openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout /etc/cockpit/ws-certs.d/10-device.key \
  -out    /etc/cockpit/ws-certs.d/10-device.cert \
  -days 3650 \
  -subj "/CN=your.domain" \
  -addext "subjectAltName=DNS:your.domain,IP:192.168.0.200"
sudo chown root:cockpit-ws /etc/cockpit/ws-certs.d/10-device.key
sudo chmod 640 /etc/cockpit/ws-certs.d/10-device.key
sudo chmod 644 /etc/cockpit/ws-certs.d/10-device.cert
sudo systemctl restart cockpit

Instead of your.domain and 192.168.0.200 you need to put your own domain and IP address. The IP address is what matters if you open the web console by address rather than by name. Note that macOS and iOS refuse TLS server certificates issued on or after 1 September 2020 with a validity period longer than 398 days. Apple exempts certificates that come from a manually added root, which is what a self-signed certificate placed in the trust store amounts to, so -days 3650 normally works; lower it to -days 397 if you do run into the error.

After that, copy /etc/cockpit/ws-certs.d/10-device.cert to the machine you open the web console from, and add it to the trust store of that machine. The 10-device.key file is the private key of the certificate; it must never leave the device.

Install the A/B Update page for Cockpit

The page consists of static files only, so it belongs in the slot image next to the package. Copy the cockpit/abupdate folder to /usr/share/cockpit/abupdate:

sudo cp -r cockpit/abupdate/. /usr/share/cockpit/abupdate

This repository is not reachable from inside the chroot, so run the command from outside it and prefix the destination with the mount point of the slot, for example /tmp/slotA/usr/share/cockpit/abupdate.

The page appears in the Cockpit menu only if /usr/bin/rauc exists, so make sure rauc is installed in the same slot. Otherwise the menu item is silently hidden.

You can also modify the page settings in /usr/share/cockpit/abupdate/abupdate.config. Reload the page to apply the changes.

Table 2 - A/B Update page settings.

Variable Default Description
staging_directory /var/tmp/abupdate Directory on this system where uploaded bundles are stored. It is created if it does not exist.
bundle_extensions .raucb File name extensions the file chooser offers and the page accepts, separated by commas. Matching ignores case. Leave the value empty to accept any file.
free_space_margin 64M How much room to leave free on the target filesystem. An upload is refused when the bundle would not fit within this reserve. Plain bytes or a K, M or G suffix.
chunk_bytes 8M Bytes per chunk. This is also the upper bound of data in flight, so it trades memory against per-chunk round-trip overhead. Plain bytes or a K, M or G suffix.
superuser try How the upload helpers run. try uses administrative access when the session has it and stays unprivileged otherwise; require refuses to upload without it.

The superuser setting covers the upload only. Installing a bundle, switching the primary slot and rebooting always need administrative access, whatever it is set to. When the session does not have it, the page says so as soon as a file is picked, so a large bundle is not transferred just to be refused at the install step.

It is recommended to change staging_directory if your update bundles are large. The default directory is on the rootfs slot, which is only a few gigabytes in this guide, so the /home partition is a better place, for example /home/abupdate.

How to use Linux Cockpit A/B Update

Upload update bundle

Here is the OS update menu. Drag an update bundle onto the drop zone, or click Choose file and pick the file.

abupdate_update_1

The menu shows the following information:

  • File - the file name.
  • Size - the file size.
  • Target path - where the file is stored.
  • Free space - the free space on the filesystem that holds the target directory.
  • Writing as - the user the file is written as.

If a file with the same name already exists in the target directory, the menu warns that it will be replaced once the upload completes.

The Verify with sha256sum after writing checkbox is selected by default. It reads the bundle once more on the device and compares the checksum. You can clear it to save time on large bundles.

abupdate_update_2

Click Upload and install. The file is uploaded to the device in chunks, so the menu shows the progress, the transfer rate and the remaining time. You can suspend the transfer with Pause and stop it with Cancel.

abupdate_update_3

After a successful upload and check, the menu shows the SHA-256 checksum and starts installing the update.

abupdate_update_4

After a successful installation, the menu shows the result: where the bundle is stored, how much data was transferred and how long it took. Upload another file clears the form for the next bundle.

abupdate_update_5

The installed system starts only after a reboot. You can reboot the device with Reboot device in the System status section. It asks for confirmation first.

abupdate_update_6

System status menu

Here you can get the following information:

  • Booted slot - the slot the device is running from right now.
  • Primary slot - the slot the device boots next.
  • Compatible - the device code name. It must match the compatible field of the bundle manifest.
  • Variant - the device variant.
  • Slot information. The slots are grouped by boot name, and each of them shows:
    • Class - the slot class from system.conf, for example rootfs. Slots of the same class replace each other during an update.
    • Device - the block device of the slot, for example /dev/sda2.
    • Type - the filesystem type, for example ext4.
    • Boot name - the name the bootloader uses for the slot, A or B.
    • State - whether the slot is booted, active or inactive.
    • Boot status - good or bad. GRUB does not boot a bad slot.
    • Mounted at - where the slot is mounted, if it is mounted at all.
    • Parent - the parent slot, for slots that belong to another slot.
    • Bundle version - the version field of the bundle installed into the slot.
    • Installed - when that bundle was installed.

Slots without a boot name are listed separately.

abupdate_status_1

You can switch the primary slot. The running system is left alone; the new slot starts at the next reboot.

abupdate_status_2

You can reboot the device. Both actions ask for confirmation first.

abupdate_status_3


Table of contents