Build an Ubuntu image

In this tutorial, we’ll build a custom Ubuntu image for AMD64 machines. We’ll work through everything from the initial project setup to the image’s first boot.

The tutorial takes about 25 minutes to complete. It doesn’t require an intimate understanding of disk images, but you’ll need to be familiar with Linux paradigms and using the terminal.

What we’ll build

After installing the necessary tools, we’ll start building a custom Ubuntu image from the ground up.

We’ll define the image’s structure and content step by step. The image will be based on the suite of packages from Ubuntu 24.04 LTS, with some additional software that caters it to the tutorial.

We’ll end the tutorial by packaging the complete image and running it with QEMU, a popular machine emulator.

Once you’ve completed the tutorial, you’ll have practical experience with Imagecraft and a custom image you can add software to or model your next image from.

What you’ll need

For this tutorial, you’ll need:

  • An AMD64 machine running Ubuntu 24.04 LTS

  • Super user privileges on your machine

  • 10GiB of available storage

Install prerequisites

To begin, let’s install the Imagecraft snap. Open a terminal and run:

snap install imagecraft --beta --classic

Next, let’s install Multipass, which will create the build environment when it comes time to package the image:

snap install multipass

We’ll run our image with QEMU. Install it with:

sudo apt install qemu-system-x86

Lastly, we’ll need UEFI firmware to pass to QEMU. One of the most popular choices is OVMF. Install it with:

sudo apt install ovmf

Set up the project

We’ll need a directory to hold the project. Create a directory wherever you like to keep your software projects:

mkdir ubuntu-minimal
cd ubuntu-minimal

Images are built and configured through an imagecraft.yaml file, called the project file. Let’s create one in the new project directory with the init command:

imagecraft init

Open the generated project file in your preferred text editor. This is where most of the tutorial will take place.

Describe the image

An image’s project file starts with details like its name, version, and build environment. Imagecraft initialized these keys with generic values. Let’s update the summary and description keys to better reflect the new project. Replace the first six keys with:

name: ubuntu-minimal
base: bare
build-base: ubuntu@24.04
version: '0.1'
summary: A lightweight, unofficial Ubuntu image for AMD64 machines.
description: |
  The ubuntu-minimal image is a lightweight, unofficial Ubuntu image for AMD64
  machines. Its root file system and packages are based off of Ubuntu 24.04 LTS,
  and it's booted with GRUB.

The base key defines the files that make up the foundation of the image. We’re starting with an empty directory, known as the bare base, and building it up from scratch.

The build-base key defines the operating system that’s used to assemble the image. It does not affect the image’s contents. It’s best to build with the latest Ubuntu LTS release in most cases, so we left this unchanged.

The summary and description keys tell consumers of the image a little more about it. The summary is a one-line description, limited at 79 characters, while the description is more open-ended and can span multiple lines. These were both placeholders in the template project file, so we made them meaningful for this project.

Define the partitions

Now that we’ve described the image and declared its build details, we need to define its partitions. To do so, we’ll customize the volumes key.

The volumes key contains a single entry, named disk. The schema key tells us that disk is partitioned with GPT, the recommended schema for most images. We’ll define individual partitions in the structure key.

The image will have two partitions: a root file system and an EFI system partition. Each will need their own entry in the structure key. The first was defined for us automatically. Before we go over it, let’s define the EFI system partition the image will boot from. Add the following highlighted lines after the rootfs partition:

volumes:
  disk:
    schema: gpt
    structure:
      - name: rootfs
        role: system-data
        type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4
        filesystem: ext4
        filesystem-label: root
        size: 3G
      - name: efi
        role: system-boot
        type: C12A7328-F81F-11D2-BA4B-00A0C93EC93B
        filesystem: vfat
        filesystem-label: uefi
        size: 256M

There’s a lot to unpack here. Let’s take a moment to go over each of the efi partition’s keys and compare them to the rootfs partition.

We declared that this is the boot partition by setting the role key to system-boot. The rootfs partition’s role key was set to system-data, which tells Imagecraft that it contains the operating system.

We set the type key to the identifier for EFI system partitions in a GUID partition table. The rootfs partition was generated with the identifier for Linux file systems. The identifiers themselves come from the UEFI specification—don’t worry about memorizing them.

We set the filesystem key to vfat for its compatibility with EFI system partitions. Since the rootfs partition will be for general usage, it uses the ext4 filesystem instead.

In both partitions, the filesystem-label key is set to a unique, human-readable name. This is for the benefit of you and anyone else who might work with the packaged image later on.

Mount the partitions

The image’s partitions are ready, but we haven’t told Imagecraft where to mount them in the image’s file system. Let’s shift our focus to the filesystems key.

The filesystems key maps the image’s partitions to their mount points. It expects a single file system, named default, that mounts a partition to the root of the image. The filesystems key in the project file already mounts the rootfs partition to the image’s root, but we’ll still need to mount the EFI system partition.

Add the following highlighted lines to the end of the filesystems key:

