Cloud-init on Proxmox: auto-install packages on boot
June 26, 2026 · 8 min read
A fresh cloud image, Ubuntu, Debian, Rocky, boots up generic: no users, no SSH keys, no hostname, a DHCP NIC and nothing installed beyond the base system. Cloud-init is the mechanism that turns that generic image into your server on the very first boot: it creates the login user, drops in your SSH key, sets the network, installs packages and runs commands, all before you ever log in. This post explains how cloud-init works, how Proxmox VE wires it up, and gives real, copy-paste examples that install packages and run commands.
What cloud-init actually is
Cloud-init is the de-facto standard for early initialisation of cloud instances. It ships inside
virtually every official cloud image. On first boot it looks for a data source,
a small configuration payload attached to the VM, reads it, and applies it in stages while the
system comes up. Crucially, the heavy lifting runs once: cloud-init records that
it has run (under /var/lib/cloud/) and skips the one-time modules on subsequent boots.
The payload is split into a few well-known documents:
- meta-data: identity: instance ID, hostname.
- user-data: the main config you write: users, packages, commands, files. Usually a
#cloud-configYAML document. - network-config: interfaces, addresses, gateways, DNS.
- vendor-data: config supplied by the platform/host (Proxmox), merged with your user-data.
How Proxmox delivers cloud-init
Proxmox VE implements the NoCloud data source: it builds a tiny config drive (an extra CD-ROM disk) and attaches it to the VM. Cloud-init inside the guest finds that drive and applies it. You don't assemble the drive by hand. Proxmox generates it from the VM's options.
The typical workflow is: download a cloud image, import it as the VM's disk, add a cloud-init
drive, set the cloud-init fields, then boot. With qm on the host it looks like this:
# 1. Create a VM shell
qm create 9000 --name ubuntu-cloud --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0
# 2. Import the downloaded cloud image as the VM's disk
qm set 9000 --scsi0 local-lvm:0,import-from=/var/lib/vz/template/iso/noble-server-cloudimg-amd64.img
qm set 9000 --scsihw virtio-scsi-pci --boot order=scsi0
# 3. Add the cloud-init drive
qm set 9000 --ide2 local-lvm:cloudinit
# 4. Set the cloud-init fields
qm set 9000 --ciuser admin --cipassword 'changeme' \
--sshkeys ~/.ssh/id_ed25519.pub \
--ipconfig0 ip=dhcp
# 5. (optional) grow the disk and boot
qm resize 9000 scsi0 +18G
qm start 9000
Those --ciuser, --sshkeys and --ipconfig0 options become
cloud-init user-data and network-config behind the scenes. That covers the basics: a login user,
a key and an IP. The interesting part is going beyond that: installing software and running setup.
Installing packages on first boot
Package installation lives in user-data, in a #cloud-config document. The relevant
keys are package_update, package_upgrade and packages:
#cloud-config
package_update: true
package_upgrade: true
packages:
- nginx
- git
- htop
- qemu-guest-agent
On first boot cloud-init refreshes the package index, upgrades existing packages, then installs
the listed ones using the distro's native package manager (apt on Debian/Ubuntu, dnf on
Rocky/Alma/Fedora). You don't specify which; cloud-init detects it. Installing
qemu-guest-agent here is a common trick: it lets Proxmox read the VM's IP and do
clean shutdowns.
Running commands: runcmd and bootcmd
For anything package lists can't express, use runcmd (runs late in boot, once) or
bootcmd (runs very early, on every boot). runcmd is what you want for
setup steps:
#cloud-config
runcmd:
- systemctl enable --now nginx
- mkdir -p /var/www/app
- [ sh, -c, "echo 'deploy ok' > /var/www/html/index.html" ]
Each item is either a string (run via the shell) or a list (run directly, no shell, safer for arguments with spaces or quotes). Commands run as root.
Writing files
write_files drops configuration onto disk before runcmd runs, so you can
write a config and then reload the service that uses it:
#cloud-config
write_files:
- path: /etc/nginx/sites-available/app.conf
permissions: '0644'
content: |
server {
listen 80;
root /var/www/app;
index index.html;
}
runcmd:
- ln -sf /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/app.conf
- nginx -t && systemctl reload nginx
A complete, realistic example
Putting it together: a VM that comes up as a hardened web host with a deploy user, a key, the guest agent, nginx installed and configured, and root SSH login disabled:
#cloud-config
hostname: web-01
manage_etc_hosts: true
users:
- name: deploy
groups: [sudo]
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... you@laptop
ssh_pwauth: false
disable_root: true
package_update: true
packages:
- nginx
- qemu-guest-agent
write_files:
- path: /var/www/html/index.html
content: "<h1>web-01 is live</h1>\n"
runcmd:
- systemctl enable --now qemu-guest-agent
- systemctl enable --now nginx
The vendor-data angle (and Proxmox snippets)
There are two places config can come from: user-data (per-VM, what you write) and
vendor-data (supplied by the platform). Proxmox lets you attach a custom snippet
as vendor-data via the cicustom option, pointing at a file on a snippet-enabled
storage:
qm set 9000 --cicustom "vendor=local:snippets/vendor.yaml"
This is how you apply a baseline to every VM (install the guest agent, set a timezone,
add a monitoring agent) without repeating it in each VM's user-data. NexoVirt uses exactly this
mechanism: it delivers a qemu-guest-agent vendor snippet so provisioned VMs report
their IP and shut down cleanly, while leaving your per-VM cloud-init credentials intact.
Gotchas worth knowing
- It runs once. Editing the cloud-init fields after a VM has already booted
won't re-apply them. Cloud-init has marked itself done. To force a clean re-run you must
clean its state (
cloud-init clean) or, more reliably, reinstall the VM. - YAML is strict. A single bad indent silently disables a whole section. Keep two-space indentation and validate before you ship a snippet.
- The image must include cloud-init. Official "cloud" images do; a plain ISO install does not. Always start from the distro's cloud image.
- Passwords vs keys. Prefer SSH keys; set
ssh_pwauth: falseto disable password login once a key is in place.
How NexoVirt handles it
Doing this by hand for every VM: download the right cloud image, import it, attach a cloud-init drive, write user-data, deliver a vendor snippet, resize and boot, is a lot of repeated, fragile steps. NexoVirt wraps the whole flow: you pick an OS template and a plan, set a password or paste an SSH key, and the panel provisions the VM from the cloud image with cloud-init configured, delivers the guest-agent snippet, resizes the disk and starts it. The OS template catalog is the list of images it can deploy this way. You get cloud-init's automation without hand-assembling any of it.