Building pre-configured VM images with Packer and GitHub actions

Simplifying VM provisioning and accelerating boot times

Madhan published on
5 min, 974 words

Banner

I recently hit a roadblock in my homelab setup when trying to install few packages as part of cloud-init during VM creation. Main issue I faced were the QEMU guest agent not running properly, which made getting the IP addresses of newly spun VMs difficult. That’s when I came across Hashicorp Packer — a tool that allows to pre-build cloud images with all the necessary packages already installed, and eliminating lengthy VM boot.

QEMU (Quick Emulator) — An open-source hypervisor that emulates physical computers by providing virtualized hardware resources like storage, network cards, and CPU to guest operating systems.

KVM (Kernel-based Virtual Machine) — A Linux virtualization technology that turns the kernel into a hypervisor, allowing multiple isolated virtual machines to run on a single host with near-native performance.

Packer QEMU builder — Creates KVM virtual machine images by spinning up a temporary VM, installing an operating system, provisioning software, and then converting the result into a reusable image.

Packer configuration breakdown

Plugin and variable setup

packer {
    required_plugins {
        qemu = {
            version = ">= 1.1.4"
            source  = "github.com/hashicorp/qemu"
        }
    }
}
variable "image_url" {
    type    = string
    default = "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"
}
variable "image_checksum" {
    type    = string
    default = "sha256:834af9cd766d1fd86eca156db7dff34c3713fbbc7f5507a3269be2a72d2d1820"
}

This section declares the required QEMU plugin and defines variables for the Ubuntu 24.04 cloud image URL and its SHA256 checksum for integrity verification.

QEMU source configuration

source "qemu" "ubuntu" {
    iso_url              = var.image_url
    iso_checksum         = var.image_checksum
    disk_image           = true
    output_directory     = "artifacts"
    disk_interface       = "virtio"
    net_device           = "virtio-net"
    disk_size            = "8G"
    format               = "qcow2"
    disk_compression     = true
    accelerator          = "kvm"
    headless             = true
    qemuargs = [
        ["-cdrom", "cidata.iso"]
    ]
    ssh_username         = "ubuntu"
    ssh_password         = "supersecret"
    ssh_timeout          = "10m"
    shutdown_command     = "echo 'supersecret' | sudo -S shutdown -P now"
}

Key Configuration,

  • qcow2 — QEMU's copy-on-write format that supports compression and snapshots

  • accelerator = "kvm" — Enables hardware acceleration for faster builds

  • qemuargs — Attaches the cloud-init ISO containing user configuration

Build process

build {
    sources = ["source.qemu.ubuntu"]
    provisioner "shell" {
        inline = [
            "while [ ! -f /var/lib/cloud/instance/boot-finished ]; do echo 'Waiting for Cloud-Init...'; sleep 1; done",
        ]
    }
    provisioner "shell" {
        inline = [
            "sudo apt-get clean",
            "sudo rm -rf /var/lib/apt/lists/*",
            "sudo fstrim -av"
        ]
    }
}

The build process,

  1. Waits for cloud-init completion to ensure all initial setup is finished

  2. Cleans package caches to reduce final image size

  3. Trims unused disk space for optimal storage efficiency

Cloud-Init configuration

users:
    - name: ubuntu
    sudo: ALL=(ALL) NOPASSWD:ALL
    shell: /bin/bash
    lock_passwd: false
    plain_text_passwd: supersecret
ssh_pwauth: true
password: supersecret
chpasswd:
    expire: false
runcmd:
    - systemctl enable ssh
    - systemctl start ssh
    - systemctl restart ssh
package_update: true
package_upgrade: true
packages:
    - openssh-server
    - clang
    - llvm
    - make
    - gcc
    - git
    - curl
    - wget
    - net-tools
    - qemu-guest-agent
    - ubuntu-drivers-common
    - linux-headers-generic
write_files:
    - path: /etc/ssh/sshd_config.d/99-packer.conf
    content: |
        PasswordAuthentication yes
        PermitRootLogin no
        PubkeyAuthentication yes
    permissions: '0644'

This cloud-init configuration,

  • Creates a user with sudo privileges and password authentication

  • Installs essential development tools and system utilities

  • Configures SSH for remote access

  • Updates all packages to latest versions

GitHub Actions automation

The GitHub Actions workflow automates the entire image building and release process.

Workflow triggers and permissions

on:
    push:
    branches:
        - "main"
    workflow_dispatch:
permissions:
    contents: write
    id-token: write

Triggers on pushes to main branch or manual dispatch and grants permissions to create releases & upload artifacts.

Environment Variables

env:
    PRODUCT_VERSION: "1.14.1"
    UBUNTU_VERSION: "22.04"

Defines Packer and Ubuntu version.

GA step analysis

1. Repository checkout

- name: Checkout repository
    uses: actions/checkout@v4

Downloads the repository code.

2. KVM permissions setup

- name: Enable KVM group perms
    run: |
    echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
    sudo udevadm control --reload-rules
    sudo udevadm trigger --name-match=kvm

This step enables hardware acceleration for significantly faster builds.

3. QEMU installation

- name: Install QEMU and dependencies
    run: |
    sudo apt-get update
    sudo apt-get install -y qemu-system-x86 qemu-utils genisoimage

Installs QEMU system emulator, utilities, and tools for creating ISO images.

4. Cloud-Init ISO creation

- name: Create Cloud-Init ISO
    run: |
    genisoimage -output cidata.iso -input-charset utf-8 -volid cidata -joliet -r http/user-data http/meta-data

Creates an ISO containing cloud-init configuration files (user-data) that QEMU will mount as a CD-ROM during VM boot.

5. Packer setup and execution

- name: Setup Packer
    uses: hashicorp/setup-packer@main
    id: setup
    with:
    version: ${{ env.PRODUCT_VERSION }}

- name: Initialize Packer
    id: init
    run: packer init .

- name: Validate Packer template
    run: packer validate .

- name: Build artifact
    run: packer build .

Downloads Packer, initializes plugins, validates the template syntax, and builds the custom image.

6. Release creation

- name: Rename artifact for release
    run: |
    mv artifacts/* ubuntu-${{ github.ref_name }}.qcow2
- name: Create Release and Upload Artifact
    uses: softprops/action-gh-release@v1
    with:
    files: ubuntu-${{ github.ref_name }}.qcow2
    tag_name: v${{env.UBUNTU_VERSION}}-${{ github.run_number }}
    name: Ubuntu custom image v${{env.UBUNTU_VERSION}}-${{ github.run_number }}
    body: |
        ## Ubuntu Image Release
        Built from commit: ${{ github.sha }}
        Branch: ${{ github.ref_name }}
        Build date: ${{ github.event.head_commit.timestamp }}

Renames the built image with a descriptive filename and creates a GitHub release with,

  • Automatic version tagging using Ubuntu version and build number

  • Detailed release notes including commit info and build timestamp

  • The custom image file as a downloadable asset

* * * *

Originally published on Medium

🌟 🌟 🌟 The source code for this blog post can be found here 🌟🌟🌟

GitHub - madhank93/ubuntu-cloud-image

Reference:

[1] https://www.redhat.com/en/topics/virtualization/what-is-KVM

[2] https://www.qemu.org/documentation/

[3] https://developer.hashicorp.com/packer

[4] https://actuated.com/blog/automate-packer-qemu-image-builds