Provisionar uma VM via Terraform é declarar, como código, os mesmos componentes que você criaria na interface gráfica do hypervisor: disco, CPU, memória, rede, usuário e imagem do sistema operacional. A diferença é que, em vez de uma ISO de instalação, usamos uma imagem de nuvem (template qcow2) pronta, personalizada no primeiro boot pelo cloud-init — e todo o ciclo de vida (criar, alterar, destruir) fica versionado e repetível.
Ao final deste tutorial você terá uma VM Debian 13 (2 vCPU, 2 GiB RAM, 16 GiB de disco) rodando no KVM, acessível por SSH com sua chave pública, criada e destruída com um comando.
Código completo: os arquivos deste tutorial estão na pasta
parte-1-vm-unicado repositório da série no GitLab — clone e ajuste os valores em vez de copiar trecho a trecho.
Recursos que o Terraform vai criar:
| Recurso | Tipo no provider | Função |
|---|---|---|
common_init |
libvirt_cloudinit_disk |
Gera a ISO de cloud-init (user-data + meta-data) |
common_init_volume |
libvirt_volume |
Copia a ISO para o pool de storage do Libvirt |
os_volume |
libvirt_volume |
Disco da VM (16 GiB) usando a template como backing store |
domain |
libvirt_domain |
A máquina virtual em si |
~/.ssh/kvm e ~/.ssh/kvm.pub) — se não tiver: ssh-keygen -t ed25519 -f ~/.ssh/kvm;templates apontando para /datastore/templates/ (onde ficam as imagens-base) e o pool default ativo.Verifique a versão do hypervisor:
virsh version --daemon
Compiled against library: libvirt 10.0.0
Using library: libvirt 10.0.0
Using API: QEMU 10.0.0
Running hypervisor: QEMU 8.2.2
Running against daemon: 10.0.0
A sintaxe do provider
dmacvicar/libvirtmudou da versão 0.8 para a 0.9 — blocos que antes eram atributos (disk { ... },network_interface { ... }) viraram objetos HCL aninhados. Exemplos antigos da internet não funcionam sem adaptação. Este tutorial já está na sintaxe 0.9.
# provider.tf
terraform {
required_version = "~> 1.14"
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
version = "~> 0.9"
}
}
}
provider "libvirt" {
uri = "qemu:///system"
}
Usamos a imagem oficial de nuvem do Debian 13 (trixie), distribuída no site do projeto (ou construa a sua com Packer: Template Debian 13) — imagens de nuvem já vêm com cloud-init, virtio e crescimento automático de disco:
sudo wget -c https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2 -P /datastore/templates/
Confirme que o Libvirt enxerga o arquivo no pool e inspecione a imagem:
sudo virsh pool-refresh templates && sudo virsh vol-list templates
qemu-img info /datastore/templates/debian-13-generic-amd64.qcow2
image: debian-13-generic-amd64.qcow2
file format: qcow2
virtual size: 3 GiB (3221225472 bytes)
disk size: 414 MiB
O disco virtual da template tem apenas 3 GiB — no próximo passo criamos um volume de 16 GiB com ela como backing store, e o cloud-init expande a partição no primeiro boot (veja a seção 7.3).
# volumes.tf
resource "libvirt_volume" "os_volume" {
name = "vm-tf.qcow2"
pool = "default"
capacity = 16 * 1024 * 1024 * 1024 # 16 GiB em bytes
target = {
format = {
type = "qcow2"
}
}
backing_store = {
path = "/datastore/templates/debian-13-generic-amd64.qcow2"
format = {
type = "qcow2"
}
}
}
O backing_store torna o volume uma camada copy-on-write sobre a template: a VM grava só as diferenças, e a imagem-base permanece intacta para as próximas VMs.
Dois arquivos: o user-data.yaml (o que o cloud-init executa no primeiro boot) e o cloudinit.tf (que empacota tudo numa ISO e a envia ao pool do Libvirt).
# user-data.yaml
#cloud-config
manage_etc_hosts: true
users:
- name: gean
gecos: "Gean Martins"
sudo: "ALL=(ALL) NOPASSWD:ALL"
groups: users, sudo
shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- ${ssh_key}
# cloudinit.tf
resource "libvirt_cloudinit_disk" "common_init" {
name = "common_init.iso"
user_data = templatefile("${path.module}/user-data.yaml", {
ssh_key = trimspace(file(pathexpand("~/.ssh/kvm.pub")))
})
meta_data = yamlencode({
instance-id = "vm-tf"
local-hostname = "vm-tf"
})
}
resource "libvirt_volume" "common_init_volume" {
name = "vm-tf_init.iso"
pool = "default"
create = {
content = {
url = libvirt_cloudinit_disk.common_init.path
}
}
}
Duas observações de segurança e portabilidade:
sudo NOPASSWDé aceitável em laboratório, mas evite-o em qualquer VM que receba serviços de outros hosts — sem senha no sudo, qualquer processo rodando como o usuário vira root sem atrito. Em produção, usesudo: "ALL=(ALL) ALL"e gerencie a senha por outro canal.
pathexpand() garante que o ~ seja expandido para o home do usuário em qualquer versão do Terraform — sem ele, versões mais antigas falham ao resolver file("~/.ssh/kvm.pub").Usamos a rede default, criada automaticamente na instalação do hypervisor (NAT 192.168.122.0/24):
virsh net-list
Name State Autostart Persistent
--------------------------------------------
default active yes yes
# domain.tf
resource "libvirt_domain" "domain" {
name = "vm-tf"
title = "VM Terraform"
description = "Máquina virtual implementada via Terraform"
memory = 2048
memory_unit = "MiB"
vcpu = 2
type = "kvm"
cpu = {
mode = "host-passthrough"
}
features = {
acpi = true
}
os = {
type = "hvm"
type_arch = "x86_64"
type_machine = "q35"
firmware = "efi"
}
devices = {
disks = [
{
source = {
volume = {
pool = libvirt_volume.os_volume.pool
volume = libvirt_volume.os_volume.name
}
}
target = { dev = "vda", bus = "virtio" }
driver = {
type = "qcow2"
}
},
{
device = "cdrom"
source = {
volume = {
pool = libvirt_volume.common_init_volume.pool
volume = libvirt_volume.common_init_volume.name
}
}
target = { dev = "sda", bus = "sata" }
}
]
interfaces = [
{
type = "network"
model = {
type = "virtio"
}
source = {
network = {
network = "default"
}
}
}
]
videos = [
{
model = {
type = "virtio"
primary = "yes"
heads = 1
}
}
]
# Console serial — essencial para VMs headless (virsh console vm-tf)
serials = [
{
type = "pty"
target = {
port = 0
type = "isa-serial"
}
}
]
consoles = [
{
type = "pty"
target = {
port = 0
type = "serial"
}
}
]
graphics = [
{
spice = {
auto_port = true
listen = "127.0.0.1"
}
}
]
}
running = true
}
Pontos que merecem atenção:
host-passthrough: a VM vê a CPU real do host (melhor desempenho e todas as extensões), mas dificulta migração entre hosts de gerações diferentes;firmware = "efi": boot UEFI (a imagem genérica do Debian 13 suporta); em imagens antigas pode ser necessário BIOS;virsh console vm-tf quando a rede da VM falha — mantenha sempre.terraform init
terraform validate # Success! The configuration is valid.
terraform plan
O plan deve mostrar Plan: 4 to add, 0 to change, 0 to destroy — os quatro recursos da tabela de abertura. Confira principalmente: o caminho do backing_store, a rede default e se a sua chave pública aparece corretamente no user_data.
terraform apply # confirme com: yes
libvirt_volume.os_volume: Creation complete after 0s [id=/datastore/images/vm-tf.qcow2]
libvirt_domain.domain: Creation complete after 1s [name=vm-tf]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
virsh list
Id Name State
-----------------------
1 vm-tf running
# Descobre o IP entregue pelo DHCP da rede default
virsh domifaddr vm-tf
Name MAC address Protocol Address
-------------------------------------------------------------------------------
vnet0 52:54:00:ac:08:64 ipv4 192.168.122.2/24
ssh -i ~/.ssh/kvm gean@192.168.122.2
Dentro da VM, confirme que disco, memória e CPU correspondem ao declarado:
df -h / # /dev/vda1 16G — o cloud-init expandiu os 3 GiB da template para 16 GiB
free -m # ~1935 MiB
nproc # 2
O redimensionamento automático funciona porque a imagem usa partições comuns com
growpartdo cloud-init. Em imagens com LVM, o disco não cresce sozinho — seria preciso expandir PV/LV manualmente ou via módulos adicionais do cloud-init.
terraform destroy # confirme com: yes
Destroy complete! Resources: 4 destroyed.
O destroy remove o domínio, os dois volumes e a ISO de cloud-init — a template em /datastore/templates/ permanece intacta, pronta para a próxima VM.
vm-tf é didático, mas fora do laboratório aplique a taxonomia de nomenclatura — ex.: lab-nat-web01, com hostname da VM igual ao nome no hypervisor;variables.tf/terraform.tfvars e use count ou for_each para subir várias VMs de uma vez;virsh net-update default add ip-dhcp-host ...) ou configure rede no cloud-init (network-config);users, growpart, write_files etc.