Durante años mi homelab ha funcionado como funcionan muchos: entras en la web de Proxmox, clonas una VM, le pones una IP que crees que está libre, instalas cuatro cosas por SSH y te olvidas. Funciona hasta que un día no sabes qué VM hace qué, qué IPs están ocupadas ni cómo volverías a montar aquello si se rompe. Así que me puse a meterlo todo en un repositorio, que he llamado, sin mucha imaginación, homelab-iac.

Pero no se trata solo de tener orden. Con homelab-iac aplico en casa las prácticas de un entorno empresarial: infraestructura como código, estado compartido, secretos fuera del repo y documentación. Me sirve para probar cosas nuevas sin miedo y para entender cómo funciona todo por dentro, y así luego aplicarlo en el mundo real.

Lo empecé en abril con lo mínimo: crear VMs en Proxmox con Terraform y dejarlas configuradas con Ansible. Durante meses no pasó de ahí, hasta que en octubre le di el empujón: DNS, estado compartido, secretos, acceso remoto y documentación. El repo es privado, así que en este post cuento cómo está montado, con los fragmentos de código que importan y, sobre todo, con los tropiezos que no salen en los tutoriales.

La idea

Terraform crea las máquinas y Ansible las configura. El repo tiene esta pinta:

homelab-iac/
├── terraform/
│   ├── proxmox/      # VMs y contenedores LXC
│   └── cloudflare/   # registros DNS de la zona
├── ansible/
│   ├── bootstrap*.yml
│   ├── inventory/
│   └── roles/        # common, hardening, docker, postgres, tailscale...
├── scripts/
└── docs/             # una guía por pieza

Para tener una máquina nueva hacen falta dos comandos:

1. terraform apply     → clona la template y cloud-init arranca la VM con IP, usuario y clave SSH
2. ansible-playbook    → hardening, Docker y lo que necesite esa máquina

Terraform y Proxmox

Uso el provider bpg/proxmox, que tiene un desarrollo muy activo y cubre todo lo que necesito, LXC incluidos. Todas las VMs salen de un único recurso con for_each sobre un mapa:

resource "proxmox_virtual_environment_vm" "vm" {
  for_each   = var.vms
  name       = each.key
  node_name  = each.value.node
  vm_id      = each.value.vm_id
  protection = each.value.protection

  clone {
    vm_id = each.value.template_id
    full  = true
  }

  initialization {
    ip_config {
      ipv4 {
        address = each.value.vm_ip
        gateway = each.value.vm_gateway
      }
    }
    user_account {
      username = var.vm_user
      keys     = [var.vm_ssh_key]
    }
  }
  # cpu, memory, disk, network_device...
}

El inventario real de máquinas es el terraform.tfvars, que se lee casi como una hoja de cálculo:

vms = {
  "labs-vm-int-monitoring-01" = {
    node         = "vmtmini04"
    template_id  = 9001
    storage      = "local-lvm"
    vm_id        = 3018
    vm_cores     = 4
    vm_memory    = 8192
    vm_disk_size = 41
    vm_ip        = "192.168.55.45/24"
    # ...
  }
}

Añadir una VM es añadir un bloque y hacer apply. Uso for_each y no count porque así cada VM tiene su propia clave en el estado (vm["labs-vm-int-monitoring-01"]). Con count las VMs van numeradas, y si borras una del medio, Terraform quiere recrear todas las que van detrás.

Por el camino aprendí esto:

  • Hace falta una template por nodo. Tengo dos nodos de Proxmox y el clon tiene que salir de una template del mismo nodo, así que hay una 9000 en uno y una 9001 en el otro. Las genera un script que descarga la cloud image de Ubuntu 24.04 y le instala qemu-guest-agent con virt-customize antes de convertirla en template. Sin el agente, Terraform se queda esperando a que la VM le diga su IP.
  • Lo importante lleva protection = true. Proxmox se niega a borrar una VM protegida, también si se lo pide terraform destroy. Más abajo explico por qué me hace falta.
  • Los LXC no tienen cloud-init. Terraform los crea con la clave SSH en root, y un playbook pequeño hace lo que en una VM haría cloud-init: crear el usuario, darle sudo y su clave. A partir de ahí se configuran con el mismo Ansible que las VMs.

Ansible: el bootstrap

Ansible lleva unos cuantos roles pequeños: common (paquetes base, usuario y claves), hardening (UFW, fail2ban y SSH en otro puerto, sin root ni contraseñas), docker, y luego uno por tipo de máquina: postgres, devbox, tailscale…

