Hobyte

Manage Vms With Terraform and Ansible

I’m currently working on a project that relies heavily on virtual machines. While managing them is quite easy with libvirt and virt-manager, setting up multiple VMs with the same configuration is just boring and tedious. And it is hard to make no mistakes and ensure the VMs are configured exactly the same. But as the sky is the limit for computers and programming, there is a solution I wanted to try out for a long time.

I heard about Terraform some years ago, but never really tried it out. So now is the perfect time to try it. And I complemented it with ansible to not only create the VMs, but configure them in the same step to save me some extra time. This will also ensure the VMs are always the same, solving the problem described earlier.

Infrastructure as Code with Terraform

Terraform is a Infrastucture as Code (IaC) tool to automate the deployment of VMs, networks and other infrastructure. The desired state is described in terraform files and terraform will create the resources needed to for this state. So the user only has to describe the desired state and terraform will do the rest.

Tip
You can also use opentofu, which is a fork of opentofu. As a project of the Linux Foundation, it is more community driven and has a more open governance model.

Terraform uses its own domain specific Language (DSL) to describe resources. With this language, providers contain resources that can be configured and managed with terraform. Each resource is a block, that contains all information that is needed to create the resource. For a VM, this is name, disk, image and network connection. Additionally, resources can use other resource, so a VM can be connected to a network created by terraform. This allows easy configuration of multiple resources in one place.

For a detailed introduction, have a look at the official intro.

Libvirt Provider

Note
I will use tofu (opentofu) in the examples, but the commands also work with terraform

Im running all my VMs on my workstation with libvirt and virt-manager as ui. So I wanted terraform to use this existing infrastructure. Luckily, there is a libvirt provider available in the terraform registry. This provider contains all resources available from libvirt, allowing the creation of VMs, networks, volumes, etc. with terraform

Creating a VM

To create a VM, the dmacvicar/libvirt provider needs to be added to the main terraform file. Additionally, it needs to be configured with the uri of the libvirt daemon. In my case, this is qemu:///system. Then a libvirt_domain resource can be created, representing the VM. In my case, I added a cloudinit file to the VM, to configure it at first boot. I also added a network interface and disk for storage and internet access. With this basic configuration, my terraform file looks like this:

terraform {
  required_providers {
    libvirt = { (1)
      source  = "dmacvicar/libvirt"
      version = "0.8.3"
    }
  }
}

locals {
  private_key_file = "${path.module}/keys/ansible"
  public_key_file  = "${local.private_key_file}.pub"
  private_key      = file(local.private_key_file)
  public_key       = file(local.public_key_file)
}

provider "libvirt" { (2)
  uri = "qemu:///system"
}

data "template_file" "cloudinit_file" { (3)
  template = file("${path.module}/cloud-init.yaml")
  vars = {
    "public_key" = local.public_key
  }
}

resource "libvirt_cloudinit_disk" "cloudinit_disk" {
  name      = "commoninit.iso"
  user_data = data.template_file.cloudinit_file.rendered
}

resource "libvirt_volume" "ubuntu-qcow2" { (4)
  name   = "ubuntu-qcow2"
  pool   = "default"
  source = "https://cloud-images.ubuntu.com/releases/plucky/release/ubuntu-25.04-server-cloudimg-amd64.img"
  format = "qcow2"
}

resource "libvirt_domain" "domain-ubuntu" { (5)
  name   = "ubuntu-server"
  memory = 512
  vcpu   = 1

  cloudinit = libvirt_cloudinit_disk.cloudinit_disk.id

  network_interface {
    network_name   = "default"
    wait_for_lease = true
  }

  disk {
    volume_id = libvirt_volume.ubuntu-qcow2.id
  }
}
  1. add libvirt provider

  2. configure the connection url

  3. cloud init file definition

  4. disk for the VM

  5. the VM resource tying it all together

To check if this file is without errors and see what changes terraform will make, the plan subcommand can be used. This will print a plan with all changes from the current configuration:

-> % tofu plan
data.template_file.cloudinit_file: Reading...
data.template_file.cloudinit_file: Read complete after 0s [id=55915264c5c1dcb323f42cda8c348c4cc6c021f49402f57ca3db183383a3d274]

OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated
with the following symbols:
  + create

