Building an Ubuntu 26.04 LTS Cloud-Init Template in Proxmox

Ubuntu 26.04 LTS dropped last month, and instead of doing what I’d normally do — build a template, snapshot it, and slowly watch it age — I decided it was finally time to actually dig into Cloud-Init.

If you’ve built Proxmox templates before, you know the drill: create a VM, install the OS, install your packages, sysprep it, convert it to a template, and then clone it every time you need a new box. It works, but every clone inherits whatever state the template was in the day you built it. Packages drift. You’re manually cleaning up machine IDs and SSH host keys after every clone. Spin up ten VMs for a lab build and you’ve got ten rounds of post-boot cleanup ahead of you.

Cloud-Init sidesteps all of that. Instead of a fat, opinionated image, you start with a minimal cloud image — the same kind AWS, Azure, and GCP use under the hood. The image carries no fixed identity. When you clone it and boot it, Proxmox injects your config (username, SSH key, hostname, network settings) via a virtual CD-ROM drive, and Cloud-Init configures the VM from the inside on first boot. Clean machine ID, unique SSH host keys, fully configured — before you even open a terminal.

The real unlock is that your config is a YAML file. Want ten identical VMs? Same file, ten times. Want to add a package or a user? Edit the file. Your infrastructure becomes something you can version, reuse, and actually reason about — instead of a template you’re scared to touch because you don’t remember what’s in it.

That’s why I built this. Here’s exactly how I did it on Proxmox VE 9.2.3 with Ubuntu 26.04 LTS “Resolute Raccoon.” Still on Proxmox 8? I’d get the cluster current first — here’s how I upgraded my Proxmox cluster from version 8 to 9.

What is Cloud-Init, Actually?

Cloud-Init is an industry-standard first-boot initialization system — the same one AWS, Azure, and GCP use under the hood. Instead of manually clicking through an Ubuntu installer and then scrubbing machine IDs and SSH host keys from the image, you start with a pre-built minimal “cloud image” designed to be generic and stateless.

When you clone it and boot it, Cloud-Init reads a small config — hostname, username, SSH key, network settings — that Proxmox injects via a virtual CD-ROM drive. The VM configures itself automatically in seconds. No manual post-boot setup, no configuration drift, no forgotten hostname changes causing weird network issues two weeks later.

One important thing to understand upfront: Cloud-Init only runs once — on first boot. It’s not a service that re-applies config every time the VM starts. After that first boot, it stamps a flag on the VM and never runs again. Rebooting a running VM doesn’t pull fresh packages or re-apply settings. If you want a fresh, fully updated machine, the workflow is to clone the template again — not reboot an existing one. That distinction shapes everything about how you operate day-to-day with Cloud-Init.

Why Shared Storage Changes the Storage Decision

On a single node, local-lvm is fine. On a cluster, it creates a real problem: the template only exists on the node you built it on. Clones default to that same node’s local storage, which means no live migration without offline disk moves, no HA eligibility, and you’d need a copy of the template on every node if you want flexibility.

Storing the template on shared storage (I’m using Ceph) solves all of this. Any node can clone directly from it, clones live on shared storage by default, and those VMs are immediately eligible for live migration and Proxmox HA — no extra configuration needed. On a single node, local-lvm is a solid choice; NFS, CIFS, or a directory-backed share are also valid where configured. The commands below use ceph_pool as an example storage ID, but the disk volume ID printed by qm importdisk is backend-specific—copy it exactly rather than replacing only the storage name.

The Walkthrough

What you’ll need: Proxmox VE (I’m on 9.2.3, kernel 7.0.6-2-pve), a configured Ceph pool or shared storage, SSH access to a node, and about 10 minutes.

Step 1: Download the Ubuntu 26.04 Cloud Image

SSH into your Proxmox node and grab the official image:

cd /var/lib/vz/template/iso/
wget https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.imgCode language: JavaScript (javascript)

Verify it landed with ls -lh resolute-server-cloudimg-amd64.img — you should see around 819M. This is a minimal server image: no desktop, no extras.

Step 1 - Download Ubuntu 26.04 Cloud Image

Step 2: Inject the QEMU Guest Agent

Ubuntu cloud images ship intentionally lean. The QEMU Guest Agent — which lets Proxmox read the VM’s IP address and send clean shutdown signals from the UI — isn’t included. We inject it directly into the image file before the VM ever boots using virt-customize.

Install the tool:

apt update && apt install -y libguestfs-tools
Step 2 - Install libguestfs-tools

Then inject the agent:

