Custom images¶
Danger
We strongly recommend to use the provided Ubuntu images. Support for custom image creation and non-Ubuntu/non-Debian distributions is very limited and may not be available at all. The image creation process described in this guide requires solid system administration experience and proficiency with the shell. Use at your own risk.
This guide is intended primarily for
- System administrators who are trying to automate image creation for their labs
- Advanced users who have compelling reasons for using non-Ubuntu flavours
As mentioned in the warning above, the guide expects advanced understanding of operating systems and shell programming. We may not be able to provide any further support especially when it comes to non-Ubuntu/Debian based systems.
Environment¶
For creating custom images, we will be using a regular VM but the process can be easily adapted to run on your laptop. We will not be building anything locally, so a modest 2cpu-8ram-hpcv3 with uzh-only network interface will suffice.
To keep everything tidy, let's create a separate directory.
We will need the following additional software.
-
OpenStack python client that we will install into a virtual environment.
-
HashiCorp packer. The installation instructions still rely on the deprecated
apt-key, so we provide the updated approach below.sudo mkdir -p /etc/apt/keyrings keyPath="/etc/apt/keyrings/hashicorp-archive-keyring.gpg" debArch="$(dpkg --print-architecture)" debUrl="https://apt.releases.hashicorp.com" debRel="$(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs)" curl -fsSL https://apt.releases.hashicorp.com/gpg | gpg --dearmor | \ sudo tee $keyPath > /dev/null echo "deb [arch=$debArch signed-by=$keyPath] $debUrl $debRel main" | \ sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt-get update && sudo apt-get install packer
In addition, you will need the openrc file for the project where you would like to store the images.
Process outline¶
- Create Packer template file
- Create Packer variable file
- Format and validate the template
- Build image. In particular, packer will do the following
- Launch a VM using the specified specs and source image
- Run customization commands
- Shut down the VM
- Take a snapshot and convert it into an image
- Destroy the VM
First, we will see how to build an Ubuntu image. Then the same process will be shown for RockyLinux and AlmaLinux. Finally, we will provide some tips for debugging.
Tip
Ubuntu section contains important details. Please read it through even if you only need Rocky or Alma.
You may want to source your openrc file at this point as several steps will use the openstack client. Also, make sure that the python virtual environment is activated.
Packer template¶
For Ubuntu 26 image, we can start with the following template. We will save it in the file named deb-based.pkr.hcl.
packer {
required_plugins {
openstack = {
version = "~> 1"
source = "github.com/hashicorp/openstack"
}
}
}
variable "image_name" {
type = string
}
variable "source_image_url" {
type = string
}
variable "ssh_username" {
type = string
}
variable "network" {
type = string
}
variable "flavor" {
type = string
default = "2cpu-8ram-hpcv3"
}
variable "image_visibility" {
type = string
default = "private"
}
source "openstack" "deb" {
external_source_image_url = var.source_image_url
image_name = var.image_name
image_visibility = var.image_visibility
flavor = var.flavor
ssh_username = var.ssh_username
networks = [var.network]
security_groups = ["default"]
ssh_wait_timeout = "20m"
ssh_timeout = "5m"
}
build {
name = "deb-compatible-image"
sources = ["source.openstack.deb"]
provisioner "shell" {
inline = [
"echo 'Starting provisioning...'",
"sudo DEBIAN_FRONTEND=noninteractive apt-get update -y",
"sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y",
"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y cloud-init python-is-python3"
]
}
# *** Place your provisioner(s) here ***
provisioner "shell" {
inline = [
"echo 'Cleaning up...'",
"sudo DEBIAN_FRONTEND=noninteractive apt-get autoremove -y",
"sudo DEBIAN_FRONTEND=noninteractive apt-get clean -y",
"sudo cloud-init clean --logs --seed --machine-id",
"sudo rm -rfv /home/*/.cache",
"sudo rm -fv /home/*/.lesshst",
"sudo rm -fv /etc/ssh/ssh_host_*",
"sudo find /var/log -type f -exec truncate -s 0 {} \\;",
"sudo rm -rfv /var/log/journal/*",
"command -v fstrim && sudo fstrim -av || true",
"sudo rm -fv /home/*/.bash_history",
"sudo rm -fv /root/.ssh/authorized_keys",
"sudo rm -fv /home/*/.ssh/authorized_keys"
]
}
post-processor "shell-local" {
inline = [
"echo 'Image build complete: ${var.image_name}'"
]
}
}
The template defines what kind of VM packer should create, what source image to use, and what customization commands to execute. Custom commands are executed by provisioners. There are two of them defined. The first one updates the apt sources, runs upgrade just in case, ensures cloud-init is installed, and takes care of the python3 annoyance. The second provisioner cleans up the system to ensure that no instance-specific state or credentials are leaked into cloned systems. This prevents security risks such as duplicate host identities and unintended access, while ensuring each new instance generates its own unique configuration and keys on first boot.
You can place your provisioners between the two. Provisioners can transfer files to the image and they can execute various commands (see packer documentation for details).
As an example, let's create a provisioner that install some useful packages.
provisioner "shell" {
inline = [
"echo 'Installing additional packages...'",
"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y language-pack-de",
"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y fd-find ripgrep fzf jq"
]
}
This provisioner should be placed just below the comment # *** Place your provisioner(s) here ***.
Setting variables¶
There are four variables that are declared but not set in the template. In principle, you can set them directly in the template but they can also be defined in a separate file, so that you could reuse the template with different configurations.
The following parameters are required:
image_name: name for the output image, e.g. "u26_20260421"source_image_url: source image url; it has to be a cloud image!ssh_username: SSH username, for Ubuntu it isubuntunetwork: ID for theuzh-onlynetwork, e.g.openstack network show -f value -c ID uzh-only
Warning
The source image must be a cloud image!
We can save them into u26.pkrvars.hcl as follows.
cat <<EOF > u26.pkrvars.hcl
image_name = "u26_20260518"
source_image_url = "https://cloud-images.ubuntu.com/resolute/20260421/resolute-server-cloudimg-amd64.img"
ssh_username = "ubuntu"
network = "$(openstack network show -f value -c ID uzh-only)"
EOF
Alternatively, you can set all these variables on command line as -var 'ssh_username=ubuntu' but the command becomes rather long in that case.
Initialize openstack plugin¶
Before running packer for the first time, you need to initialize the openstack plugin, which is defined at the top of the template. The following command will download and install the plugin. Afterwards, you do not need to run init unless you add more plugins to the template.
Format and validate template¶
It is recommended to keep the formatting consistent and to validate the template before building.
Note
If you have not sourced the openrc file or defined the required variables, the validation will fail.
Building¶
Depending on the complexity of the provisioners, building may take anywhere from a few minutes to several hours.
If the process fails, packer will remove the VM automatically.
Red Hat derivatives¶
Rocky Linux and Alma Linux are popular distributions that provide cloud images. Since they are very similar, we can use the same template.
packer {
required_plugins {
openstack = {
version = "~> 1"
source = "github.com/hashicorp/openstack"
}
}
}
variable "image_name" {
type = string
}
variable "source_image_url" {
type = string
}
variable "ssh_username" {
type = string
}
variable "network" {
type = string
}
variable "flavor" {
type = string
default = "2cpu-8ram-hpcv3"
}
variable "image_visibility" {
type = string
default = "private"
}
source "openstack" "rh" {
external_source_image_url = var.source_image_url
image_name = var.image_name
image_visibility = var.image_visibility
flavor = var.flavor
ssh_username = var.ssh_username
networks = [var.network]
security_groups = ["default"]
ssh_wait_timeout = "20m"
ssh_timeout = "5m"
}
build {
name = "rh-compatible-image"
sources = ["source.openstack.rh"]
provisioner "shell" {
inline = [
"echo 'Starting provisioning...'",
"sudo dnf -y update",
"sudo dnf -y install cloud-init langpacks-core-de",
]
}
# *** Place your provisioner(s) here ***
provisioner "shell" {
inline = [
"echo 'Cleaning up...'",
"sudo dnf -y autoremove",
"sudo dnf clean all",
"sudo cloud-init clean --logs --seed --machine-id",
"sudo rm -rfv /home/*/.cache",
"sudo rm -fv /home/*/.lesshst",
"sudo rm -fv /etc/ssh/ssh_host_*",
"sudo find /var/log -type f -exec truncate -s 0 {} \\;",
"sudo rm -rfv /var/log/journal/*",
"command -v fstrim >/dev/null && sudo fstrim -av || true",
"sudo rm -fv /home/*/.bash_history",
"sudo rm -fv /root/.ssh/authorized_keys",
"sudo rm -fv /home/*/.ssh/authorized_keys"
]
}
post-processor "shell-local" {
inline = [
"echo 'Image build complete: ${var.image_name}'"
]
}
}
As an example, we can create a provisioner that installs NVIDIA driver and CUDA toolkit. While the example is a good use case illustration, it is not appropriate for testing or exploration. The driver is large and may require 15-30 minutes to install.
provisioner "shell" {
environment_vars = [
"urlPrefix=https://developer.download.nvidia.com/compute/cuda/repos",
"distro=rhel10",
"arch=x86_64"
]
inline = [
"echo 'Installing NVIDIA driver and CUDA toolit...'",
"sudo dnf config-manager --add-repo $urlPrefix/$distro/$arch/cuda-$distro.repo",
"sudo dnf install -y epel-release",
"sudo dnf install -y cuda-driver cuda-toolkit"
]
}
Rocky¶
Variable file
Warning
The source image must be a cloud image!
cat <<EOF > rocky.pkrvars.hcl
image_name = "rocky_10.1"
source_image_url = "https://dl.rockylinux.org/pub/rocky/10/images/x86_64/Rocky-10-GenericCloud-Base-10.1-20251116.0.x86_64.qcow2"
ssh_username = "rocky"
network = "$(openstack network show -f value -c ID uzh-only)"
EOF
Building
packer validate -var-file=rocky.pkrvars.hcl rh-based.pkr.hcl
packer build -var-file=rocky.pkrvars.hcl rh-based.pkr.hcl
Alma¶
Variable file
Warning
The source image must be a cloud image!
cat <<EOF > alma.pkrvars.hcl
image_name = "alma_10.1_20260518"
source_image_url = "https://mirror.init7.net/almalinux/10.1/cloud/x86_64/images/AlmaLinux-10-GenericCloud-10.1-20260518.0.x86_64.qcow2"
ssh_username = "almalinux"
network = "$(openstack network show -f value -c ID uzh-only)"
EOF
Building
packer validate -var-file=alma.pkrvars.hcl rh-based.pkr.hcl
packer build -var-file=alma.pkrvars.hcl rh-based.pkr.hcl
Debugging suggestions¶
Since the whole process may be rather time consuming, we suggest the following approach.
-
Create a simple image using one of the templates above without any additional provisioners.
-
Use the created image to instantiate a VM
-
Run your customization commands to ensure everything works correctly
-
Add a provisioner with those commands
-
Build a new image
To use an existing ScienceCloud image as a starting point, you would need to make the following modifications.
-
Replace
source_image_urlvariable withsource_image -
In the source block, replace
external_source_image_url = var.source_image_urlline withsource_image = var.source_image -
Remove the first provisioner as the update and upgrade already run and no longer needed.
-
Find the ID of the image, e.g.
openstack image show -f value -c ID $imgName -
In the variables file, replace
source_image_urlwithsource_imageand set it to the image ID found in the previous step. -
Build an image with the updated template and variables file.
If you still encounter problems, you can create an image with partial customization and use it as starting point. Packer will still need to instantiate VM but you will be able to avoid external image download, update, and upgrade calls that may be rather slow.