OpenTofu will perform the following actions:

  # libvirt_cloudinit_disk.cloudinit_disk will be created
  + resource "libvirt_cloudinit_disk" "cloudinit_disk" {
      + id        = (known after apply)
      + name      = "commoninit.iso"
      + pool      = "default"
      + user_data = <<-EOT
            #cloud-config
            ssh_pwauth: false
            keyboard:
              layout: de
            users:
              - name: ansible
                sudo: ALL=(ALL) NOPASSWD:ALL
                groups: users, admin
                home: /home/ansible
                shell: /bin/bash
                ssh_authorized_keys:
                  - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC4e2IW4x9d4gqga+afgUUEFD6js+uoN91Y7l80/hGum simon@edora

            network:
              version: 2
              ethernets:
                ens3:
                  dhcp4: true
        EOT
    }

  # libvirt_domain.domain-ubuntu will be created
  + resource "libvirt_domain" "domain-ubuntu" {
      + arch        = (known after apply)
      + autostart   = (known after apply)
      + cloudinit   = (known after apply)
      + emulator    = (known after apply)
      + fw_cfg_name = "opt/com.coreos/config"
      + id          = (known after apply)
      + machine     = (known after apply)
      + memory      = 512
      + name        = "ubuntu-server"
      + qemu_agent  = false
      + running     = true
      + type        = "kvm"
      + vcpu        = 1

      + cpu (known after apply)

      + disk {
          + scsi      = false
          + volume_id = (known after apply)
          + wwn       = (known after apply)
        }

      + graphics {
          + autoport       = true
          + listen_address = "127.0.0.1"
          + listen_type    = "address"
          + type           = "spice"
        }

      + network_interface {
          + addresses      = (known after apply)
          + hostname       = (known after apply)
          + mac            = (known after apply)
          + network_id     = (known after apply)
          + network_name   = "default"
          + wait_for_lease = true
        }

      + nvram (known after apply)
    }

  # libvirt_volume.ubuntu-qcow2 will be created
  + resource "libvirt_volume" "ubuntu-qcow2" {
      + format = "qcow2"
      + id     = (known after apply)
      + name   = "ubuntu-qcow2"
      + pool   = "default"
      + size   = (known after apply)
      + source = "https://cloud-images.ubuntu.com/releases/plucky/release/ubuntu-25.04-server-cloudimg-amd64.img"
    }

Plan: 3 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + ip_address = (known after apply)

This wont make any changes yet, but allows a review of all changes before applying them. This is especially handy when updating infrastructure, as it shows which resources are changed or replaced. If these changes are okay, they can be applied with apply:

-> % tofu apply
data.template_file.cloudinit_file: Reading...
data.template_file.cloudinit_file: Read complete after 0s [id=55915264c5c1dcb323f42cda8c348c4cc6c021f49402f57ca3db183383a3d274]

OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated
with the following symbols:
  + create

OpenTofu will perform the following actions:

  # libvirt_cloudinit_disk.cloudinit_disk will be created
  + resource "libvirt_cloudinit_disk" "cloudinit_disk" {
      + id        = (known after apply)
      + name      = "commoninit.iso"
      + pool      = "default"
      + user_data = <<-EOT
            #cloud-config
            ssh_pwauth: false
            keyboard:
              layout: de
            users:
              - name: ansible
                sudo: ALL=(ALL) NOPASSWD:ALL
                groups: users, admin
                home: /home/ansible
                shell: /bin/bash
                ssh_authorized_keys:
                  - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC4e2IW4x9d4gqga+afgUUEFD6js+uoN91Y7l80/hGum simon@edora

            network:
              version: 2
              ethernets:
                ens3:
                  dhcp4: true
        EOT
    }

  # libvirt_domain.domain-ubuntu will be created
  + resource "libvirt_domain" "domain-ubuntu" {
      + arch        = (known after apply)
      + autostart   = (known after apply)
      + cloudinit   = (known after apply)
      + emulator    = (known after apply)
      + fw_cfg_name = "opt/com.coreos/config"
      + id          = (known after apply)
      + machine     = (known after apply)
      + memory      = 512
      + name        = "ubuntu-server"
      + qemu_agent  = false
      + running     = true
      + type        = "kvm"
      + vcpu        = 1

      + cpu (known after apply)

      + disk {
          + scsi      = false
          + volume_id = (known after apply)
          + wwn       = (known after apply)
        }

      + graphics {
          + autoport       = true
          + listen_address = "127.0.0.1"
          + listen_type    = "address"
          + type           = "spice"
        }

      + network_interface {
          + addresses      = (known after apply)
          + hostname       = (known after apply)
          + mac            = (known after apply)
          + network_id     = (known after apply)
          + network_name   = "default"
          + wait_for_lease = true
        }

      + nvram (known after apply)
    }

  # libvirt_volume.ubuntu-qcow2 will be created
  + resource "libvirt_volume" "ubuntu-qcow2" {
      + format = "qcow2"
      + id     = (known after apply)
      + name   = "ubuntu-qcow2"
      + pool   = "default"
      + size   = (known after apply)
      + source = "https://cloud-images.ubuntu.com/releases/plucky/release/ubuntu-25.04-server-cloudimg-amd64.img"
    }

