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
Wait for boot and the VM agent:
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:
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:
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.yamlmust match the guest OS interface name. Common VM names includeenp5s0orens3; check withip linkif 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,IMAGEsuch asimages: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.