
Building Incus images by hand does not scale. This guide industrialises that build through two routes: Packer with its Incus builder, and Ansible with the connection plugin, to produce reproducible and publishable images. It covers a complete Packer template, the equivalent Ansible playbook, and publishing the image into Incus. The commands apply to Incus 7.0.
What you will learn
- Build an image with a Packer template (the Incus builder).
- Do the same with an Ansible playbook.
- Publish the configured instance as a reusable image.
- Choose between the two approaches.
Building a custom image with Packer
This section walks through creating a custom image with Packer using the Incus builder. That builder produces container images compatible with Incus while giving the automation and reproducibility Packer provides.
Prerequisites
You first need Packer installed. It is available from the official site and through package managers such as apt on Linux or brew on macOS, or as a binary on Windows.
Then make sure Incus is properly installed and configured in your environment. The Packer builder for Incus needs Incus reachable to generate container images. See installing Incus if needed.
The Packer configuration file
Building an image with Packer rests on a template in JSON or HCL that says how the image will be built. Here is a simple HCL file for an Incus image:
packer { required_plugins { incus = { version = ">= 1.0.0" source = "github.com/bketelsen/incus" } }}
source "incus" "trixie" { image = "images:debian/13" output_image = "debian-ansible" reuse = true}
build { sources = ["incus.trixie"] provisioner "shell" { scripts = [ "scripts/debian/init.sh", ] }}Here the Incus builder takes a Debian 13 base image to create a new one. The provisioner block runs commands that prepare the image for use with Ansible.
The shell script:
apt updateapt install -y openssh-server python3 sudouseradd -m -s /bin/bash ansiblemkdir -p /home/ansible/.sshecho 'ssh-ed25519 xxxxxxxxxxxx' > /home/ansible/.ssh/authorized_keyschown -R ansible:ansible /home/ansible/.sshchmod 600 /home/ansible/.ssh/authorized_keysecho 'ansible ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/ansiblePASSWD=$(date | md5sum | cut -c1-8)echo "ansible:$PASSWD" | chpasswdRunning the build
To build the image, run the following from the directory holding the HCL file:
packer build debian-incus.pkr.hclPacker reads the template, uses the incus builder to download the base image, runs the provisioning script to configure it, then makes it available locally.
Checking the generated image
Once built, you find the image in Incus:
incus image list debian-ansible
+----------------+--------------+--------+--------------------------------------+--------------+-----------+-----------+----------------------+| ALIAS | FINGERPRINT | PUBLIC | DESCRIPTION | ARCHITECTURE | TYPE | SIZE | UPLOAD DATE |+----------------+--------------+--------+--------------------------------------+--------------+-----------+-----------+----------------------+| debian-ansible | 0fb77aa067dc | no | Debian trixie amd64 (20260630_05:24) | x86_64 | CONTAINER | 184.26MiB | 2026/07/01 07:59 UTC |+----------------+--------------+--------+--------------------------------------+--------------+-----------+-----------+----------------------+Extra builder options
Beyond the options above, the Incus builder offers several optional parameters:
reuse: reuse an existing image alias.publish_remote_name: publish the image on a specific Incus remote.init_sleep: the delay, in seconds, between launch and provisioning.virtual_machine: build a virtual machine image rather than a container image.
A field note: pairing the Packer Ansible provisioner with the Incus connection stays fragile. Packer does create and start the container, but Ansible provisioning through the Incus plugin does not always follow correctly. When that pairing gives trouble, an all-Ansible approach is more robust and drops Packer entirely.
Building an image with Ansible
You can use Ansible with the Incus connection plugin to automate creating and configuring images directly on containers, without Packer. The plugin lets Ansible treat Incus containers as remote machines, which makes running roles and playbooks straightforward. How the connection plugin works is covered in managing Incus with Ansible.
First install the community.general collection:
ansible-galaxy collection install community.generalHere is the playbook doing the same work as the Packer template:
---- name: Create instance hosts: localhost connection: local tasks: - name: Create instance ansible.builtin.shell: cmd: incus launch images:debian/13 debian-ansible creates: debian-ansible.ok - name: Start instance ansible.builtin.shell: cmd: incus start debian-ansible - name: Create flag ansible.builtin.file: path: debian-ansible.ok state: touch- name: Run command in container hosts: debian-ansible connection: community.general.incus gather_facts: false vars: pwd: "{{ lookup('password', '/dev/null length=15 chars=ascii_letters') }}" tasks: - name: Install packages ansible.builtin.raw: apt install -y python3 args: executable: /usr/bin/bash - name: Create user ansible.builtin.user: name: ansible shell: /usr/bin/bash password: "{{ pwd }}" state: present create_home: true home: /home/ansible - name: Copy SSH key ansible.posix.authorized_key: user: ansible state: present key: "{{ lookup('file', item) }}" with_fileglob: - ~/.ssh/id_ed25519.pub - name: Add user to sudoers community.general.sudoers: user: ansible name: ansible nopassword: true commands: ALL state: present- name: Publish the image hosts: localhost connection: local tasks: - name: Stop instance ansible.builtin.shell: cmd: incus stop debian-ansible removes: debian-ansible.ok - name: Create image ansible.builtin.shell: cmd: incus publish --alias debian_ansible debian-ansible --reuse removes: debian-ansible.ok - name: Delete flag ansible.builtin.file: path: debian-ansible.ok state: absentThe playbook splits into three stages:
-
Creating an instance: it launches a Debian 13 instance with Incus and creates a flag file marking successful creation. The instance is then started.
-
Running commands in the container: Ansible connects through Incus and runs several tasks: install Python 3, create an
ansibleuser with a random password, copy an SSH key and add the user to sudoers. -
Creating the image: once configured, the instance is stopped and published as an image named
debian_ansiblein Incus. The flag file is removed.
The command stays ordinary, since the parameters live in the playbook:
ansible-playbook incus.yml -i debian-ansible,The inventory can also be populated dynamically by the community.general.incus inventory plugin, rather than passed on the command line. This image build then fits naturally into a CI/CD pipeline to publish each new image automatically.
Key points
- Two routes to industrialise: the Incus Packer builder, or an end-to-end Ansible playbook.
- The Packer plus Ansible provisioner pairing stays fragile; the all-Ansible approach is more robust.
- The playbook launches an instance, configures it through the connection plugin, then publishes it as an image.
incus publish --aliasturns a stopped instance into a reusable image.- The published image appears in
incus image listwith its fingerprint, size and date.
FAQ: common questions about automating Incus images
Two routes to industrialise
Two approaches produce reproducible images:
- Packer with its Incus builder: an HCL template describes the base image and provisioners configure it.
- Ansible end to end through the Incus connection plugin: one playbook launches, configures and publishes the instance.
The all-Ansible route is often more robust: pairing the Ansible provisioner of Packer with the Incus connection stays fragile, and provisioning does not always follow the container starting.
incus publish
Once the instance is configured and stopped, you turn it into an image:
incus stop my-instance
incus publish --alias my-image my-instance --reuse
The --alias option names the image and --reuse overwrites an existing alias. The image then shows in incus image list with its fingerprint, size and date, ready to base new instances on.
The Incus connection plugin
Ansible drives an Incus container without SSH through the connection plugin community.general.incus, which treats the container as a remote machine.
Install the collection first:
ansible-galaxy collection install community.general
Then the play targeting the container declares:
connection: community.general.incus
A matching inventory plugin of the same name can also list the instances for you, instead of passing them on the command line.
One flow, more reliable
Pairing Packer with the Ansible provisioner through the Incus plugin chains badly: Packer does create and start the container, but Ansible provisioning does not always trigger correctly.
A fully Ansible approach avoids that breaking point:
- one tool launches the instance, configures it and publishes it;
- the flow fits naturally into a CI/CD pipeline;
- the inventory can be populated by the
community.general.incusplugin.
Fewer moving parts, so fewer points of failure.
Next steps
- Debugging an Incus VM stuck at boot: the reflex when a freshly built image produces a VM that will not start.
- Securing Incus: what to lock down before launching instances from your own images in bulk.
- Incus OS without a shell: the logical conclusion of the image approach, a system itself rebuilt at every update.