Plan: 3 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + ip_address = (known after apply)

Do you want to perform these actions?
  OpenTofu will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

libvirt_volume.ubuntu-qcow2: Creating...
libvirt_cloudinit_disk.cloudinit_disk: Creating...
libvirt_cloudinit_disk.cloudinit_disk: Still creating... [10s elapsed]
libvirt_volume.ubuntu-qcow2: Still creating... [10s elapsed]
libvirt_cloudinit_disk.cloudinit_disk: Still creating... [20s elapsed]
libvirt_volume.ubuntu-qcow2: Still creating... [20s elapsed]
libvirt_volume.ubuntu-qcow2: Still creating... [30s elapsed]
libvirt_cloudinit_disk.cloudinit_disk: Still creating... [30s elapsed]
libvirt_volume.ubuntu-qcow2: Still creating... [40s elapsed]
libvirt_cloudinit_disk.cloudinit_disk: Still creating... [40s elapsed]
libvirt_cloudinit_disk.cloudinit_disk: Still creating... [50s elapsed]
libvirt_volume.ubuntu-qcow2: Still creating... [50s elapsed]
libvirt_volume.ubuntu-qcow2: Creation complete after 53s [id=/var/lib/libvirt/disk/ubuntu-qcow2]
libvirt_cloudinit_disk.cloudinit_disk: Creation complete after 53s [id=/var/lib/libvirt/disk/commoninit.iso;6e2c0646-489a-4510-98c3-1f69af4446e2]
libvirt_domain.domain-ubuntu: Creating...
libvirt_domain.domain-ubuntu: Still creating... [10s elapsed]
libvirt_domain.domain-ubuntu: Creation complete after 14s [id=b3007206-6070-4b8e-80db-8c460aafe0fa]

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

ip_address = "192.168.122.247"

This, once again, prints the changes that will be made and then creates and configures the resources as defined in the terraform file.

Cleaning up

When there is no need for the VM any more or you want to remove it for a different reason, use the destroy command. Terraform will then remove all resources defined in the file, after asking for permission:

-> % tofu destroy
data.template_file.cloudinit_file: Reading...
data.template_file.cloudinit_file: Read complete after 0s [id=55915264c5c1dcb323f42cda8c348c4cc6c021f49402f57ca3db183383a3d274]
libvirt_cloudinit_disk.cloudinit_disk: Refreshing state... [id=/var/lib/libvirt/disk/commoninit.iso;6e2c0646-489a-4510-98c3-1f69af4446e2]
libvirt_volume.ubuntu-qcow2: Refreshing state... [id=/var/lib/libvirt/disk/ubuntu-qcow2]
libvirt_domain.domain-ubuntu: Refreshing state... [id=b3007206-6070-4b8e-80db-8c460aafe0fa]

OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated
with the following symbols:
  - destroy