virt-customize -a resolute-server-cloudimg-amd64.img --install qemu-guest-agentCode language: CSS (css)
Step 2.1 - Inject QEMU Guest Agent

You’ll see a timestamped log as it works. The random seed warning is harmless — cloud images don’t persist a seed by design. What matters: Installing packages: qemu-guest-agent and Finishing off.

Step 3: Create the VM Shell

Build an empty hardware profile — chassis only, no OS yet:

qm create 9000 --name "ubuntu-2604-cloudinit" --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0Code language: JavaScript (javascript)
Step 3 - Create the VM Shell

VM ID 9000 keeps this in the template range (9000+), visually separated from runtime VMs in the sidebar. Silent return means success.

Step 4: Import and Attach the Disk

Import the cloud image into your Ceph pool:

qm importdisk 9000 resolute-server-cloudimg-amd64.img ceph_poolCode language: CSS (css)

Not using Ceph? Use your storage ID in place of ceph_pool (for example, local-lvm or an NFS/CIFS/directory-backed store). The disk name returned by the import is backend-specific: copy the complete volume ID from the success message into the attach command below. A directory-backed share can return truenas_vm:9000/vm-9000-disk-0.raw, rather than the Ceph-style ceph_pool:vm-9000-disk-0.

The image expands from its compressed 819MB to 3.5GB on Ceph — that’s the actual provisioned size. When complete:

unused0: successfully imported disk 'ceph_pool:vm-9000-disk-0'Code language: JavaScript (javascript)
Step 4 - Import Disk to Ceph

Now attach it on a VirtIO SCSI controller — the highest-performance option, and what Ubuntu cloud images expect:

qm set 9000 --scsihw virtio-scsi-pci --scsi0 ceph_pool:vm-9000-disk-0Code language: JavaScript (javascript)

For non-Ceph storage: do not replace only ceph_pool in the command above. Attach the exact volume ID printed by qm importdisk. For example: qm set 9000 --scsihw virtio-scsi-pci --scsi0 truenas_vm:9000/vm-9000-disk-0.raw.

Step 4.1 - Attach Disk

Step 5: Add the Cloud-Init Drive

This is what makes the whole thing work — a virtual CD-ROM that Proxmox uses to pass your first-boot config into the VM:

qm set 9000 --ide2 ceph_pool:cloudinitCode language: JavaScript (javascript)

Not using Ceph? Replace ceph_pool here with the storage ID you selected for the template disk—for example, qm set 9000 --ide2 local-lvm:cloudinit. Unlike the imported OS disk, this command creates the new Cloud-Init volume, so it uses the storage ID plus :cloudinit.

Step 5 - Add the Cloud-Init Drive

Proxmox immediately generates a blank Cloud-Init ISO. Each clone you create gets its own copy, populated with whatever config you set in the Cloud-Init tab.

Step 6: Boot Order, Serial Console & QEMU Agent

Three quick settings, each with a specific reason:

qm set 9000 --boot order=scsi0
qm set 9000 --agent enabled=1
qm set 9000 --serial0 socket --vga serial0Code language: JavaScript (javascript)
Step 6 - Set Boot Order, Serial Console and QEMU Agent
  • --boot order=scsi0 — boot from disk, not the CD-ROM
  • --agent enabled=1 — tells Proxmox to talk to the guest agent we injected
  • --serial0 socket --vga serial0 — redirects console output to a serial socket; skip this and you’ll get a black screen in the web console because cloud kernels don’t load VGA drivers

One more requirement: This creates the serial device in Proxmox. The Ubuntu guest still needs a serial-login service, which Step 8 covers after you SSH into the new VM.

Step 7: Convert to Template

qm template 9000
Step 7 - Convert to Template

Because the disk lives on Ceph, Proxmox snapshots it before locking — you’ll see Creating snap: 100% complete. That’s a shared storage bonus. VM 9000 now shows the stacked-pages template icon in the UI. Read-only from here — clone it to make VMs, never edit the template directly.

Step 7.1 - Template icon visible in Proxmox UI

Step 8: Clone, Configure, Boot

Right-click VM 9000 then Clone. Set mode to Full Clone, assign an ID and name, target ceph_pool for storage.

Not using Ceph? Choose the local or shared storage configured for your environment instead. The storage target governs where the full clone is created and whether it can be live-migrated or made HA-eligible.

Step 8 - Clone the template

On the new VM, open the Cloud-Init tab and fill in your user, SSH public key, and IP config. Then hit Regenerate Image before starting.

Step 8.1 - Fill out Cloud-Init tab

