Replace GRUB2 with systemd-boot on Ubuntu 18.04

GRUB2 has always rubbed me the wrong way. It works — most of the time — but its complexity and structural distance from GRUB Legacy make debugging and customization nearly impossible. And maybe it’s just me, but I like to keep my /boot partitions small. As kernel sizes climb ever upward, removing a bloated dependency like GRUB2 becomes more and more attractive.

Enter systemd-boot (f. gummiboot). Put simply, it boots. No pomp and circumstance, just good old fashioned config-file simplicity.

While GRUB2 is still the default bootloader for new Ubuntu 18.04 (Bionic Beaver) installations, it comes with everything you need to run systemd-boot instead. The migration process is very easy, but finding information about how to migrate is not. Uncle Google pointed me toward a few tutorials — mostly for Arch Linux — but had almost nothing covering Debian-based systems, and literally nothing hitting all the necessary points for my damn setup.

The Ingredients

Not that my setup is super weird; it’s just a normal amount of weird, I think.

  • The laptop boots using the UEFI.
  • Secure Boot is enabled.
  • XanMod kernels are used instead of stock.
  • There is only one disk, an SSD, with three partitions (but no LVM). There’s an ext2 boot partition (/boot), a vfat EFI partition (/boot/efi), and a LUKS-encrypted root partition (/dev/mapper/sda3_crypt), beneath which lies a BTRFS partition with @ (/) and @home (/home) subvolumes.

That isn’t how Ubuntu does things by default, but none of it’s crazy, right?

Set Up systemd-boot

Unlike a lot of software, bootloaders can generally exist independently of one another. To be safe, you should leave your working GRUB2 around until you are confident you have systemd-boot set up correctly. If you screw something up, don’t sweat it! Just re-reboot, open your BIOS’ boot menu, and point it to GRUB2.

With that in mind, let’s begin!

For Ubuntu environments, there should be two boot-like partitions. One of them is the proper boot, usually mounted to /boot, and the other is a special EFI partition, usually mounted to /boot/efi. As systemd-boot is exclusively designed for the UEFI, most of this tutorial will focus on the latter partition.

# Everything in this tutorial should be done as root:
sudo -i

# Now hop on into the EFI partition root.
cd /boot/efi

# Configuration files will go here:
mkdir -p loader/entries

# And kernels will go here:
mkdir ubuntu

There are two types of configuration files: a loader.conf, which controls general systemd-boot behaviors, and any number of entries, which contain specific launch parameters like kernel and root flags.

Let’s start with the main loader configuration. Using the editor of your choice, place something like the following into /boot/efi/loader/loader.conf:

default ubuntu
timeout 1
editor 0

Easy, right? We have a default entry item — which we’ll make shortly — and a short timeout to allow for human intervention as needed.

The entry configurations are easy too, but there’s a hitch: Debian-based systems will not directly install package files to any sort of FAT partition, but EFI won’t boot from anything other than a FAT partition. Luckily, the solution is simple: copy your kernels from /boot to /boot/efi/ubuntu.

There’s another hitch: systemd-boot does not go out of its way to generate entry configuration files for you. As you add or remove kernels, you’ll need to add or remove entries.

To kill two birds with one stone, you can set a kernel postinstall/postrm hook like this. Since I am using XanMod kernels, I adapted it as follows. Either way, you’ll want to save the scripts to both /etc/kernel/postinstall.d/zz-update-systemd-boot and /etc/kernel/postrm.d/zz-update-systemd-boot:

#!/bin/bash
#
# This is a simple kernel hook to populate the systemd-boot entries
# whenever kernels are added or removed.
#



# The UUID of your disk.
UUID="CHANGEME"

# The LUKS volume slug you want to use, which will result in the
# partition being mounted to /dev/mapper/CHANGEME.
VOLUME="CHANGEME"

# Any rootflags you wish to set.
ROOTFLAGS="CHANGEME"



# Our kernels.
KERNELS=()
FIND="find /boot -maxdepth 1 -name 'vmlinuz-*' -type f -print0 | sort -rz"
while IFS= read -r -u3 -d $'\0' LINE; do
	KERNEL=$(basename "${LINE}")
	KERNELS+=("${KERNEL:8}")
done 3< <(eval "${FIND}")