El que más guerra me dio fue hardening. Desde Ubuntu 22.10, SSH arranca por socket activation: el puerto lo decide ssh.socket y no sshd_config. Cambias Port en el fichero, reinicias y SSH sigue escuchando en el 22. Lo resolví desactivando el socket:

- name: Disable ssh.socket (Ubuntu 24.04 socket activation overrides sshd_config port)
  systemd:
    name: ssh.socket
    enabled: false
    state: stopped

Hay una segunda trampa: cloud-init deja un /etc/ssh/sshd_config.d/50-cloud-init.conf que puede volver a activar la autenticación por contraseña aunque la hayas quitado en sshd_config. El rol lo borra.

Como el puerto cambia durante el propio bootstrap, la primera ejecución va contra el 22 y las siguientes contra el nuevo:

ansible-playbook bootstrap.yml -e "ansible_port=22" --limit nueva-vm   # primera vez
ansible-playbook bootstrap.yml --limit nueva-vm                         # después

Y ese --limit es obligatorio: el playbook base apunta a hosts: all y hace un dist-upgrade, así que sin él se actualiza todo el homelab a la vez.

El DNS también: Cloudflare

Mis servicios internos tienen nombre en el DNS público de mi dominio y apuntan a IPs privadas: resuelven desde cualquier sitio, pero solo se llega a ellos desde casa o por la VPN. Esos registros también están en Terraform, con el provider de Cloudflare (v5) y el mismo patrón de mapa más for_each.

Ya tenía unos 50 registros creados a mano, así que el trabajo consistió en importarlos con bloques import hasta que el plan dijera No changes. Me encontré con dos sorpresas:

  • El nombre del registro. En el tfvars va relativo (git, @…) y el código le añade la zona. La API devuelve siempre el nombre completo, y si en el estado guardas el corto, el plan ve un cambio en cada ejecución.
  • Los _acme-challenge se quedan fuera, a propósito. Los crea y los borra el cliente ACME en cada renovación de certificados. Si los gestionara Terraform, saldrían como deriva en cada plan, y un apply los desharía.

El estado: Postgres, y el problema del huevo y la gallina

Al principio el terraform.tfstate vivía en local, y eso vale mientras trabajas desde una sola máquina. En cuanto empecé a usar el repo desde dos sitios, lo moví al backend pg. El estado está ahora en una base de datos de una VM con Postgres, todas las máquinas leen el mismo, y el backend lo bloquea mientras dura un plan o un apply, así que dos máquinas no pueden pisarse.

terraform {
  backend "pg" {} # la conexión llega por la variable de entorno PG_CONN_STR
}

¿Por qué Postgres y no MinIO?

Lo más habitual es guardar el estado en S3, y en un homelab eso suele querer decir MinIO. Lo descarté por dos motivos:

  • MinIO ya no se mantiene. No quería apoyar en un proyecto abandonado algo tan central como el estado de Terraform.
  • Un servidor de Postgres sirve para mucho más. Con él tengo un servidor de base de datos para los proyectos internos del homelab, no solo para Terraform. NetBox, por ejemplo, ya guarda ahí su base. Además, el pg_dump diario se lleva el estado junto con todo lo demás, y el bloqueo viene de serie con los advisory locks de Postgres.

Tiene un contra claro: si se cae Postgres, no hay Terraform. Ahora mismo es una sola VM, sin alta disponibilidad. Es un problema del Víctor del futuro, que tendrá que ponerse con la alta disponibilidad de Postgres.

Lo curioso es que esa VM de Postgres la crea el mismo Terraform cuyo estado guarda. Por eso tiene protection = true y su backup diario incluye la base del estado: un destroy despistado no puede llevarse por delante el sitio donde Terraform apunta lo que existe.

Además, como el estado es una tabla, se puede mirar con cualquier cliente de Postgres. Para curiosear es bastante más cómodo que terraform state show.

Secretos: Infisical y un alias

En el repo no hay ni un secreto. Los de Terraform (tokens de Proxmox y Cloudflare, la cadena de conexión del estado) están en Infisical, autoalojado en el propio homelab, y solo se cargan mientras dura el comando.

Esto funciona gracias a una regla de Terraform: si existe la variable de entorno TF_VAR_algo, su valor se usa como var.algo. En Infisical guardo los secretos como TF_VAR_proxmox_api_token, TF_VAR_cloudflare_api_token, PG_CONN_STR…, e infisical run se los pasa al proceso. Para no escribirlo cada vez, tengo un alias:

alias tfi='infisical run --path=/terraform -- terraform'

Así, tfi plan es terraform plan con los secretos cargados, sin escribirlos en disco ni dejarlos en la shell. El proyecto y el entorno de Infisical salen de un .infisical.yaml versionado en la raíz del repo, de modo que el mismo alias me sirve en cualquier otro repo que siga la convención.