One thing I ran into: I initially tried username and password only, no SSH key. Ubuntu cloud images disable SSH password authentication by default, so even with a valid password, SSH will reject the connection. Always use an SSH key. On Windows, generate one in PowerShell:

ssh-keygen -t ed25519 -C "you@example.com"
cat C:UsersYourName.sshid_ed25519.pubCode language: CSS (css)

Paste the .pub output into the Cloud-Init SSH key field, regenerate, and you’re good.

First boot takes about 30 seconds. Once the guest agent checks in, the IP appears on the Summary tab and you can SSH straight in.

To use the Proxmox web console as well, SSH into the new Ubuntu VM and enable its serial login service:

sudo systemctl enable --now serial-getty@ttyS0.serviceCode language: CSS (css)

Return to the Proxmox Console tab, click inside it, and press Enter for the login prompt. This command runs in Ubuntu, not on the Proxmox host. The Day Two user-data snippet option can automate this for future clones.

Step 8.2 - Successful login to Ubuntu 26.04 VM

Important: Everything you configured in the Cloud-Init tab — username, SSH key, IP address — was a one-time event. Cloud-Init has stamped its flag and it’s done. If you update those fields in the Proxmox UI and click Regenerate Image on a running VM, nothing will change on that VM. Those updates apply to the next clone. If you need a VM with different settings, clone fresh. That’s the workflow.

Day Two Operations

