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 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

Then inject the agent:
virt-customize -a resolute-server-cloudimg-amd64.img --install qemu-guest-agentCode language: CSS (css)

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)

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-lvmor 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 returntruenas_vm:9000/vm-9000-disk-0.raw, rather than the Ceph-styleceph_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)

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_poolin the command above. Attach the exact volume ID printed byqm importdisk. For example:qm set 9000 --scsihw virtio-scsi-pci --scsi0 truenas_vm:9000/vm-9000-disk-0.raw.

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_poolhere 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.

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)

--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

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 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.

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.

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.

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.
- 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
resize2fsrequired. - 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.
- 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 asvendor=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_vmCreate the snippets directory and the vendor-data file:
mkdir -p /mnt/pve/truenas_vm/snippets nano /mnt/pve/truenas_vm/snippets/vendor-data.yamlStart 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.serviceSave the file, then make sure Proxmox sees it:
pvesm list truenas_vm --content snippetsThe 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 addvendor=. Runningqm set --cicustomwith only the new value replaces the entire property.With no existing
cicustomvalue, 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.yamlFor a single-node installation using the default
localstorage, place the YAML in/var/lib/vz/snippets/and usevendor=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: truemay 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.serviceThe 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 cleanor delete/var/lib/cloudunless 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.yamlis the first version. For the next change, copy it to a new name such asvendor-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. - 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, addansibleto the role’spackageslist and add a versioned repository command toruncmd:runcmd: - ansible-pull -U https://github.com/your-org/homelab-ansible.git -i localhost, playbooks/web.ymlUse 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_ed25519Then 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: reloadedRun it with:
ansible-playbook -i inventory.yml deploy-nginx.ymlThis 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.