Replace GRUB2 with systemd-boot on Ubuntu 18.04

UPDATE 2022-07-17: This tutorial is a few years old now, but continues to work A-OK up through Ubuntu 22.04 (Jammy Jellyfish). :) Special thanks to readers @Scott, @John, and @Niels for providing improvements and notes to the original scripts, some of which I've added below.

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.

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 — make sure you know how to get there, haha — 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, and 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.

But wait, another issue: systemd-boot does not automatically regenerate entry configuration files; as kernels are added or removed, we'll need to add or remove entries for the different versions accordingly.

For this tutorial, we're going to keep things simple, and link a single BASH script to the kernel's postinst and postrm hooks and initramfs' post-update hook.

Note: We can get away with one script in three places because we don't have anything special going on during the build, but if you have initramfs PREREQs, special firmware/dkms build tasks, are stuck running a version of run-parts that demands /bin/sh compatibility, or want to do other fun and wonderful things like mirror the entire EFI partition to another location for safety, you'll need to deviate from this tutorial a little bit.

Let's start by creating a file like the following (be sure to update CHANGEME values, most of which can be stolen from your old GRUB config) at /etc/kernel/postinst.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.
# Note: if using LVM, this should be the LVM partition.
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. For example, mine are currently
# "subvol=@ quiet splash intel_pstate=enable".
ROOTFLAGS="CHANGEME"

# Our kernels.
KERNELS=()
FIND="find /boot -maxdepth 1 -name 'vmlinuz-*' -type f -not -name '*.dpkg-tmp' -print0 | sort -Vrz"
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!
echo -e "\e[2m---\e[0m"
exit 0

Note: if you're using LVM — I'm not, but it is the Ubuntu default — you may need to change the "options cryptdevice…" lines above to to read like this instead:

options cryptdevice=UUID=${UUID}:crypt-root root=/dev/mapper/${VOLUME}
ro ${ROOTFLAGS}

Before going further, it is good to double-check the ownership and permissions are correct:

# Set the right owner.
chown root: /etc/kernel/postinst.d/zz-update-systemd-boot
# Make it executable.
chmod 0755 /etc/kernel/postinst.d/zz-update-systemd-boot

How are you feeling? We're almost done! The other hooks need to do exactly the same thing, so we can get away with symlinking them to the postinst.d version:

# One for the kernel's postrm:
cd /etc/kernel/postrm.d/ && ln -s ../postinst.d/zz-update-systemd-boot zz-update-systemd-boot

# Note: Ubuntu does not usually create the necessary hook folder
# for initramfs, so the next line will take care of that if
# needed. (And yes, it *is* supposed to be "initramfs" and not
# "initramfs-tools"!)
[ -d "/etc/initramfs/post-update.d" ] || mkdir -p /etc/initramfs/post-update.d

# And now we can add the symlink:
cd /etc/initramfs/post-update.d/ && ln -s ../../kernel/postinst.d/zz-update-systemd-boot zz-update-systemd-boot

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. (Well, read the next few lines so you know what's coming, then reboot. Haha.)

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.

Josh Stoik
2 June 2018
Previous On Content-Security-Policy Headers
Next PHP 7.2 on Debian Stretch