OpenTofu will perform the following actions:

  # libvirt_cloudinit_disk.cloudinit_disk will be destroyed
  - resource "libvirt_cloudinit_disk" "cloudinit_disk" {
      - id        = "/var/lib/libvirt/disk/commoninit.iso;6e2c0646-489a-4510-98c3-1f69af4446e2" -> null
      - name      = "commoninit.iso" -> null
      - pool      = "default" -> null
      - user_data = <<-EOT
            #cloud-config
            ssh_pwauth: false
            keyboard:
              layout: de
            users:
              - name: ansible
                sudo: ALL=(ALL) NOPASSWD:ALL
                groups: users, admin
                home: /home/ansible
                shell: /bin/bash
                ssh_authorized_keys:
                  - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC4e2IW4x9d4gqga+afgUUEFD6js+uoN91Y7l80/hGum simon@edora

            network:
              version: 2
              ethernets:
                ens3:
                  dhcp4: true
        EOT -> null
    }

  # libvirt_domain.domain-ubuntu will be destroyed
  - resource "libvirt_domain" "domain-ubuntu" {
      - arch        = "x86_64" -> null
      - autostart   = false -> null
      - cloudinit   = "/var/lib/libvirt/disk/commoninit.iso;6e2c0646-489a-4510-98c3-1f69af4446e2" -> null
      - cmdline     = [] -> null
      - emulator    = "/usr/bin/qemu-system-x86_64" -> null
      - fw_cfg_name = "opt/com.coreos/config" -> null
      - id          = "b3007206-6070-4b8e-80db-8c460aafe0fa" -> null
      - machine     = "pc" -> null
      - memory      = 512 -> null
      - name        = "ubuntu-server" -> null
      - qemu_agent  = false -> null
      - running     = true -> null
      - type        = "kvm" -> null
      - vcpu        = 1 -> null

      - cpu {
          - mode = "custom" -> null
        }

      - disk {
          - scsi      = false -> null
          - volume_id = "/var/lib/libvirt/disk/ubuntu-qcow2" -> null
        }

      - graphics {
          - autoport       = true -> null
          - listen_address = "127.0.0.1" -> null
          - listen_type    = "address" -> null
          - type           = "spice" -> null
          - websocket      = 0 -> null
        }

      - network_interface {
          - addresses      = [
              - "192.168.122.247",
            ] -> null
          - hostname       = "ubuntu-server" -> null
          - mac            = "52:54:00:02:61:B4" -> null
          - network_id     = "3fef1d81-529a-4637-a293-d0cc2df892eb" -> null
          - network_name   = "default" -> null
          - wait_for_lease = true -> null
        }
    }

  # libvirt_volume.ubuntu-qcow2 will be destroyed
  - resource "libvirt_volume" "ubuntu-qcow2" {
      - format = "qcow2" -> null
      - id     = "/var/lib/libvirt/disk/ubuntu-qcow2" -> null
      - name   = "ubuntu-qcow2" -> null
      - pool   = "default" -> null
      - size   = 3758096384 -> null
      - source = "https://cloud-images.ubuntu.com/releases/plucky/release/ubuntu-25.04-server-cloudimg-amd64.img" -> null
    }

Plan: 0 to add, 0 to change, 3 to destroy.

Changes to Outputs:
  - ip_address = "192.168.122.247" -> null

Do you really want to destroy all resources?
  OpenTofu will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

libvirt_domain.domain-ubuntu: Destroying... [id=b3007206-6070-4b8e-80db-8c460aafe0fa]
libvirt_domain.domain-ubuntu: Destruction complete after 0s
libvirt_cloudinit_disk.cloudinit_disk: Destroying... [id=/var/lib/libvirt/disk/commoninit.iso;6e2c0646-489a-4510-98c3-1f69af4446e2]
libvirt_volume.ubuntu-qcow2: Destroying... [id=/var/lib/libvirt/disk/ubuntu-qcow2]
libvirt_cloudinit_disk.cloudinit_disk: Destruction complete after 0s
libvirt_volume.ubuntu-qcow2: Destruction complete after 0s

Destroy complete! Resources: 3 destroyed.

Going further with Ansible

Terraform can only create the resources specified, but further configuring them needs an additional tool. For in the example above, cloud init is used to make some basic configuration on the VM. But if you need to go further than that, cloud init might not be enough and a tool with more capabilities might be needed. This is were Ansible steps in and provides configuration with a similar concept as terraform. While terraform works on the resource level and creates physical or virtual resources, Ansible is a Configuration as Code Tool. Within a Ansible file, called playbook, tasks can be defined to do something on the target. This can be running a shell command or installing a tool. These tasks describe the target state that should be reached. And whenever Ansible is run, it will try to reach this state.

Example Playbook

As an example, I created this playbook that adds a user to the VM and configures the public SSH key to login with this user. Additionally, this playbook installs apache httpd and starts the service.

- become: true
  hosts: all
  name: apache-install
  tasks:
    - name: Add the user 'sammy' and add it to 'sudo'
      user:
        name: sammy
        group: sudo
    - name: Add SSH key to 'sammy'
      authorized_key:
        user: sammy
        state: present
        key: "{{ lookup('file', public_key) }}"
    - name: Wait for apt to unlock
      become: true
      shell:  while sudo fuser /var/lib/dpkg/lock >/dev/null 2>&1; do sleep 5; done;
    - name: Install apache2
      apt:
        name: apache2
        update_cache: yes
        state: latest
    - name: Enable mod_rewrite
      apache2_module:
        name: rewrite
        state: present
      notify:
        - Restart apache2
  handlers:
    - name: Restart apache2
      service:
        name: apache2
        state: restarted

Run from Terraform

We can run this playbook by hand, but when creating a VM with terraform, it would be nice if the Ansible playbook is automatically executed on the new target. This is one step less to do then running terraform and ansible separate, meaning one less option to make errors or forget something.

