This guide will walk you through the process of installing a minimal version of Ubuntu 24.04 (Noble Numbat) on various types of systems that support UEFI (including creating AMIs for EC2 instances) or Raspberry Pis.
- Required packages
- Preparing the volume
- Partitioning the volume
- Formatting and mounting
- Architecture and repository URL
- Bootstrapping the system
- Minimal OS packages (TODO)
- Operating system installation
- Kernel and helper packages
- Cleaning up
Before we begin, it's important to make sure you have the necessary packages installed. This guide requires the use of:
parted
to partition volumesdedebootstrap
to bootstrap the operationg systemzerofree
to optionally clean up volumes after installation.
You can install these packages by running the following command:
apt-get update && apt-get install --yes parted debootstrap zerofree
Regardless of what kind of system you're trying to install, you will need an
empty volume to install Ubuntu. You will need to set the root volume's device
as the BASE_DEV
environment variable.
If targeting a normal device you can simply export the BASE_DEV
environment
variable directly:
# Simply export the root device upon which we want to install the OS
export BASE_DEV=/dev/sdb
If you want to create an image to be later written to an SD card or USB stick, (think Raspberry Pi) you need to first create the image file:
# Create a simple image file, 4Gb will suffice for this install
dd if="/dev/zero" of="/raspberry-os.img" bs=4M count=1024 conv=fsync status=progress
Then you can use the loopback interface to use the image as a normal disk:
# Find our first available "loopback" device and use it as our BASE_DEV
BASE_DEV="$(losetup -f)"
# Associate our image file with the loopback device
losetup -P "${BASE_DEV}" "/raspberry-os.img"
From the EC2 console, create a new volume (4GiB should suffice) and attach it to a running EC2 instance where we can run the installation process.
Your mileage might vary, but simply export the device name (in most cases
/dev/nvme1n1
) as the BASE_DEV
environment variable.
# Simply export the root device upon which we want to install the OS
export BASE_DEV=/dev/nvme1n1
We will create two partitions on the disk: a small (256 MiB) FAT32 partition
for boot
and the rest of the space as our EXT4 root
volume.
You can use parted
to simply create a basic partition layout.
# Create the GPT partition table
parted -s "${BASE_DEV}" mklabel gpt
# Create our two BOOT and ROOT partitions
parted -s "${BASE_DEV}" mkpart UEFI 1MiB 256MiB
parted -s "${BASE_DEV}" mkpart ROOT 256MiB 100%
# Setup flags on our UEFI partition
parted -s "${BASE_DEV}" set 1 boot on
parted -s "${BASE_DEV}" set 1 esp on
# Create the MSDOS partition table
parted -s "${BASE_DEV}" mklabel msdos
# Create our two BOOT and ROOT partitions
parted -s "${BASE_DEV}" mkpart primary fat32 1MiB 256MiB
parted -s "${BASE_DEV}" mkpart primary ext4 256MiB 100%
# Set the boot partition as bootable
parted -s "${BASE_DEV}" set 1 boot on
Before moving on to the next step, it's important to give the system a moment to populate the device tree. This may take a second or two.
Once the kernel has finished re-populating the device tree, we can proceed to identify the UUIDs and devices of our partitions. To do this, we will use the following commands:
# Find the UUID and device of the boot partition
export BOOT_UUID="$(partx -go UUID -n 1 "${BASE_DEV}")"
export BOOT_DEV="$(realpath /dev/disk/by-partuuid/"${BOOT_UUID}")"
# Find the UUID and device of the root partition
export ROOT_UUID="$(partx -go UUID -n 2 "${BASE_DEV}")"
export ROOT_DEV="$(realpath /dev/disk/by-partuuid/"${ROOT_UUID}")"
With our partition UUIDs and devices identified, we can now format and mount our file systems.
We'll need to format the boot
partition as FAT32
and the root
partition
as EXT4
. To do so, use the following command:
mkfs.fat -n "UBUNTU_BOOT" -F 32 "${BOOT_DEV}"
mkfs.ext4 -F "${ROOT_DEV}"
For UEFI systems we'll mount the root partition under /mnt
and the boot
partition will be mounted under /mnt/boot/efi
:
mount "${ROOT_DEV}" /mnt
mkdir -p /mnt/boot/efi
mount "${BOOT_DEV}" /mnt/boot/efi
On the other hand, for the Raspberry Pi, we'll mount similarly the root
partition under /mnt
and, but the mountpoint of our boot partition is
/mnt/boot/firmware
:
mount "${ROOT_DEV}" /mnt
mkdir -p /mnt/boot/firmware
mount "${BOOT_DEV}" /mnt/boot/firmware
TODO: we need to set up our APT repo to download the packages
curl -L -o '/mnt/minimal-os.deb' \ 'https://github.com/usrz/minimal-ubuntu/releases/download/v2.0.0/minimal-os_2.0.0_all.deb' curl -L -o '/mnt/minimal-ec2-os.deb' \ 'https://github.com/usrz/minimal-ubuntu/releases/download/v2.0.0/minimal-ec2-os_2.0.0_all.deb' curl -L -o '/mnt/minimal-rpi-os.deb' \ 'https://github.com/usrz/minimal-ubuntu/releases/download/v2.0.0/minimal-rpi-os_2.0.0_arm64.deb'
To bootstrap the system, you will need to find the correct URL of the Ubuntu repository to fetch packages from, and the architecture of the system you are going to install.
We export this into the REPO_URL
and TARGET_ARCH
environment variables.
Remember, Raspberry Pis, AWS Graviton instances, M1/M2 Macs are ARM64!
Normally you will want to use a local mirror of the Ubuntu repositories. In
my case (Germany), I'll use the de
mirror.
For ARM64
, the repository base URL will be:
export REPO_URL="http://de.ports.ubuntu.com/ubuntu-ports"
export TARGET_ARCH="arm64"
For X86_64
the repository base URL will be:
export REPO_URL="http://de.archive.ubuntu.com/ubuntu"
export TARGET_ARCH="amd64"
Canonical provides mirrors of the various Ubuntu repositories in each AWS region, so you can use those for speed. You can get the region calling:
export AWS_REGION="$(curl --silent http://169.254.169.254/latest/meta-data/placement/region)"
Then for ARM64
the repository base URL will be:
export REPO_URL="http://${AWS_REGION}.clouds.ports.ubuntu.com/ubuntu-ports"
export TARGET_ARCH="arm64"
While for X86_64
the repository base URL will be:
export REPO_URL="http://${AWS_REGION}.clouds.archive.ubuntu.com/ubuntu"
export TARGET_ARCH="amd64"
Now we can simply use debootstrap
to install the basics of the OS:
debootstrap --arch="${TARGET_ARCH}" --variant=minbase --include=systemd noble /mnt "${REPO_URL}"
Once debootstrap
is finished, we can mount the various filesystems required by
the installation:
mount -t proc proc "/mnt/proc"
mount -t sysfs sysfs "/mnt/sys"
mount -o bind /run "/mnt/run"
mount -o bind /dev "/mnt/dev"
mount -o bind /dev/pts "/mnt/dev/pts"
And then chroot
into our new environment using the C
locale (as no other
locale has yet been generated):
eval $(LANG=C LC_ALL=C LANGUAGE=C locale) chroot "/mnt" /bin/bash --login
bind 'set enable-bracketed-paste off'
We continue the installation by configuring some basic files.
First of all, we need to prepare the /etc/hostname
and /etc/hosts
files:
# Set the generic "ubuntu" host name
echo "ubuntu" > "/etc/hostname"
# Basic "hosts" file for "ubuntu"
cat > "/etc/hosts" << EOF
127.0.0.1 localhost
127.0.1.1 ubuntu ubuntu.local
EOF
Then we'll set up the /etc/mtab
link and /etc/fstab
file:
ln -sf "/proc/self/mounts" "/etc/mtab"
cat > "/etc/fstab" << EOF
$(printf "# %-41s %-15s %-5s %-17s %-5s %s" "PARTITION" "MOUNTPOINT" "TYPE" "OPTIONS" "DUMP" "FSCK")
$(printf "UUID=%-38s %-15s %-5s %-17s %-5s %s" $(findmnt -no UUID,TARGET,FSTYPE "${ROOT_DEV}") "defaults,discard" "0" "1")
$(printf "UUID=%-38s %-15s %-5s %-17s %-5s %s" $(findmnt -no UUID,TARGET,FSTYPE "${BOOT_DEV}") "umask=0077" "0" "1")
EOF
We then need to prepare our sources list for APT in /etc/apt/sources.list
:
cat > "/etc/apt/sources.list" << EOF
deb ${REPO_URL} noble main restricted universe multiverse
deb ${REPO_URL} noble-updates main restricted universe multiverse
deb ${REPO_URL} noble-security main restricted universe multiverse
EOF
We then want to update the system, and install all packages required for a minimal system.
The minimal-os
package provided here is a meta-package that requires only
a minimal set of dependencies, and provides some basic system configuration.
export DEBIAN_FRONTEND=noninteractive
export APT_OPTIONS="\
-oAPT::Install-Recommends=false \
-oAPT::Install-Suggests=false \
-oAcquire::Languages=none"
apt-get $APT_OPTIONS update && \
apt-get $APT_OPTIONS --yes dist-upgrade && \
apt-get $APT_OPTIONS --yes install /minimal-os.deb
Then for sanity's sake, let's keep only the minimal-os
package marked as
manually installed (this will help with apt-get autoremove
):
apt-mark showmanual | xargs apt-mark auto
apt-mark manual minimal-os
Next step is to install a kernel and our helper package for AWS EC2 or Raspberry Pi systems.
For AWS EC2 instances, the minimal-ec2-os
package will install grub
and its
configurations on the root device. The kernel comes from linux-aws
:
apt-get --yes install linux-aws /minimal-ec2-os.deb
After installing, as AWS EC2 boot via UEFI and grub
simply review the contents
of /etc/default/grub
and run:
# Install GRUB on the boot device, and update the kernel
grub-install "${BASE_DEV}"
update-grub
For the Raspberry Pi, we don't need a boot loader, and the minimal-rpi-os
will
take care of preparing the /boot/firmware
filesystem for booting:
apt-get --yes install linux-raspi /minimal-rpi-os.deb
For all other systems the linux-generic
kernel is a good starting point:
apt-get --yes install linux-generic grub-efi
We then need to configure the grub
boot loader, as a starting point use:
# Basic minimal GRUB configuration
cat > "/etc/default/grub" << EOF
GRUB_DEFAULT=0
GRUB_TIMEOUT=0
GRUB_TIMEOUT_STYLE="hidden"
GRUB_CMDLINE_LINUX_DEFAULT="nomodeset console=tty1"
GRUB_DISTRIBUTOR="Minimal Ubuntu OS"
EOF
# Install GRUB on the boot device, and update the kernel
grub-install "${BASE_DEV}"
update-grub
The minimal-os
package installs the ubuntu
user. If you need to log in interactively (e.g. from the console) set the password now:
passwd ubuntu
If you need SSH access, we only allow SSH keys. Place the authorized SSH public
key in /home/ubuntu/.ssh/authorized_keys
.
NOTE: If you are in EC2-land
Finally, we allow password-less sudo for the ubuntu
user. If this is not
to your liking, take a peek at the /etc/sudoers.d/00-minimal-os
file.
Let's clean up our APT state and exit the chrooted environment:
apt-get clean && exit
We then want to clean up a bunch of files left over by the installation, some
of them will be re-created once minimal-ec2-os-setup
runs the first time:
rm -f /mnt/minimal-*os*.deb \
/mnt/etc/ssh/ssh_host_*_key* \
/mnt/root/.bash_history \
/mnt/var/log/alternatives.log \
/mnt/var/log/apt/* \
/mnt/var/log/bootstrap.log \
/mnt/var/log/dpkg.log
At this point we can simply unmount our volume:
umount -Rlf /mnt
If needed (for example in case of an EC2 volume to create an AMI, or a disk image for a Raspberry Pi) we can clean out any unused block in our root filesystem:
zerofree -v "${ROOT_DEV}"
First of all clean out any unused block in our root filesystem:
Going back to the EC2 console we can now detach the volume we created from the EC2 instance we used for setup, and create a snapshot from it.
Once the snapshot is created, we can then create an image from it (remember, select the snapshot then Actions -> Create image from snapshot, it is not in the AMI section of the console).
Remember to select the following:
- Architecture: either
arm64
orx86_64
- Root device name: always
/dev/sda1
- Virtualization type: always
Hardware-assisted virtualization
- Boot mode: always
UEFI
Remeber to detach the loopback device of our image, first:
losetup -d ${BASE_DEV}
Then you can use dd
to write the image on an SD card or USB drive.
Remember, if you're doing this process on a Mac, using /dev/rdiskX
is a lot
faster than using /dev/diskX
, so, depending on what device your SD card or
USB drive maps to, use something like this:
sudo dd if="raspberry-os.img" of="/dev/rdisk10" bs=4M conv=fsync status=progress