The template is built — now what? Here are the operations you’ll actually use on a regular basis.

  1. Resize the disk after cloning. The template disk is intentionally tiny (~3.5GB). After cloning, go to Hardware then Disk Action then Resize and add what you need (e.g. +30G). Cloud-Init automatically expands the root partition on first boot — no manual resize2fs required.
  2. Destroy and re-clone instead of patching. This is the mindset shift Cloud-Init enables. VM misbehaving or drifted from a known state? Don’t troubleshoot it — destroy it, clone a fresh one, and you’re back in 30 seconds. This is “cattle not pets” in practice.

    This works cleanly when your VM is stateless — a web server, reverse proxy, monitoring agent, or any service where the app is just a binary and config that gets re-deployed from a repo or user-data script. Nothing precious lives on the disk, so nothing is lost.


    If your VM has persistent data — a database, file storage, app state — you’d keep that on a separate attached disk that isn’t part of the Cloud-Init template. The VM disk stays ephemeral (OS + app binaries), and the data disk survives independently when you destroy and recreate the VM. In Proxmox, that’s just a second disk attached to the clone after it’s created.


  3. Use vendor-data snippets for a reusable baseline.

    Proxmox already generates Cloud-Init user-data from the username, SSH key, password, DNS, and IP settings in the Cloud-Init tab. A custom user= file replaces that generated data. For a baseline that should run alongside the settings from the UI, attach the snippet as vendor= instead.

    Choose where the snippet lives. Local storage is fine for a single-node system or a VM pinned to one node. If clones can migrate or use HA, put the snippet on shared storage that every node can reach. This cluster uses the shared TrueNAS NFS storage named truenas_vm.

    First enable the Snippets content type: Datacenter → Storage → select truenas_vm → Edit → Content → Snippets. Keep the existing content types selected when you add it.

    Open a shell on any healthy Proxmox node and confirm that the shared storage is active:

    pvesm status --storage truenas_vm

    Create the snippets directory and the vendor-data file:

    mkdir -p /mnt/pve/truenas_vm/snippets
    nano /mnt/pve/truenas_vm/snippets/vendor-data.yaml

    Start with this baseline:

    #cloud-config
    package_update: true
    package_upgrade: true
    package_reboot_if_required: true
    runcmd:
      - systemctl enable --now qemu-guest-agent
      - systemctl enable --now serial-getty@ttyS0.service

    Save the file, then make sure Proxmox sees it:

    pvesm list truenas_vm --content snippets

    The output should include truenas_vm:snippets/vendor-data.yaml.

    Check before attaching it. The next command assumes the template does not already have a custom Cloud-Init file. Check VM 9000 first:

    qm config 9000 | grep -E '^(name|template|cicustom):'

    If a cicustom: line already exists, preserve every value on that line when you add vendor=. Running qm set --cicustom with only the new value replaces the entire property.

    With no existing cicustom value, attach the shared vendor-data file and verify the result:

    qm set 9000 --cicustom "vendor=truenas_vm:snippets/vendor-data.yaml"
    qm config 9000 | grep '^cicustom:'

    The result should be:

    cicustom: vendor=truenas_vm:snippets/vendor-data.yaml

    For a single-node installation using the default local storage, place the YAML in /var/lib/vz/snippets/ and use vendor=local:snippets/vendor-data.yaml.

    Test with a fresh clone. Cloud-Init runs per instance, so an existing VM will not test a changed snippet. Clone the template, fill in the username, SSH key, and network settings in the Cloud-Init tab, regenerate the image, and boot it. Package updates can make this first boot take several minutes, and package_reboot_if_required: true may reboot the VM once.

    After the clone is reachable, check Cloud-Init and the two services:

    cloud-init status --wait
    cloud-init status --long
    systemctl is-enabled qemu-guest-agent serial-getty@ttyS0.service
    systemctl is-active qemu-guest-agent serial-getty@ttyS0.service

    The Proxmox username and SSH key should still work because the snippet is vendor-data rather than replacement user-data. The web console should also present a login prompt without running the Step 8 serial console command by hand.

    Once the baseline works, you can add packages, timezone settings, firewall rules, Docker, monitoring agents, or other role-specific setup. Keep credentials, API tokens, and private keys out of snippets. The Cloud-Init drive is readable from inside the guest.

    After first boot, leave the Cloud-Init pieces in place. Keep the Cloud-Init drive attached and keep the referenced YAML file on shared storage. Cloud-Init records that it completed, so it will not repeat the per-instance setup during a normal reboot. Do not run cloud-init clean or delete /var/lib/cloud unless you deliberately want Cloud-Init to treat the VM as a new instance.

    Treat a snippet that deployed VMs reference as immutable. In this example, vendor-data.yaml is the first version. For the next change, copy it to a new name such as vendor-data-v2.yaml, test that file with a disposable clone, and then update the template. Do not edit or delete the old file while existing VMs still reference it.

  4. Pair it with Ansible to configure apps automatically. Once your Cloud-Init VM is booted and SSH is ready, Ansible can take over and configure whatever you need — no manual steps, no drift. This is where the two tools complement each other perfectly: Cloud-Init handles the OS identity and base setup, Ansible handles the app layer.

    You can run Ansible centrally from an admin workstation after the clone is reachable, or let a clone bootstrap itself on first boot with ansible-pull. For the self-bootstrap pattern, add ansible to the role’s packages list and add a versioned repository command to runcmd:


    runcmd:
      - ansible-pull -U https://github.com/your-org/homelab-ansible.git -i localhost, playbooks/web.yml

    Use a public repository or a properly managed deploy credential; never put an access token or private key in user-data. Here’s a practical example — deploying Nginx onto a freshly cloned VM. Start with an inventory file pointing at your VM’s IP (the one that showed up in the Proxmox Summary tab after first boot):


    # inventory.yml
    all:
    hosts:
    web01:
    ansible_host: 10.0.5.100
    ansible_user: daniel
    ansible_ssh_private_key_file: ~/.ssh/id_ed25519

    Then a playbook to install and configure the app:


    # deploy-nginx.yml
    ---
    - name: Deploy Nginx web server
    hosts: all
    become: true

    vars:
    site_name: my-lab-app

    tasks:
    - name: Install Nginx
    apt:
    name: nginx
    state: present
    update_cache: yes

    - name: Write site config
    copy:
    dest: /etc/nginx/sites-available/{{ site_name }}
    content: |
    server {
    listen 80;
    server_name _;
    root /var/www/{{ site_name }};
    index index.html;
    }
    notify: Reload Nginx

    - name: Enable site
    file:
    src: /etc/nginx/sites-available/{{ site_name }}
    dest: /etc/nginx/sites-enabled/{{ site_name }}
    state: link

    - name: Create web root
    file:
    path: /var/www/{{ site_name }}
    state: directory

    - name: Deploy index page
    copy:
    dest: /var/www/{{ site_name }}/index.html
    content: "<h1>Deployed via Ansible from Cloud-Init template</h1>"

    - name: Ensure Nginx is running
    service:
    name: nginx
    state: started
    enabled: yes

    handlers:
    - name: Reload Nginx
    service:
    name: nginx
    state: reloaded

    Run it with:


    ansible-playbook -i inventory.yml deploy-nginx.yml

    This works out of the box because Cloud-Init already created your user and installed your SSH key — Ansible has everything it needs to connect. Swap Nginx for any app, and the pattern is the same: Cloud-Init provisions the VM, Ansible configures what runs on it.


What’s Next

Template done — Ubuntu 26.04 LTS on Ceph, any node in the cluster can clone from it, every clone is live-migratable and HA-eligible out of the box. The first thing I’m deploying from it is Hermes Agent — and having a clean, repeatable Cloud-Init base to start from makes that setup a whole lot cleaner. I’ll cover that next.

Leave a Comment