Ubuntu The Hard Way
Updated On: 2026-04-27
Someone once asked how I setup Ubuntu the hard way with LUKS encrypted BTRFS subvolumes and TPM2 support.
Disclaimer: The Ubuntu installer is my recommended way of setting up Ubuntu until you want to dive into how it all works. If you're new to Linux, I recommend following along in a VM, or other low-risk environment.
I'm trying to showcase the commands and general approach here. Your milage may very.
Why Ubuntu without the installer:
- More control over partitions
- More control over the boot process from the start
- Ability to make semi-standard or non-standard choices like systemd-boot and dracut.
- For all the friends we meet along the way.
Intro And Setup
To get started, we'll setup our partition table. I use parted for this. I am going to use btrfs for the main OS, and fat32 for the efi+esp partition. Fat32 might be old, but it's required for UEFI boots. I consider my data more important than the ESP to boot into the system, so the plan is to create our esp partition at the end of the drive so that it can be resized or relocated later if needed.
Partitioning
Using Parted
I consider user data to be more important than boot-concerns, so if I choose to add other drives, I might choose to boot from there. As such, I place the ESP at the end of the device so that resizing data drives later overtop of it, or shrinking them to grow the space, is trivial. It will always be easier to recreate the ESP than a user's data.
parted -a optimal /dev/vda
mktable gpt
mkpart prevoe-esp -2048M 100%
set 1 esp on
mkpart prevoe-luks-0 0% -2048M
q
Encryption Setup
LUKS Formatting
Next we setup the data partition to use LUKS2. Boring, Simple, Predictable.
cryptsetup luksFormat --type=luks2 /dev/disk/by-partlabel/prevoe-luks-0
Decrypt the Disk
Decrypt the partition so that we can install into it. I choose to put btrfs into the decrypted partitions label so that I know at a glance what's what when recovering.
cryptsetup open /dev/disk/by-partlabel/prevoe-luks-0 prevoe-btrfs-0
Drive Formatting
Formatting the disk with btrfs is easy enough. Notably I'm setting the label here which matches the partition name. This means /dev/mapper/prevoe-btrfs-0 will now be the same as /dev/disk/by-label/prevoe-btrfs-0 after this step.
Format Devices
mkfs.btrfs --label prevoe-btrfs-0 /dev/mapper/prevoe-btrfs-0
mkfs.fat -F32 /dev/disk/by-partlabel/prevoe-esp
Mounts And Subvolumes
BTRFS Layout Setup
BTRFS has subvolumes, and we want to use them for the root of our OS, so we mount the root of the BTRFS partition and create the subvolumes of interest.
mkdir /mnt/btrfs-full
mount /dev/disk/by-label/prevoe-btrfs-0 /mnt/btrfs-full
cd /mnt/btrfs-full
# Now create each directory, and the neach volume
mkdir -p os/root/ubuntu-24.04
btrfs subvol create os/root/ubuntu-24.04/@live
mkdir -p home/catchall
btrfs subvol create home/catchall/@live
mkdir -p home/cprevoe
btrfs subvol create home/cprevoe/@live
OS Layout Setup Leveraging Subvolumes
We mount the subvolumes we've created and the esp so that we have the partition layout of our new OS available.
mkdir -p /mnt/realroot/
mount /dev/disk/by-label/prevoe-btrfs-0 -o subvol=/os/root/ubuntu-24.04/@live /mnt/realroot
mkdir -p /mnt/realroot/boot/efi /mnt/realroot/home
mount /dev/disk/by-label/prevoe-btrfs-0 -o subvol=/home/catchall/@live /mnt/realroot/home
mkdir -p /mnt/realroot/home/cprevoe
mount /dev/disk/by-label/prevoe-btrfs-0 -o subvol=/home/cprevoe/@live /mnt/realroot/home/cprevoe
mount /dev/disk/by-partlabel/prevoe-esp /mnt/realroot/boot/efi
OS Foundation Bootstrap
This is the heaviest step where we actually install everything which needs to be installed for a minimum system. For speed and network reasons, I'm using a local passthrough mirror. You can replace it with your ubuntu mirror of choice.
Install OS Base with Debootstrap
apt install debootstrap
debootstrap --arch=amd64 --variant=minbase noble /mnt/realroot \
http://apt-cache-ng.k8s.andro.zdani.prevoe.com/archive.ubuntu.com/ubuntu/
OS Manual Configuration
The arch-install-scripts gives us access to genfstab and arch-chroot which are super useful.
Install arch-install-scripts
apt install arch-install-scripts
Create The fstab And cryptab
Next we generate our fstab, and then we generate our crypttab
mkdir -p /mnt/realroot/etc
genfstab -U /mnt/realroot > /mnt/realroot/etc/fstab
# We remove subvol references so we can swap out the subvolumes as desired
sed -i 's/subvolid=[0-9]*,//g' /mnt/realroot/etc/fstab
ROOT_UUID="$(blkid -s UUID -o value /dev/disk/by-partlabel/prevoe-luks-0)"
echo "prevoe-btrfs-0 UUID=${ROOT_UUID} none luks,tpm2-device=auto" > /mnt/realroot/etc/crypttab
Setup Basic Networking
Also to avoid errors, we set the hostname, and setup the expected hosts file.
echo prevoe-ubuntu > /mnt/realroot/etc/hostname
cat > /mnt/realroot/etc/hosts << EOF
127.0.0.1 localhost
127.0.1.1 prevoe-ubuntu
EOF
Kernel CMDLINE Generation Hook
Instead of grub, we're going to use systemd-boot, and dracut. With a bit of tinkering, one can setup the tpm to provide luks the key to automatically decrypt the drive.
Ubuntu seems to be heading in the direction of dracut producing the initrd, and systemd-ukify creating the universal kernal image (UKI). We can hook into this cleaner and in a more automated way than if we did it all in dracut, so here we go.
mkdir -p /mnt/realroot/etc/kernel/preinst.d
FILE=/mnt/realroot/etc/kernel/preinst.d/99-generate-kernel-cmdline
cat > ${FILE} << ENDOFSCRIPT
#!/bin/bash
# We will define our own cmdline so crypttab is used during boot
CMDLINE_FILE=/etc/kernel/cmdline
root_device=\$(awk '\$2 == "/" { print \$1 }' /etc/fstab)
root_device_options=\$(awk '\$2 == "/" { print \$4 }' /etc/fstab)
# If root device not in fstab, fallback to findmnt
if [ -z "\${root_device}" ]; then
# Square brackets are possible in BTRFS
root_device=\$(findmnt -no SOURCE / | cut -f1 -d'[')
root_device_options=\$(findmnt -no OPTIONS /)
fi
[ -z "\${root_device}" -o -z "\${root_device_options}" ] && exit 0
cat > "\${CMDLINE_FILE}" << EOF
quiet splash
root=\${root_device}
rootflags=\${root_device_options}
rd.luks.crypttab=1
EOF
ENDOFSCRIPT
chmod +x ${FILE}
Kernel UKI Configuration
cat > /mnt/realroot/etc/kernel/install.conf << EOF
layout=uki
initrd_generator=dracut
uki_generator=ukify
EOF
Tell Dracut to Include crypttab
By default, the kernel now assumes that you either use gpt-auto-root or tell it where to find root
with kernel command line arguments. This is great except our root is behind luks. Rather than tell
the kernel via cmdline how to access it, I much prefer to embed that configuration instead.
So, we tell dracut to include the crypttab here. We also include tpm2-tss so it can decrypt using the TPM.
mkdir -p /mnt/realroot/etc/dracut.conf.d
cat > /mnt/realroot/etc/dracut.conf.d/99-include-crypttab.conf << EOF
# Required for automatic unlocking with tpm2
add_dracutmodules+=" tpm2-tss "
# Required for decrypting with rd.luks.crypttab=1
install_items+=" /etc/crypttab "
EOF
Configure Apt Sourcs
An old-fashioned sources.list was created for us in the old format by debootstrap, so we want to get rid of that as well and modernize.
echo "" > /mnt/realroot/etc/apt/sources.list
cat > /mnt/realroot/etc/apt/sources.list.d/ubuntu.sources << EOF
Types: deb
URIs: http://apt-cache-ng.k8s.andro.zdani.prevoe.com/archive.ubuntu.com/ubuntu/
Suites: noble noble-updates noble-backports
Components: main universe restricted multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
## Ubuntu security updates. Aside from URIs and Suites,
## this should mirror your choices in the previous section.
Types: deb
URIs: http://apt-cache-ng.k8s.andro.zdani.prevoe.com/security.ubuntu.com/ubuntu/
Suites: noble-security
Components: main universe restricted multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
OS Installation
We now have a basic skeleton of the OS, but it will not boot nor do anything useful yet. We chroot into this directory which allows us to interact with it as though it were the booted OS. We use arch-chroot from arch-install-scripts because it mounts all of the extras like /dev /proc and others for us.
Pivot Into the new OS
arch-chroot /mnt/realroot
Update and Install Non-Kernel Packages
And finally we have the installation. This is the heaviest part involving downloading a gig or two of packages and installing them all. The DEBIAN_FRONTEND prefix tells it to not pause to ask us for our opinions.
export DEBIAN_FRONTEND=noninteractive
apt update \
&& apt upgrade -yq \
&& apt install -yq \
ubuntu-desktop-minimal \
ubuntu-desktop \
sudo \
dracut \
systemd-boot \
systemd-ukify \
btrfs-progs \
tpm2-tools \
gawk
Install Kernel Packages
Unfortunately the ubuntu linux images in 24.04 have grub as a "recommends" which causes it to be installed. To avoid this, we separate our desktop install from our kernel install and install the latter with no-recommends.
# Leaving this in for people who pause
export DEBIAN_FRONTEND=noninteractive
# We have to use no-install-recommends to avoid grub
apt install --no-install-recommends -yq \
linux-generic-hwe-24.04
Install systemd-boot into ESP
Next we install the bootloader with bootctl. This allows our "Linux System Loader" to appear in our UEFI menu.
bootctl install && bootctl list
Add Users
Now if you know your users, it's time to set them up.
Setup Users
chown -R 2202:2202 /home/cprevoe
addgroup --gid 2202 cprevoe
adduser --uid 2202 --gid 2202 --gecos "Christopher Prevoe,,,,prevoe.com" cprevoe
adduser cprevoe sudo
Reboot And Test
And now we finish it off with a reboot. Exit out of the chroot, then restart the machine. Note, if you're following along using virsh, you might need to execute a virsh start directly.
Reboot!
reboot
Final Thoughts
And there we have it. Ubuntu 24.04 installed with BTRFS subvolumes used for critical components. You can now instantly snapshot the entire OS and even swap out the OS for other versions by mounting the btrfs-root and playing around in there.
And all this, just because someone once asked how I setup Ubuntu leveraging BTRFS subvolumes. And there we have it.
Hope this helps