Cloud-init on Proxmox: auto-install packages on boot

June 26, 2026 · 8 min read

Cloud-init on Proxmox: auto-install packages on boot

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-config YAML 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: false to 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.

Try NexoVirt

One clean panel for every Proxmox host.