Managing Incus resources with Terraform

Incus, you say?

According to its purveyors, Incus is "next-generation system container, application container, and virtual machine manager." It is a fork of LXD which retains a more open-source friendly license.

Although Incus/LXD are typically associated with Linux containers, I was more interested in testing its virtual machine capabilities, so my resources are a bit more VM-centric.

This article assumes you already have a working Incus cluster.

Configure your Incus client

Before you can use the Terraform provider, you'll need to obtain and configure the Incus client. I'm using a Mac, so I followed the directions for "Other operating systems" found here.

After downloading the client, you can configure it via these instructions.

I'm using my own TLS certificates in my homelab, so there are a few extra steps.

Directory structure

First, let's see what the working directory for my incus client looks like:

$ tree ~/.config/incus
/Users/inflatador/.config/incus
├── client.ca
├── client.crt
├── client.key
├── config.yml
├── oidctokens
└── servercerts
    └── example.crt

3 directories, 5 files

The files are follows:

  • servercerts/example.crt: Found in /var/lib/incus/server.crt on the incus server. If this changes on the incus server and you try to login from your workstation without updating it, you'll get certificate errors.

  • client.crt, client.key: Certificate/private key for mutual TLS authentication. The certificate must be added to the incus cluster's certificate trust, see this page for details.

config.yml

Here's my known-good incus client config file:

default-remote: example
remotes:
  images:
    addr: https://images.linuxcontainers.org
    protocol: simplestreams
    public: true
  local:
    addr: unix://
    protocol: incus
    public: false
  example:
    addr: https://incus.service.rl:443
    auth_type: tls
    project: default
    protocol: incus
    public: false
    keepalive: 90
aliases: {}
defaults:
  list_format: ""
  console_type: ""
  console_spice_command: ""

You can verify that this is working by running any incus command:

 incus remote list

 +------------------+------------------------------------+---------------+-------------+--------+--------+--------+
 |       NAME       |                URL                 |   PROTOCOL    |  AUTH TYPE  | PUBLIC | STATIC | GLOBAL |
 +------------------+------------------------------------+---------------+-------------+--------+--------+--------+
 | images           | https://images.linuxcontainers.org | simplestreams | none        | YES    | NO     | NO     |
 +------------------+------------------------------------+---------------+-------------+--------+--------+--------+
 | local            | unix://                            | incus         | file access | NO     | YES    | NO     |
 +------------------+------------------------------------+---------------+-------------+--------+--------+--------+
 | example (current)| https://incus.service.rl:443       | incus         | tls         | NO     | NO     | NO     |
 +------------------+------------------------------------+---------------+-------------+--------+--------+--------+

Terraform config

Here's the relevant bits from provider.tf:

// You will need the Incus client on the host that runs terraform.
// Ref https://github.com/lxc/incus/blob/main/doc/installing.md
provider "incus" {
  accept_remote_certificate = true
  default_remote            = "example"

  remote {
    name                = "example"
    address             = "https://incus.service.rl:443"
    authentication_type = "tls"
  }
}

And terraform.tf:

terraform {
  required_providers {
    incus = {
      source  = "lxc/incus"
      version = "1.0.1"
    }
  }

Creating virtual machine "flavors" via incus profiles

To get a cloud-like experience in Incus, I've defined an incus profile as follows:

resource "incus_profile" "example2-2" {
  project     = "default"
  name        = "example2-2"
  description = "2 GB RAM, 2 vCPUs"

  config = {
    "boot.autostart" = true
    "limits.cpu"     = 2
    "limits.memory"  = "2GiB"
  }
  device {
    name = "root"
    type = "disk"

    properties = {
      path = "/"
      pool = data.incus_storage_pool.lvm.name
      size = "12GiB"
    }
  }
// all VMs created from this profile get a cloud-init config-drive
  device {
    name = "cidata"
    type = "disk"
    properties = {
      source = "cloud-init:config"
    }

  }
// all VMs created from this profile are attached to the "lab98" VLAN
  device {
    name = var.nic_device
    type = "nic"

    properties = {
      parent  = "lab98"
      nictype = "macvlan"
    }
  }

}

Then we create a VM based on this profile:

resource "incus_instance" "example100" {
  project  = "default"
  profiles = ["example2-2"]
  name     = "example100"
  image    = "images:debian/13/cloud"
  type     = "virtual-machine"
  config = {
    "cloud-init.user-data" = templatefile("${path.module}/../shared/debian-user-data.yml.tpl",
      {
        hostname = "example100"
        fqdn     = "example100.host.rl"
    })
    "cloud-init.network-config" = templatefile("${path.module}/../shared/network-data.yml.tpl",
      { last_octet = "102"
        nic_device = "enp5s0"
    })
  }

}

Note my choice of image: the "cloud" variant, which has cloud-init pre-installed. I'm also using terraform's templatefile function. to template the cloud-init user-data and cloud-init network-config.

debian-user-data.yml.tpl:

#cloud-config
# Debian
hostname: ${hostname} # this will be replaced by the value "example100" above
manage_etc_hosts: True
...

network-data.yml.tpl:

version: 2
ethernets:
  ${nic_device}: # renders as "enp5s0"
    addresses:
      - 172.19.98.${last_octet}/24 # renders as "172.19.98.102"

A few minutes after a terraform apply, the new VM is available:

incus list
+------------+---------+------------------------+------+-----------------+-----------+--------------------+
|    NAME    |  STATE  |          IPV4          | IPV6 |      TYPE       | SNAPSHOTS |      LOCATION      |
+------------+---------+------------------------+------+-----------------+-----------+-
| example100 | RUNNING | 172.19.98.102 (enp5s0) |      | VIRTUAL-MACHINE | 0         | dreamcast.host.rl  |
+------------+---------+------------------------+------+-----------------+-----------+--------------------+

Soon afterwards, cloud-init configures the network and enables my SSH access, so I can login and have a look at the new VM:

ssh 172.19.98.102
Linux example100 6.12.57+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1 (2025-11-05) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sun Dec 28 18:58:34 2025 from [...]
inflatador@example100:~$ hostnamectl
Static hostname: example100
      Icon name: computer-vm
        Chassis: vm 🖴
     Machine ID: 38b857894fb644d9ae3d205ee66e6f63
        Boot ID: 7f9406d7b9a64c5dad387226e86f7e32
   AF_VSOCK CID: 3081916918
 Virtualization: kvm
Operating System: Debian GNU/Linux 13 (trixie)
         Kernel: Linux 6.12.57+deb13-amd64
   Architecture: x86-64
Hardware Vendor: QEMU
 Hardware Model: Standard PC _Q35 + ICH9, 2009_
Firmware Version: unknown
  Firmware Date: Wed 2022-02-02
   Firmware Age: 3y 10month 3w 4d

In Conclusion

Thanks to the Incus devs for a great product! The Terraform integrations make it easy to manage virtual machines in a cloud-like fashion.