# There has to be at least one kernel.
if [ ${#KERNELS[@]} -lt 1 ]; then
	echo -e "\e[2msystemd-boot\e[0m \e[1;31mNo kernels found.\e[0m"
	exit 1
fi



# Perform a nuclear clean to ensure everything is always in perfect
# sync.
rm /boot/efi/loader/entries/*.conf
rm -rf /boot/efi/ubuntu
mkdir /boot/efi/ubuntu



# Copy the latest kernel files to a consistent place so we can keep
# using the same loader configuration.
LATEST="${KERNELS[@]:0:1}"
echo -e "\e[2msystemd-boot\e[0m \e[1;32m${LATEST}\e[0m"
for FILE in config initrd.img System.map vmlinuz; do
    cp "/boot/${FILE}-${LATEST}" "/boot/efi/ubuntu/${FILE}"
    cat << EOF > /boot/efi/loader/entries/ubuntu.conf
title   Ubuntu GNOME
linux   /ubuntu/vmlinuz
initrd  /ubuntu/initrd.img
options cryptdevice=UUID=${UUID}:${VOLUME} root=/dev/mapper/${VOLUME} ro rootflags=${ROOTFLAGS}
EOF
done



# Copy any legacy kernels over too, but maintain their version-based
# names to avoid collisions.
if [ ${#KERNELS[@]} -gt 1 ]; then
	LEGACY=("${KERNELS[@]:1}")
	for VERSION in "${LEGACY[@]}"; do
	    echo -e "\e[2msystemd-boot\e[0m \e[1;32m${VERSION}\e[0m"
	    for FILE in config initrd.img System.map vmlinuz; do
	        cp "/boot/${FILE}-${VERSION}" "/boot/efi/ubuntu/${FILE}-${VERSION}"
	        cat << EOF > /boot/efi/loader/entries/ubuntu-${VERSION}.conf
title   Ubuntu GNOME ${VERSION}
linux   /ubuntu/vmlinuz-${VERSION}
initrd  /ubuntu/initrd.img-${VERSION}
options cryptdevice=UUID=${UUID}:${VOLUME} root=/dev/mapper/${VOLUME} ro rootflags=${ROOTFLAGS}
EOF
	    done
	done
fi



# Success!
exit 0

These hooks should be owned by root and executable. If needed, run:

# Set the right owner.
chown root: /etc/kernel/postinstall.d/zz-update-systemd-boot
chown root: /etc/kernel/postrm.d/zz-update-systemd-boot

# Set the right permissions.
chmod 0755 /etc/kernel/postinstall.d/zz-update-systemd-boot
chmod 0755 /etc/kernel/postrm.d/zz-update-systemd-boot

Also, be sure to update the CHANGEME variables at the top to match your system. You can steal these values from your GRUB2 configuration as needed. For reference, I use VOLUME="sda3_crypt" and ROOTFLAGS="subvol=@ quiet splash". (My @home subvolume is handled in /etc/fstab.)

Once your hooks are customized and saved, reinstall one of the kernel images already on your system. This will let you know whether the hooks work, and assuming they do, copy your kernels to the EFI partition and generate the appropriate systemd-boot entries.

Actually Install systemd-boot

For most people, installation consists of a single command:

# Again, this should go to the EFI partition:
bootctl install --path=/boot/efi

To verify the bootloaders installed on the system — and their order — run:

efibootmgr

The BootOrder line shows you a comma-separated list of entry IDs in order of priority, after which you’ll see a list of IDs mapped to human-friendly names. If everything worked correctly, the first ID in BootOrder should correspond to Linux Boot Manager. If it doesn’t, you’ll probably need to set the boot order from BIOS instead.

Secure Boot

Secure Boot, as always, requires a few extra hoops. If you don’t or can’t run Secure Boot, you can skip this section and laugh at the rest of us. Otherwise, you’ll need to make another bootloader entry, a PreLoader to handle image verification and signing. To get started, download the following two files* to /boot/efi/EFI/systemd:

Once the files are in place, run the following commands:

# Rename your true bootloader. PreLoader expects a
# file called "loader.efi".
mv /boot/efi/EFI/systemd/systemd-bootx64.efi /boot/efi/EFI/systemd/loader.efi

# Add a bootloader entry for PreLoader. Change X and Y below to
# correspond to your /boot/efi device. For example, if that
# device is /dev/sda1, change "X" to "a" and "Y" to "1".
efibootmgr --disk /dev/sdX --part Y --create --label "PreLoader" --loader /EFI/systemd/PreLoader.efi

# Once again, you can check the bootloader order by running:
efibootmgr

If all went well, you should now see the PreLoader entry set to boot first. If not, you’ll have to change the order manually in BIOS.

Reboot!

You should now have a working systemd-boot configuration, but to be sure, you’ll need to reboot. So do that now.

For systems with Secure Boot enabled, you should be greeted with a verification error message and a HashTool wizard. Select “Enroll Hash” from the menu and navigate to loader.efi, enroll its hash, then repeat the process for your actual kernel, e.g. vmlinuz-ABC123 (which will be up and over a few directories from where you found loader.efi).

Once both image hashes are enrolled, re-reboot, and everything should work A-OK.

The verification error workaround is thankfully only necessary the first time you boot from a given image. (The process for new kernels is identical, except you don’t have to re-enroll loader.efi.)

If you run into problems, use your native BIOS boot selector to choose GRUB2 so you can get back in and fix whatever unforgivable mistake prevented systemd-boot from working.

Clean Up

Once systemd-boot is working, you can safely remove GRUB2.

# Purge the packages.
apt-get purge grub*

# Purge any obsolete dependencies.
apt-get autoremove --purge

Purging the packages may or may not purge all the crap GRUB dumped into your /boot and /boot/efi partitions. To free up space, you should manually stroll through those directories and delete any GRUB leftovers.

Done!

That’s it! The few seconds spent booting your system should now be a little brighter.