Skip to content

VMs and cloud-init

Incus can run full VMs and pass cloud-init data to them. This is excellent for repeatable lab servers.

Important: cloud-init requires a cloud image. For Debian examples, use images:debian/trixie/cloud rather than a plain image such as images:debian/13. The same rule applies to containers and VMs when you expect cloud-init to run.

Official docs:

  • Virtual machines: https://linuxcontainers.org/incus/docs/main/explanation/containers_and_vms/
  • Cloud-init: https://linuxcontainers.org/incus/docs/main/cloud-init/

Launch a VM

incus launch images:debian/trixie/cloud debian-vm --vm

Wait for boot and the VM agent:

incus exec debian-vm -- cloud-init status --wait
incus shell debian-vm

If incus exec reports that the VM agent is not running, wait and retry.

Size a VM

incus launch images:debian/trixie/cloud app-vm --vm \
  -c limits.cpu=2 \
  -c limits.memory=4GiB \
  -d root,size=40GiB

Use cloud-init user data

Create user-data.yaml:

#cloud-config
package_update: true
packages:
  - nginx
users:
  - name: admin
    groups: sudo
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:ALL
runcmd:
  - systemctl enable --now nginx
final_message: "VM is ready after $UPTIME seconds"

Launch with it using a cloud image:

incus launch images:debian/trixie/cloud web-vm --vm \
  -c cloud-init.user-data="$(cat user-data.yaml)"

Wait and verify:

incus exec web-vm -- cloud-init status --wait
incus exec web-vm -- systemctl is-active nginx

Static IP pattern

For managed bridge DHCP static leases:

incus launch images:debian/trixie/cloud app-vm --vm
incus config device override app-vm eth0 ipv4.address=10.10.10.60
incus restart app-vm

This is usually the simplest lab pattern because Incus still manages DHCP and DNS on the bridge.

Use cloud-init user-data and network-config together

For repeatable VMs, keep user configuration and network configuration in separate files:

web-vm/
├── user-data.yaml
└── network.yaml

Example user-data.yaml:

#cloud-config
hostname: web-vm
manage_etc_hosts: true
package_update: true
packages:
  - nginx
  - curl
users:
  - name: admin
    groups:
      - sudo
    shell: /bin/bash
    sudo: ALL=(ALL) NOPASSWD:ALL
    lock_passwd: true
    ssh_authorized_keys:
      - ssh-ed25519 AAAA_REPLACE_WITH_YOUR_PUBLIC_KEY admin@example
write_files:
  - path: /var/www/html/index.html
    permissions: "0644"
    content: |
      hello from Incus cloud-init
runcmd:
  - systemctl enable --now nginx
  - curl -fsS http://127.0.0.1/ || true
final_message: "web-vm cloud-init finished after $UPTIME seconds"

Example network.yaml for a static address inside the guest OS:

version: 2
ethernets:
  enp5s0:
    dhcp4: false
    addresses:
      - 10.10.10.60/24
    routes:
      - to: default
        via: 10.10.10.1
    nameservers:
      addresses:
        - 1.1.1.1
        - 8.8.8.8

Launch the VM with both files using a cloud image:

incus launch images:debian/trixie/cloud web-vm --vm \
  -c cloud-init.user-data="$(cat user-data.yaml)" \
  -c cloud-init.network-config="$(cat network.yaml)" \
  -c limits.cpu=2 \
  -c limits.memory=2GiB \
  -d root,size=30GiB

Wait and verify:

incus exec web-vm -- cloud-init status --wait
incus exec web-vm -- ip addr
incus exec web-vm -- ip route
incus exec web-vm -- systemctl is-active nginx
curl -fsS http://10.10.10.60/

Important notes:

  • The interface name in network.yaml must match the guest OS interface name. Common VM names include enp5s0 or ens3; check with ip link if networking does not come up.
  • Static guest networking is powerful but easier to break than Incus-managed DHCP leases.
  • For many homelab VMs, prefer the Incus DHCP reservation pattern first, then use cloud-init network config when you need OS-level control.
  • Always check that the static IP is free before launching a long-lived VM.

Repeatable template shape

For service labs, keep a small folder per VM template. Set the default image to a cloud image when the template uses cloud-init:

service-vm/
├── README.md
├── run.sh
├── destroy.sh
├── user-data.yaml
├── network.yaml
└── scripts/
    └── install-service.sh

run.sh should:

  • set VM_NAME, IMAGE such as images:debian/trixie/cloud, STATIC_IP, CPU, RAM, and disk defaults;
  • allow environment overrides;
  • inject scripts into cloud-init;
  • create and start the VM.

destroy.sh should delete only the named VM and be safe to rerun.

VM troubleshooting

incus info app-vm
incus console app-vm --show-log
incus exec app-vm -- cloud-init status --long
incus exec app-vm -- journalctl -u cloud-init --no-pager -n 100

Common issues:

  • VM agent not ready yet;
  • cloud-init YAML indentation error;
  • image is not a cloud image or does not include the Incus agent/cloud-init;
  • VM has no network;
  • static IP conflicts with another instance;
  • disk too small for packages.