filesystems:
  default:
    - device: (volume/disk/rootfs)
      mount: /
    - device: (volume/disk/efi)
      mount: /boot/efi/

With this entry, we mounted the EFI system partition to the /boot/efi/ directory in the final image. We’ll create this directory shortly.

Set up the root file system

Because we’re building the image on the bare base, its file system is currently an empty directory. Let’s start building it up with the parts key.

Parts are the means by which we source packages for and manipulate the files in the image. They’re the primary way we interact with the overlay file system, which is where we’ll build up the image.

We’ll create the file system with a part that uses the mmdebstrap plugin.

In the parts key, replace the template part with a new part named rootfs, defined as follows:

parts:
  rootfs:
    plugin: mmdebstrap
    mmdebstrap-suite: noble

The mmdebstrap-suite key is specific to the mmdebstrap plugin and specifies the package suite to bootstrap. To get the packages that are shipped with Ubuntu 24.04 LTS, we set the suite to noble.

The plugin removes the default sources configuration files, which limit us to the system packages from the noble suite’s main component. If we want to install anything more than essential system packages, we’ll need to add a new sources configuration file. We also still need to create the /boot/efi/ directory we mounted the efi partition to.

Add the following override-build key to the part:

  rootfs:
    plugin: mmdebstrap
    mmdebstrap-suite: noble
    override-build: |
      craftctl default
      mkdir $CRAFT_PART_INSTALL/boot/efi/
      cat << EOF > $CRAFT_PART_INSTALL/etc/apt/sources.list.d/ubuntu.sources
      Types: deb deb-src
      URIs: http://archive.ubuntu.com/ubuntu
      Suites: noble noble-updates noble-backports noble-security
      Components: main restricted universe multiverse
      Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
      EOF

The override-build key replaces the plugin’s default behavior. Since we want to extend the part’s build instead of overriding it, we started the script with craftctl default, which runs the plugin’s default commands.

Now, when we install packages into the image, we’ll be able to access the other components in the noble suite.

At this point, the file system only exists in the rootfs part. To get it into the final image, we’ll need to copy it into the overlay file system. We can do so with the organize key and the (overlay)/ prefix. Add the following highlighted lines to the rootfs key:

  rootfs:
    plugin: mmdebstrap
    mmdebstrap-suite: noble
    override-build: |
      craftctl default
      mkdir $CRAFT_PART_INSTALL/boot/efi/
      cat << EOF > $CRAFT_PART_INSTALL/etc/apt/sources.list.d/ubuntu.sources
      Types: deb deb-src
      URIs: http://archive.ubuntu.com/ubuntu
      Suites: noble noble-updates noble-backports noble-security
      Components: main restricted universe multiverse
      Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
      EOF
    organize:
      '*': (overlay)/

This copies the result of the part’s build step to the root of the overlay file system, thereby securing its place in the final image.

Add essential packages

We’ll need some additional packages for the image to be bootable. Let’s define a new part to source them. In this case, we don’t need any special behavior, so we’ll set the plugin key to nil. Add a new part named packages, defined as follows:

  packages:
    plugin: nil
    overlay-packages:
      - ubuntu-server-minimal
      - linux-image-generic
      - grub2-common
      - grub-pc
      - shim-signed
      - sl

With the exception of sl, these packages add system essentials such as the kernel, core utilities, and boot loader. You won’t need to worry about sl until we run the image later on.

Create the file system table

If we tried to boot the image now, its partitions wouldn’t be mounted. This is because Imagecraft requires that we create our file system table manually. The content of this table is similar to what we declared in the filesystems key, with some additional configuration.

With how we set up the partitions and mount points, the table will read:

LABEL=root        /            ext4    discard,errors=remount-ro    0    1
LABEL=uefi        /boot/efi/   vfat    umask=0077                   0    1

The first three columns should look familiar—these are the labels, mount points, and file system types we declared for our partitions. The last three columns declare each partition’s active mount options, whether we want to dump the partition’s utility backup, and the file system check order.

Let’s create a part that writes this to the /etc/fstab/ directory in the overlay file system. Add a new part named fstab, defined as follows:

  fstab:
    plugin: nil
    overlay-script: |
      cat << EOF > $CRAFT_OVERLAY/etc/fstab
      LABEL=root    /             ext4    discard,errors=remount-ro    0    1
      LABEL=uefi    /boot/efi/    vfat    umask=0077                   0    1
      EOF

Here, we used the overlay-script key to write the table to the overlay file system, which is referenced through the $CRAFT_OVERLAY environment variable. Keep in mind that this environment variable is only available in parts that include, or depend on another part that includes, overlay keys.

The partitions will now be mounted automatically when the system boots.

Set the default user

To interact with the system after we boot the image, we’ll need to set the default user and password.

For the purposes of this tutorial, we’ll set up a login part that runs the chpasswd command in the overlay file system. This should not be done in images built for production environments.

Add a new part named login, defined as follows:

  login:
    plugin: nil
    overlay-script:
      echo "root:password" | chpasswd --root "${CRAFT_OVERLAY}"

When we run our image later, we’ll log in with the username root and the password password.