To do this, the ansible/ansible provider can be user. It contains a ansible_playbook resource that can be used to run a playbook. It takes the ip or name of the target resource as name, a playbook to run on this target and some extra variables that will be passed to ansible. When running tofu apply, this resource will run the playbook on the defined target automatically. So to run the playbook above, the terraform example can be extended like this:

terraform {
  required_providers {
    libvirt = {
      source  = "dmacvicar/libvirt"
      version = "0.8.3"
    }
    ansible = {
      source = "ansible/ansible"
      version = "1.3.0"
    }
  }
}

resource "ansible_playbook" "apache-install" {
  name = "${libvirt_domain.domain-ubuntu.network_interface[0].addresses[0]}"
  playbook = "apache-install.yml"
  extra_vars = {
    public_key = local.public_key_file
    ansible_user = "ansible"
    ansible_ssh_host_key_checking = false
    ansible_ssh_private_key_file = local.private_key_file
  }
}

The extra vars are needed in this case, as cloud-init creates an ansible user to connect with and adds SSH keys for this user. So the user name and keys need to be set for ansible. Additionally, SSH host checking is disabled, so ansible connects to new hosts too. With this configuration, ansible will run after the VM is provisioned and will configure it according to the playbook:

-> % tofu apply
data.template_file.cloudinit_file: Reading...
data.template_file.cloudinit_file: Read complete after 0s [id=55915264c5c1dcb323f42cda8c348c4cc6c021f49402f57ca3db183383a3d274]

OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated
with the following symbols:
  + create

OpenTofu will perform the following actions:

  # ansible_playbook.apache-install will be created
  # libvirt_cloudinit_disk.cloudinit_disk will be created
  # libvirt_domain.domain-ubuntu will be created
  # libvirt_volume.ubuntu-qcow2 will be created

Plan: 4 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + ip_address = (known after apply)

Do you want to perform these actions?
  OpenTofu will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

libvirt_cloudinit_disk.cloudinit_disk: Creating...
libvirt_volume.ubuntu-qcow2: Creating...
libvirt_volume.ubuntu-qcow2: Still creating... [10s elapsed]
libvirt_cloudinit_disk.cloudinit_disk: Still creating... [10s elapsed]
libvirt_cloudinit_disk.cloudinit_disk: Still creating... [20s elapsed]
libvirt_volume.ubuntu-qcow2: Still creating... [20s elapsed]
libvirt_volume.ubuntu-qcow2: Still creating... [30s elapsed]
libvirt_cloudinit_disk.cloudinit_disk: Still creating... [30s elapsed]
libvirt_cloudinit_disk.cloudinit_disk: Still creating... [40s elapsed]
libvirt_volume.ubuntu-qcow2: Still creating... [40s elapsed]
libvirt_volume.ubuntu-qcow2: Creation complete after 47s [id=/var/lib/libvirt/disk/ubuntu-qcow2]
libvirt_cloudinit_disk.cloudinit_disk: Creation complete after 47s [id=/var/lib/libvirt/disk/commoninit.iso;b6278e9c-98b3-4926-bb23-5573f1d168f2]
libvirt_domain.domain-ubuntu: Creating...
libvirt_domain.domain-ubuntu: Still creating... [10s elapsed]
libvirt_domain.domain-ubuntu: Still creating... [20s elapsed]
libvirt_domain.domain-ubuntu: Creation complete after 24s [id=4b47dfc9-1775-4638-8fc4-aada88b350ee]
ansible_playbook.apache-install: Creating...
ansible_playbook.apache-install: Still creating... [10s elapsed]
ansible_playbook.apache-install: Still creating... [20s elapsed]
ansible_playbook.apache-install: Still creating... [30s elapsed]
ansible_playbook.apache-install: Creation complete after 32s [id=2025-11-06 21:50:37.252688147 +0100 CET m=+71.465202337]

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

ip_address = "192.168.122.182"

After applying, the VM is fully configured with the apache webserver running. Additionally, it is possible to use the user sammy to connect to the VM via ssh:

-> % ssh -i keys/ansible sammy@192.168.122.182
Welcome to Ubuntu 25.04 (GNU/Linux 6.14.0-35-generic x86_64)
$

Summary

Terraform allows the creation of resources in a controlled and easy way. But terraform only creates the resources. To configure them, Ansible can be used. And with the ansible provider for terraform, both tools can be used together, enabling automatic deployments with configuration of different resources.

The full code for these examples is available on gitlab.