Caí en dos trampas:

  • Los *.auto.tfvars ganan a las TF_VAR_*. Si queda por ahí un secrets.auto.tfvars de antes, Terraform usa el fichero e ignora Infisical sin avisar. Rotas un token, Terraform sigue con el viejo y no entiendes por qué.
  • Para probar, printenv y no echo. En infisical run -- echo $FOO es tu shell la que sustituye $FOO, antes de que arranque Infisical, así que sale vacío y parece que no funciona.

¿Y si Infisical está caído? Es una VM más del homelab, así que puede pasar. Para ese caso los valores también están en mi gestor de contraseñas, y con ellos puedo crear temporalmente un secrets.auto.tfvars. Eso sí, hay que acordarse de borrarlo después, por lo que contaba arriba.

Tailscale en un LXC, sin tocar el nodo

Para entrar desde fuera de casa uso un LXC con Tailscale que hace de subnet router (anuncia las redes de casa) y de exit node (me deja salir a Internet por casa). El problema es que Tailscale necesita /dev/net/tun, y un LXC no privilegiado no lo tiene. Las dos formas habituales de dárselo no me valían:

  • Editar a mano el fichero de configuración del contenedor en el nodo, que es justo lo que quería dejar de hacer.
  • Pasarle el dispositivo por la API, cosa que Proxmox solo permite a root@pam, y Terraform entra con un token con menos permisos.

Al final arranco tailscaled con --tun=userspace-networking. Así el enrutado lo hace el propio proceso, sin dispositivo. Sirve igual para subnet router y para exit node, a cambio de gastar algo más de CPU. Y el contenedor entero queda en el IaC, desde el terraform apply hasta el tailscale up.

Trabajar desde cualquier máquina

Con el estado en Postgres y los secretos en Infisical, el repo se puede clonar en cualquier sitio. Lo que no está en el repo llega por otros caminos:

  • Las claves SSH (la mía y la que usa el IaC) vienen con mis dotfiles, gestionados con chezmoi y cifrados con age.
  • El secret.yml de Ansible, que todavía no está en Infisical, lo copio a mano.
  • Terraform, Ansible y la CLI de Infisical se instalan con un rol de Ansible.

Sé que una máquina está lista cuando tfi plan dice No changes en las dos carpetas de Terraform y ansible all -m ping responde. Tengo los pasos escritos en una guía, porque es justo lo que se olvida entre una vez y la siguiente.

En el día a día casi todo lo hago desde una devbox: una VM siempre encendida, creada por este mismo repo, a la que me conecto por SSH y tmux desde el portátil o desde el móvil. El portátil ha pasado a ser poco más que una terminal.

NetBox: documentar, no depender

La última pieza es NetBox, donde documento redes, VLANs, IPs y hardware. Unos scripts cargan lo que ya conoce el IaC (las VMs y los LXC de Proxmox, los registros de Cloudflare) y un barrido de ping me avisa de las IPs que responden pero no están documentadas.

Aquí tomé una decisión a propósito: NetBox solo documenta. Ni Terraform ni Ansible leen de él. Es tentador usarlo como fuente de verdad, pero entonces, si NetBox se cae, no puedo crear una VM. Prefiero que la fuente de verdad sea el repo y que NetBox sea un reflejo. Si se rompe, no pasa nada.

Lo que me llevo

  • Empieza por importar lo que ya tienes. Antes que crear cosas nuevas, lo que más me ha aportado es que Terraform conozca lo que ya existía.
  • Un plan sin cambios es el mejor test. Si después de tocar algo el plan no sale limpio, el código no refleja la realidad.
  • Protege lo que guarda el estado, con protection = true y con backups.
  • Documenta las trampas, no lo obvio. Mis guías no explican qué es Terraform. Explican por qué desactivo ssh.socket, por qué los _acme-challenge no están en el código y qué hacer si Infisical se cae. Es lo que necesitaré dentro de seis meses.

Me quedan cosas por hacer:

  • CI/CD en mi Forgejo. Ahora mismo los plan y los apply los lanzo yo desde mi máquina. Quiero que cada cambio pase por un pipeline: plan al abrir una PR para revisarlo, y apply al hacer merge. Así el repo será, de verdad, la única forma de tocar la infraestructura.
  • Que Ansible también saque sus secretos de Infisical, en vez de ese secret.yml que copio a mano.
  • Sacar los backups de Infisical fuera de su propia VM.
  • Adoptar en el IaC las máquinas que todavía viven fuera de él.

Pero lo importante ya está: si mañana se me rompe una VM, sé cómo volver a tenerla.