Our project file now contains everything we need to pack a complete, bootable image. Save and close the imagecraft.yaml file.

Pack the image

To isolate the image build from your machine, we’ll pack the image in a Multipass VM. Open a new terminal in the ubuntu-minimal/ project directory and run:

snap set imagecraft provider=multipass
imagecraft pack

The packing process takes around ten minutes. When your terminal shows the following line, the build is complete:

Packed disk.img

Congratulations on building your first image! Before you start celebrating, let’s run the image to make sure everything is working as expected.

Run and test the image

Before we run our image with QEMU, let’s copy the UEFI variables from OVMF into a temporary directory so we don’t compromise the originals:

cp /usr/share/OVMF/OVMF_VARS_4M.fd /tmp/OVMF_VARS_4M.fd

You’ll need to repeat this step if you reboot your machine between runs.

With no further ado, let’s run the image with QEMU:

qemu-system-x86_64 \
  -accel kvm \
  -m 4G \
  -cpu host \
  -smp 8 \
  -drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd \
  -drive if=pflash,format=raw,file=/tmp/OVMF_VARS_4M.fd \
  -drive file=disk.img,format=raw,index=0,media=disk

This will open QEMU in a separate window. After about a minute, it’ll display the following login prompt:

imagecraft-ubuntu-minimal-amd64-49807517 login:

As you may recall from the login part, the default username is root and the password is password. Enter these into the QEMU shell now.

By booting and logging in to the image, we’ve verified the presence of its essential packages. To ensure the packages from the extra components of the noble suite are in place, let’s run the sl command in the QEMU shell.

      ====        ________                ___________
  _D _|  |_______/        \__I_I_____===__|_________|
   |(_)---  |   H\________/ |   |        =|___ ___|       _________________
   /     |  |   H  |  |     |   |         ||_| |_||      _|                \\_____A
  |      |  |   H  |__--------------------| [___] |    =|                        |
  | ________|___H__/__|_____/[][]~\_______|       |    -|                        |
  |/ |   |-----------I_____I [][] []  D   |=======|__ __|________________________|_
__/ =| o |=-~~\  /~~\  /~~\  /~~\ ____Y___________|__ |__________________________|_
 |/-=|___|=    ||    ||    ||    |_____/~\___/           |_D__D__D_|  |_D__D__D_|
  \_/      \O=====O=====O=====O_/      \_/                \_/   \_/    \_/   \_/

Review the project file

Here’s the complete project file for the image. Yours should look similar to it.

imagecraft.yaml for ubuntu-minimal
name: ubuntu-minimal
base: bare
build-base: ubuntu@24.04
version: '0.1'
summary: A lightweight, unofficial Ubuntu image for AMD64 machines.
description: |
  The ubuntu-minimal image is a lightweight, unofficial Ubuntu image for AMD64
  machines. Its root file system and packages are based off of Ubuntu 24.04 LTS,
  and it's booted with GRUB.

platforms:
  amd64:

volumes:
  disk:
    schema: gpt
    structure:
      - name: rootfs
        role: system-data
        type: 0FC63DAF-8483-4772-8E79-3D69D8477DE4
        filesystem: ext4
        filesystem-label: root
        size: 3G
      - name: efi
        role: system-boot
        type: C12A7328-F81F-11D2-BA4B-00A0C93EC93B
        filesystem: vfat
        filesystem-label: uefi
        size: 256M

filesystems:
  default:
    - device: (volume/disk/rootfs)
      mount: /
    - device: (volume/disk/efi)
      mount: /boot/efi/

parts:
  rootfs:
    plugin: mmdebstrap
    mmdebstrap-suite: noble
    override-build: |
      craftctl default
      mkdir $CRAFT_PART_INSTALL/boot/efi/
      cat << EOF > $CRAFT_PART_INSTALL/etc/apt/sources.list.d/ubuntu.sources
      Types: deb deb-src
      URIs: http://archive.ubuntu.com/ubuntu
      Suites: noble noble-updates noble-backports noble-security
      Components: main restricted universe multiverse
      Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
      EOF
    organize:
      '*': (overlay)/

  packages:
    plugin: nil
    overlay-packages:
      - ubuntu-server-minimal
      - linux-image-generic
      - grub2-common
      - grub-pc
      - shim-signed
      - sl

  fstab:
    plugin: nil
    overlay-script: |
      cat << EOF > $CRAFT_OVERLAY/etc/fstab
      LABEL=root    /             ext4    discard,errors=remount-ro    0    1
      LABEL=uefi    /boot/efi/    vfat    umask=0077                   0    1
      EOF

  login:
    plugin: nil
    overlay-script:
      echo "root:password" | chpasswd --root "${CRAFT_OVERLAY}"

Conclusion

This marks the end of this image’s journey. If you’d like to develop your crafting skills further, you can customize the image or even build a new one from scratch.

If you create an image for a new system or architecture, we encourage you to share it with us on Matrix. We’d love to see what you come up with.

If you’d like to share any feedback on Imagecraft or this tutorial, please open an issue. We appreciate your input.