LXC, VMs Windows y una VM Kali Linux de mi homelab Proxmox declarados en código con Terraform (bpg/proxmox): perfiles de tamaño reutilizables, despliegue múltiple con un solo apply, una plantilla Windows con WinRM preconfigurado y una imagen Kali importada directamente desde el disco oficial. El propio apply genera el inventario de Ansible automáticamente — de "declarado en código" a "listo para configurar" sin teclear ninguna IP a mano.
Cada LXC o VM de este homelab —Pi-hole, cloudflared, el nodo de gestión de Ansible, la propia VM de Windows Server— nació igual: a mano desde el panel de Proxmox, eligiendo plantilla, recursos y red cada vez. El proyecto de Ansible ya resuelve la parte de configurar una máquina que ya existe, pero la existencia misma de la máquina —su ID, sus recursos, su red— seguía fuera de cualquier control de versiones.
Terraform cierra ese hueco: declara la infraestructura en archivos .tf versionados en Git, y un apply hace que la realidad del clúster coincida con lo declarado. Corre desde el mismo LXC de gestión que ya usa Ansible.
Telmate es el provider más usado históricamente, pero tiene un bug abierto de permisos (VM.Monitor) en Proxmox VE 9.x. bpg/proxmox está mantenido activamente, soporta PVE 9.2 de forma oficial y cubre tanto LXC como VMs.
Nada de root@pam. Un usuario terraform@pve con un rol propio de privilegios mínimos (ni PVEAdmin ni Sys.Modify de más) y un token de API separado de cualquier contraseña — limita el radio de impacto si el token llegara a filtrarse a gestión de VMs/LXC, no a administración del sistema. Los comandos exactos de creación están en las notas rápidas.
Un mapa flavors define perfiles reutilizables (básico/pro/supreme/extreme → cores/RAM/disco), y un for_each sobre un mapa de "máquinas deseadas" genera tantos LXC o VMs como se declaren en terraform.tfvars con un único apply — sin copiar y pegar bloques de recurso. El detalle de cómo se rellena ese mapa está en la sección de uso, más abajo.
VM base con drivers VirtIO, qemu-guest-agent y WinRM preconfigurado antes del sysprep — cada VM clonada nace ya lista para que Ansible la gestione, sin pasos manuales posteriores. terraform apply clona la plantilla vía el bloque clone del recurso proxmox_virtual_environment_vm.
Un bloque locals construye dos listas limpias de hostname + IP: para los LXC, la IP declarada (le quita el /24); para las VMs Windows, la IP descubierta tras crearse, leyendo el atributo ipv4_addresses que expone el propio recurso vía guest-agent (filtrando localhost y autoasignación sin DHCP). Una templatefile + el provider hashicorp/local escriben un inventory_generado.ini con [linux]/[windows] ya resueltos — ninguna IP se teclea a mano en ningún paso.
A diferencia de Windows y los LXC, Kali no sale de una plantilla propia — se importa directamente desde la imagen QEMU oficial (disk.import_from) con credenciales kali/kali ya listas. Se eligió VM y no LXC a propósito: el set de herramientas de pentesting necesita sockets raw, modo monitor Wi-Fi y acceso a USB, todo lo cual un LXC comparte (y limita) a nivel del kernel del host. Sin cloud-init en la imagen, la IP se fija a mano dentro del guest tras el primer arranque, igual que en las VMs Windows sin plantilla cloud-init.
Al principio, started = true estaba escrito a fuego en cada recurso. Apagar una máquina a mano en Proxmox no cambiaba lo que Terraform creía que debía pasar — el siguiente apply la volvía a encender sin avisar de por qué. Ahora started es un atributo más de cada entrada en contenedores/vms_windows (y kali_started para la VM única), con default true para no romper nada existente. Apagar algo de verdad es ahora una línea en terraform.tfvars, no una pelea recurrente contra el propio Terraform.
El día a día no toca ningún archivo .tf. Todo lo que cambia entre un despliegue y otro vive en un único sitio: terraform.tfvars. Añadir una máquina nueva es una entrada más en un mapa, no una línea de código nueva.
# terraform.tfvars — lo único que se toca para desplegar algo nuevo flavors = { minimo = { cores = 1, memory = 512, disk = 2 } basico = { cores = 1, memory = 1024, disk = 10 } pro = { cores = 2, memory = 2048, disk = 20 } supreme = { cores = 3, memory = 3072, disk = 30 } extreme = { cores = 4, memory = 4096, disk = 40 } } contenedores = { ttyd = { vm_id = 103 hostname = "ttyd" flavor = "minimo" # 1 core / 512MB / 2GB — de sobra para un solo binario ip = "192.168.1.14/24" gateway = "192.168.1.1" } } vms_windows = { win_test01 = { vm_id = 490 name = "win-test01" template_vm_id = 251 } }
minimo nació precisamente para ttyd: el flavor por defecto (basico, 10GB) sobraba de largo para un LXC que solo corre un binario de unos pocos MB — en un SSD de 240GB compartido con VMs de Windows y una Kali de 90GB, esos 8GB de diferencia por máquina cuentan.
El flujo de trabajo real, de principio a fin:
contenedores o vms_windows dentro de terraform.tfvarsterraform plan — confirmar que Terraform solo va a crear/tocar lo esperado, nada másterraform apply — la máquina nace con el flavor, plantilla y red declaradoslocals descubre la IP real (LXC: declarada; Windows: vía guest-agent) y regenera inventory_generado.iniansible-playbook -i inventory_generado.ini site.yml --limit <nombre_maquina> --ask-vault-pass — la máquina queda configurada y securizadaDe "una línea en un mapa" a "máquina securizada y en el inventario" sin tocar el panel de Proxmox ni teclear una IP en ningún paso intermedio.
Un matiz en ese último paso, la primera vez que se aprovisiona una máquina recién creada: el usuario que Ansible usa habitualmente todavía no existe ahí (lo crea el propio playbook), así que ese primer contacto necesita forzar el usuario que Terraform sí dejó autorizado — detalle completo, con el porqué, en la ficha de Ansible.
Kali queda fuera de este flujo a propósito: es una máquina única declarada directamente en su propio recurso, no un flavor reutilizable — no hay entrada de .tfvars que editar para desplegarla, solo terraform apply una vez preparada la imagen (ver notas rápidas).
terraform apply creaba el recurso pero fallaba a mitad de la operación de clonado con 403 Forbidden. El token tenía privilegios de administración general pero no los específicos que exige la operación de clonado sobre el datastore de destino.
Datastore.AllocateSpace ni Datastore.AllocateTemplate — permisos que Proxmox exige específicamente al clonar una plantilla y reservar espacio para el disco nuevo, aunque el usuario sí pudiera crear VMs desde cero.
Al declarar varias VMs con for_each sobre la misma plantilla, Terraform lanzaba los clonados en paralelo por defecto — y el segundo clonado fallaba con un error de bloqueo sobre el disco de la plantilla, como si esta ya estuviera "en uso" por otra operación.
apply intentando clonar la misma plantilla al mismo tiempo compiten por ese bloqueo, y el que llega segundo falla en vez de esperar.
terraform apply -parallelism=1). Más lento, pero elimina la condición de carrera sin tener que tocar el código de los recursos.
Al ejecutar sysprep /generalize en la plantilla Windows, el proceso fallaba con 0x80073cf2. El log (%WINDIR%\System32\Sysprep\Panther\setupact.log) señalaba la causa exacta: Package Microsoft.MicrosoftEdge.Stable_... was installed for a user, but not provisioned for all users. Es un bug conocido de Microsoft — Windows Update instala actualizaciones de Edge "solo para el usuario actual" en vez de a nivel de máquina, y sysprep exige que todo esté provisionado globalmente antes de generalizar.
Get-AppxPackage -AllUsers Microsoft.MicrosoftEdge.Stable | Remove-AppxPackage -AllUsers
Si reaparece con otro paquete, el patrón se repite: mirar el log, copiar el nombre exacto del paquete, y aplicar el mismo comando con ese nombre.
Una VM Windows gestionada por Terraform se renumeró directamente en Proxmox (qm clone + qm destroy, el mismo procedimiento manual usado para máquinas sin gestionar). El siguiente terraform plan no mostró un reemplazo claro — la VM aparecía como creación nueva, con "0 to destroy", como si nunca hubiera existido.
apply, no en el plan. El state en disco seguía apuntando al VMID viejo, que ya no existía; el refresh interno del plan lo detectó y lo descartó en memoria, pero sin guardar ese cambio — de ahí la apariencia contradictoria.
terraform state rm para soltar la referencia rota, y terraform import apuntando al VMID real (formato <nodo>/<vm_id>). Con recursos clonados de plantilla hace falta además lifecycle { ignore_changes = [clone] } — el bloque clone no se puede reconstruir desde un import, y sin ignorarlo el siguiente plan intenta re-clonar la VM entera desde cero.
Un segundo bug salió a la luz en el propio import: un local que leía la IP del guest-agent vía ipv4_addresses[0] reventaba con Invalid index en cuanto la VM estaba parada — lista vacía, índice fuera de rango. Se arregló envolviendo la expresión en try(..., null).
La lección más peligrosa quedó en el propio diff del plan: el formato de Terraform es siempre estado actual → estado deseado, y leerlo al revés a mitad de una sesión larga estuvo a un paso de aplicar un cambio de controlador SCSI en caliente sobre una VM Windows real.
-target arrastra más de lo que pareceCon started = true fijo en cada recurso, apagar una VM o LXC directamente en Proxmox no tenía ningún efecto sobre lo que Terraform consideraba correcto: el siguiente plan la mostraba con started: false -> true, encendiéndola de nuevo sin previo aviso — la propia idea de "declarativo" jugando en contra cuando el estado deseado nunca reflejó lo que realmente se quería en ese momento.
lxc.tf, vms.tf, kali.tf), no expuesto como variable — no había forma de declarar "esta la quiero apagada" sin editar código cada vez.
started en un campo optional(bool, true) dentro del tipo de contenedores y vms_windows, y una variable independiente kali_started para la VM única. El estado que se quiere pasa a vivir en terraform.tfvars, el mismo sitio que ya gestiona todo lo demás.
De paso salió a la luz un segundo bug en locals.tf: el try(..., null) que evitaba el crash cuando una VM Windows estaba apagada seguía sin poder interpolarse dentro de templatefile() — un valor null en una plantilla de texto revienta igual, solo que más tarde. Arreglo real: separar el cálculo de la IP de la decisión de si el host entra en el inventario, filtrando if v.ip != null antes de construir el archivo. Una máquina apagada, simplemente, deja de aparecer en el inventario — correcto, no hay IP a la que Ansible deba conectar.
Y una trampa aparte con terraform apply -target=..., pensado para tocar una sola máquina sin arrastrar el resto: targetear a la vez el LXC nuevo y local_file.ansible_inventory encendió también una VM Windows que nunca se nombró — el inventario depende de leer su IP vía guest-agent, así que Terraform arrastra esa dependencia igualmente. El propio Terraform lo avisa en el mensaje del plan ("-target is not for routine use"): sirve para una situación puntual, no como sustituto del campo started de arriba.
El ciclo completo terraform apply → terraform destroy está probado de extremo a extremo: LXC y VMs Windows nacen con los recursos y la red ya declarados, la plantilla Windows deja cada VM lista para Ansible sin ningún paso manual posterior, la VM Kali se importa directamente desde imagen oficial sin plantilla intermedia, y el inventario se genera solo tras cada apply — ninguna IP se teclea a mano en ningún punto. El flavor minimo y el LXC de ttyd fueron el primer despliegue real hecho enteramente con este flujo, de cero a máquina funcionando. El encendido/apagado de cada máquina es ahora un campo declarado, no un valor fijo que Terraform imponga por su cuenta en cada apply.
Referencia de uso diario y para reconstruir la plantilla Windows — sin tener que saltar al repo.
terraform plan compara la realidad contra lo que ya está declarado, no contra lo que debería estarlo — un archivo vacío o inexistente no es un error para Terraform, es simplemente "nada que hacer". Si un plan devuelve No changes y no lo esperabas, comprueba antes que el archivo se guardó bien (cat main.tf) en vez de fiarte del resultado.
Dentro de Windows, tras instalar virtio-win-guest-tools.exe y aplicar actualizaciones — WinRM para Ansible y generalizar:
# WinRM, para que Ansible pueda gestionar la VM tras el despliegue winrm quickconfig -q winrm set winrm/config/service/auth '@{Basic="true"}' winrm set winrm/config/service '@{AllowUnencrypted="true"}' New-NetFirewallRule -Name "WinRM-HTTP-In" -DisplayName "WinRM HTTP" -Enabled True -Direction Inbound -Protocol TCP -LocalPort 5985 -Action Allow -Profile Any # Generalizar y apagar C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe /shutdown
Nota: AllowUnencrypted="true" + Basic solo son aceptables porque esta plantilla vive dentro de la LAN aislada del homelab — fuera de esa red de confianza, lo correcto es WinRM sobre HTTPS con certificado.
Con la VM apagada, conviértela en plantilla desde el nodo Proxmox: qm template <vm_id> (o desde el panel: VM → menú Más → Convertir en plantilla). Una vez convertida, ya no se puede arrancar directamente — solo clonar.
Las plantillas de LXC son distintas a las de VM: se descargan como imagen del repositorio propio de Proxmox, no se instalan a mano.
# Ver plantillas disponibles y descargar la que toque: pveam available | grep debian-12 pveam download local debian-12-standard_12.7-1_amd64.tar.zst # Comprobar que ya está descargada localmente: pveam list local
El acceso no necesita contraseña ni pasos manuales: el recurso proxmox_virtual_environment_container inyecta la clave pública SSH directamente en el bloque initialization, así que el LXC nace ya accesible por clave — listo para que Ansible entre sin ningún paso intermedio:
initialization {
user_account {
keys = [file("~/.ssh/id_ed25519_terraform.pub")]
}
}
Ampliar el disco de un LXC ya desplegado (a diferencia de una VM, se aplica en caliente): cambia disk.size en terraform.tfvars y relanza terraform apply — Proxmox redimensiona el contenedor sin pararlo.
Kali publica una imagen QEMU pre-construida con credenciales kali/kali ya listas — sin plantilla que generalizar. Se descarga en el propio nodo Proxmox:
# Extraer el .7z requiere p7zip-full
apt install -y p7zip-full
wget https://cdimage.kali.org/kali-2026.2/kali-linux-2026.2-qemu-amd64.7z
7z x kali-linux-2026.2-qemu-amd64.7z
El tamaño que reporta qemu-img info es el virtual, no el ocupado real — el qcow2 es disperso ("thin"), y al importarlo sobre un storage LVM-thin como local-lvm solo consume del disco lo que Kali realmente escriba. Aun así, ese número es el que hay que fijar como disk.size en Terraform: no se puede reducir después de importado, solo crecer.
qemu-img info kali-linux-2026.2-qemu-amd64.qcow2 | grep "virtual size"
Se mueve al content type import del storage local (tiene que estar habilitado en Datacenter → Almacenamiento → local → Editar → Contenido) — no a template/iso ni directamente a local-lvm:
mv kali-linux-2026.2-qemu-amd64.qcow2 /var/lib/vz/import/
Y se referencia en disk.import_from del recurso Terraform con el formato <datastore>:import/<archivo>.qcow2 — Proxmox convierte el qcow2 a raw automáticamente al importarlo sobre un storage LVM.
Sin cloud-init en la imagen, la VM sube por DHCP en el primer arranque. IP fija dentro del propio guest:
# confirma el nombre real de la conexión antes de tocar nada
nmcli con show
nmcli con mod "<nombre>" ipv4.addresses 192.168.1.60/24 ipv4.gateway 192.168.1.1 ipv4.dns 8.8.8.8 ipv4.method manual
nmcli con up "<nombre>"
terraform init # descarga providers terraform plan # simula, no toca nada terraform apply # aplica lo declarado terraform destroy # elimina lo que gestiona el state terraform validate # solo revisa sintaxis terraform state list # qué hay en el state
Rol de privilegios mínimos para el usuario de servicio — ni PVEAdmin ni acceso de sistema, solo lo que Terraform necesita para crear, clonar y gestionar VMs/LXC (incluye los permisos de datastore que faltaban en el problema de arriba):
# Crear el rol con privilegios mínimos: pveum role add TerraformProv -privs "VM.Allocate,VM.Clone,VM.Config.Disk,VM.Config.CPU,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.Config.Cloudinit,VM.Monitor,VM.Audit,VM.PowerMgmt,Datastore.AllocateSpace,Datastore.AllocateTemplate,Datastore.Audit,Pool.Allocate,SDN.Use" # Crear el usuario de servicio y asignarle el rol: pveum user add terraform@pve pveum aclmod / -user terraform@pve -role TerraformProv # Generar el token de API (independiente de cualquier contraseña): pveum user token add terraform@pve terraform-token --privsep=0 pveum user token list terraform@pve pveum user token remove terraform@pve terraform-token # para rotarlo
Tras cada apply, el archivo queda escrito en la raíz del repo (gitignored — tiene IPs reales de la red):
cat inventory_generado.ini
# Usarlo directamente con Ansible:
ansible-playbook -i inventory_generado.ini site.yml --limit linux
ansible-playbook -i inventory_generado.ini site.yml --limit windows --ask-vault-pass
Si la sección [windows] sale vacía o con una IP 169.254.x.x, el guest-agent no había resuelto la IP real a tiempo — comprobar con qm agent <id> network-get-interfaces y relanzar terraform apply (idempotente, solo regenera el archivo).
Si una VM o LXC gestionado por Terraform se renumera a mano en Proxmox (en vez de a través del propio Terraform), el state queda apuntando a un ID que ya no existe. Un terraform plan normal no lo deja claro — puede mostrar el recurso como creación nueva en vez de como reemplazo. Reconciliarlo:
# 1. Soltar la referencia rota (no toca nada en Proxmox, solo el state) terraform state rm 'proxmox_virtual_environment_vm.<recurso>["<clave>"]' # 2. Actualizar el vm_id en terraform.tfvars al valor real # 3. Importar bajo la dirección correcta terraform import 'proxmox_virtual_environment_vm.<recurso>["<clave>"]' <nodo>/<vm_id_real> # 4. SIEMPRE revisar el plan con calma antes de aplicar terraform plan
Si el recurso se creó originalmente con un bloque clone, añade lifecycle { ignore_changes = [clone] } antes de importar — ese bloque no se puede reconstruir desde el import, y sin ignorarlo el plan intenta re-clonar la máquina entera desde cero, con pérdida de todo lo que tuviera dentro.
Un archivo limpio en el commit actual no garantiza nada — si el secreto se subió y se borró después en un commit posterior, sigue descargable por cualquiera que haga git clone y mire el historial. .gitignore solo evita subidas futuras, no limpia lo ya commiteado:
# ¿Llegó el .tfvars real a commitearse antes de que existiera el .gitignore? git log --all --full-history -- terraform.tfvars # ¿Aparece el valor real del token en cualquier punto del historial? git log -p --all | grep -i "terraform-token="
Si algo aparece: purgar del historial con git-filter-repo (no filter-branch, obsoleta) y rotar la credencial de todas formas — purgar limpia el repo, no deshace una posible copia ya hecha mientras estuvo público.
El repositorio es público y se actualiza según avanza el proyecto: