Toda la configuración de mi homelab Proxmox convertida en código versionado: un solo comando deja cualquier máquina instalada, configurada y securizada. El mismo repositorio administra contenedores y VMs Linux por SSH y Windows Server por WinRM, con hardening aplicado en dos fases y cuentas nombradas por persona (secretos cifrados con Ansible Vault) separadas de la cuenta de servicio que usa la propia automatización.
Cada vez que creaba un LXC o una VM en mi Proxmox repetía el mismo ritual a mano: actualizar, instalar los mismos paquetes, configurar locales y zona horaria, montar fail2ban, crear usuarios, endurecer SSH... Quince minutos de comandos idénticos con resultados que nunca eran del todo idénticos — cada máquina acababa siendo ligeramente distinta según el día que la instalé.
La solución tiene nombre desde hace años: infraestructura como código. La configuración no se ejecuta, se declara en archivos versionados, y una herramienta se encarga de que la realidad coincida con lo declarado. Monté el proyecto con Ansible sobre un LXC de gestión mínimo (1 vCPU, 1 GB de RAM) que actúa como único punto de administración — y cuando el modelo funcionó en Linux, lo extendí a Windows Server, porque en el mundo real las infraestructuras son mixtas.
Un LXC ligero contiene Ansible y la única clave privada con acceso al resto de máquinas. Toda la administración del homelab sale de ese punto, auditable y aislado. Las máquinas gestionadas no necesitan ningún agente instalado: Linux se administra por SSH y Windows por WinRM, ambos nativos.
Cada pieza de configuración es un rol creado con ansible-galaxy init, que genera el esqueleto estándar (tasks, handlers, defaults, vars, meta...). De ese esqueleto solo se rellena lo que el rol necesita — como mínimo tasks/main.yml (las tareas), y opcionalmente defaults/main.yml (variables con valores por defecto, lo que hace el rol reutilizable) y handlers/main.yml (acciones que solo se disparan si algo cambió, como reiniciar un servicio). El resto de carpetas quedan vacías y Ansible las ignora.
El inventario clasifica las máquinas en tres grupos: linux (LXC y VMs Debian/Ubuntu/Kali...), windows (Server, W11) y homelab (el nodo Proxmox, con tratamiento especial). site.yml mapea grupos a roles: Linux recibe base + usuario_ansible + hardening_ssh; Windows recibe base_windows; el nodo, base y usuario pero sin hardening (decisión consciente: es la máquina cuyo plan B es la consola física). Incorporar una máquina nueva es una línea en el inventario y un comando.
Windows no se gestiona con SSH sino con WinRM (puerto 5985, habilitado con Enable-PSRemoting), y sus módulos son propios: win_timezone, win_updates, win_reboot... Pero el modelo mental es idéntico: declarar estado, no ejecutar comandos. El rol de Windows incluso decide solo si debe reiniciar tras instalar actualizaciones (register + when). Misma idempotencia, mismo repo, mismo recap.
No todo aplica a todas las máquinas: chrony no puede ajustar el reloj dentro de un LXC (no tienen reloj propio, usan el del host) y el qemu-guest-agent solo tiene sentido en VMs. Las tareas usan when sobre los "facts" que Ansible descubre de cada máquina (ansible_virtualization_type): un solo rol, comportamiento correcto en cada tipo de máquina.
usuario_ansible es una cuenta de servicio: solo la usa el nodo de gestión para ejecutar playbooks, con sudo sin contraseña porque no hay ningún humano tecleando en ese momento. Para el acceso humano, un rol separado (usuario_hugo) crea una cuenta nombrada por persona: en Linux, con su propia clave SSH y sudo que sí pide contraseña; en Windows, mismo principio pero mecanismo distinto — win_user crea una cuenta local en el grupo de administradores con contraseña propia (WinRM no usa claves). La razón de fondo es trazabilidad: si todo el mundo entra como ansible o como Administrador, los logs no distinguen quién hizo qué. Con Ansible Vault cifrando contraseñas y hashes, y la lista de claves creciendo por persona (no por máquina), añadir alguien nuevo es una línea, no un redeploy.
El proyecto completo está versionado con Git y publicado para su consulta. El inventario del repo lleva IPs y credenciales de ejemplo; los valores reales solo existen en el nodo de gestión.
El día a día no es "aprovisionar todo": es tocar un grupo o una sola máquina. Para eso está --limit, que intersecta con los grupos de site.yml — cada máquina recibe solo los roles que le corresponden:
# Todo el homelab (Linux + Windows + nodo): ansible-playbook site.yml # Solo las máquinas Linux: ansible-playbook site.yml --limit linux # Solo las Windows: ansible-playbook site.yml --limit windows # Una máquina concreta: ansible-playbook site.yml --limit test-debian # Simulacro sin tocar nada (qué cambiaría): ansible-playbook site.yml --limit windows --check # Cualquier ejecución que toque variables cifradas necesita la contraseña del vault: ansible-playbook site.yml --limit linux --ask-vault-pass
El playbook instalaba y arrancaba fail2ban sin errores, pero en cada ejecución la tarea volvía a salir changed — señal de que Ansible lo encontraba parado una y otra vez. journalctl -u fail2ban reveló la causa: Have not found any log file for sshd jail. Los LXC mínimos de Debian 12 no llevan rsyslog, así que /var/log/auth.log no existe y fail2ban se suicidaba al no encontrarlo.
jail.local con backend = systemd (más python3-systemd): fail2ban lee directamente el journal en vez de buscar archivos de log. El arreglo quedó codificado en el rol — ninguna máquina futura volverá a tener este problema.
El objetivo era administrar todo con un usuario dedicado y prohibir el login de root por SSH. Pero el usuario dedicado lo crea el propio playbook... conectándose como root. Si cierras root antes de tiempo, te quedas fuera de tu propia máquina — como comprobé cuando Ansible me devolvió un Permission denied contra mi propia VM de pruebas.
visudo -cf); verificación manual de que la puerta abre; y solo entonces, segunda fase que cierra la vieja y cambia el inventario al usuario nuevo. Regla aprendida: nunca cierres una puerta sin haber probado la otra.
La VM de Windows Server se creó con hardware paravirtualizado (VirtIO SCSI, red VirtIO), que es lo eficiente en Proxmox — pero Windows no trae esos drivers de serie, así que el instalador llegaba a la selección de disco y mostraba una lista vacía: para Windows, el disco no existía.
virtio-win.iso en otra. Desde el propio instalador, "Cargar controlador" → unidad de VirtIO → driver SCSI, y el disco aparece. Tras la instalación, el paquete completo de drivers + qemu-guest-agent desde esa misma ISO, que además permite a Proxmox ver la IP de la VM y hacer apagados limpios.
A mitad de proyecto, Ansible dejó de arrancar con unsupported locale setting. El SSH desde mi equipo reenviaba es_ES.UTF-8 al LXC de gestión, que no tenía ese locale generado, y Python abortaba antes de ejecutar nada.
locale_gen en el rol base — el problema quedó declarado y resuelto para todas las máquinas de una vez.
Al separar variables normales y secretas de Windows (group_vars/windows.yml + group_vars/windows_vault.yml), ansible_password desaparecía sin ningún error — WinRM fallaba con "auth method basic requires a password" como si el vault no existiera. Se repitió después con group_vars/all.yml: una variable se resolvía bien y la otra no, sin patrón aparente.
group_vars es estricta — o un único archivo <grupo>.yml, o una carpeta <grupo>/ con varios archivos dentro, nunca ambos para el mismo grupo. windows_vault.yml suelto se interpretaba como variables para un grupo llamado literalmente windows_vault, que no existe en ningún inventario — se ignoraba en silencio, sin ningún aviso de Ansible.
group_vars/windows/vars.yml + group_vars/windows/vault.yml, group_vars/all/vars.yml + group_vars/all/vault.yml. Ansible carga automáticamente todo lo que encuentra dentro de la carpeta.
Al crear la cuenta de hugo en Windows con win_user, la tarea fallaba con group 'Administrators' not found — a pesar de que el usuario sí llegaba a crearse. La causa: el grupo local de administradores tiene un nombre distinto según el idioma de instalación de Windows, y esta VM está en español.
Administradores, no Administrators. La tarea es idempotente, así que reintentar no duplicó al usuario, solo completó lo que faltaba (añadirlo al grupo).
Con Terraform ya creando LXC/VMs directamente, aprovisionar la primera máquina nueva (ttyd) de extremo a extremo chocó con dos comportamientos de Ansible que nunca se habían visto en máquinas creadas a mano.
ansible_user: ansible en group_vars apunta a una cuenta que crea el propio rol usuario_ansible — que aún no se ha ejecutado en una máquina recién creada. Intentar forzar root con -u root en la línea de comandos no sirvió de nada: -u tiene precedencia baja, cualquier ansible_user definido en group_vars lo pisa igualmente.
-e ansible_user=root (extra-vars) para el primer contacto — -e sí gana por encima de group_vars. Solo hace falta esa vez: en cuanto usuario_ansible crea la cuenta de verdad, las siguientes ejecuciones vuelven a usar el default normal.
--skip-tags no avisa si el tag no existe. site.yml nunca había tenido tags: en sus roles — un --skip-tags hardening_ssh no encontró nada que saltarse y ejecutó el playbook entero, hardening incluido, sin ningún error ni aviso. La máquina quedó con root SSH cerrado sin que se hubiera pedido.
{ role: hardening_ssh, tags: [hardening_ssh] }), y verificar con --check que el filtro funciona de verdad antes de confiarse — las tareas saltadas deben salir como skipping, no ok/changed. Revertir lo aplicado de más fue sencillo (reponer PermitRootLogin/PasswordAuthentication a mano en esa máquina), pero el aviso real es no fiarse de un flag que nunca se comprobó.
Incorporar una máquina al homelab — Linux o Windows — pasó de quince minutos de comandos manuales a una línea en el inventario y un comando. Todas las máquinas quedan en un estado idéntico y verificable: en Linux, nadie entra por SSH con contraseña, root no entra ni con clave, fail2ban vigila los intentos y los parches de seguridad se aplican solos; en Windows, actualizaciones de seguridad instaladas y reinicio automático solo si hace falta. Cada persona entra con su propia cuenta y su propia clave — los logs distinguen quién hizo qué, no solo que "ansible" lo hizo. Todo desde un único punto de gestión auditado.
Y si mañana el mini PC muere, la configuración de todo el homelab no muere con él: está en el repo.
Desde que Terraform entró en juego, el ciclo se cierra del todo: una máquina nace declarada en código, y sale de aquí configurada y securizada — ttyd fue la primera en recorrer ese camino completo, de cero a servicio funcionando, sin un solo paso manual entre medio.
changed en cada ejecución no es un detalle cosmético — es un servicio muriéndose en bucle.group_vars no avisa cuando lo usas mal: un archivo y una carpeta con el mismo nombre de grupo no dan error, uno de los dos simplemente se ignora. Si una variable "desaparece" sin motivo aparente, es el primer sitio donde mirar.--check no es fiable en cadenas de tareas dependientes: si la tarea 1 se simula (usuario creado) en vez de ejecutarse de verdad, la tarea 2 (autorizar su clave) puede fallar por depender de algo que en modo simulación nunca llegó a existir. No es un bug del playbook, es un límite conocido del propio modo --check.-u pierde frente a ansible_user en group_vars; para forzar un usuario de verdad (primer contacto con una máquina nueva) hace falta -e, que sí tiene precedencia por encima.--check antes de confiar en un --skip-tags nuevo, siempre.Referencia de uso diario — para no tener que ir a buscarlo al repo.
# Qué cambiaría, sin tocar nada: ansible-playbook site.yml --check --diff # Solo comprobar que el YAML es válido, sin conectar a ninguna máquina: ansible-playbook site.yml --syntax-check
Si se creó a mano: una línea en inventory.ini, bajo el grupo que corresponda ([linux], [windows] o [homelab]). Si viene de Terraform, no hace falta tocar nada — se apunta directamente al inventario que genera solo en cada apply:
ansible-playbook site.yml --limit nombre_maquina_nueva ansible-playbook site.yml -i ../homelab-terraform/inventory_generado.ini --limit nombre_maquina_nueva
En una máquina recién creada por Terraform, el usuario de gestión todavía no existe — el primer contacto necesita -e ansible_user=root (ver Problemas, más arriba).
# Cifrar un archivo de variables: ansible-vault encrypt group_vars/all/vault.yml # Editarlo (lo descifra, abre el editor, vuelve a cifrar al guardar): ansible-vault edit group_vars/all/vault.yml # Ejecutar un playbook que usa variables cifradas: ansible-playbook site.yml --ask-vault-pass
Cada rol lleva su propia tag, con el mismo nombre — base, usuario_ansible, usuario_hugo, hardening_ssh, base_windows:
# Solo un rol concreto: ansible-playbook site.yml --tags usuario_hugo # Todo menos ese: ansible-playbook site.yml --skip-tags hardening_ssh # Reanudar desde una tarea concreta si algo falló a mitad: ansible-playbook site.yml --start-at-task="Instalar fail2ban"
Un tag que no existe en site.yml no da error — --tags/--skip-tags simplemente no encuentran nada que coincida, y el playbook corre entero como si el flag no estuviera puesto. Verificar en seco antes de fiarse de un filtro nuevo:
ansible-playbook site.yml --limit <host> --skip-tags hardening_ssh --check --ask-vault-pass
Las tareas del rol saltado deben aparecer como skipping, no ok/changed.
Nunca un archivo <grupo>.yml y una carpeta <grupo>/ a la vez para el mismo grupo — uno de los dos se ignora sin avisar. Si separas variables normales de secretos con vault, todo dentro de la carpeta:
group_vars/ ├── all/ │ ├── vars.yml # variables normales, en claro │ └── vault.yml # secretos, cifrados └── windows/ ├── vars.yml └── vault.yml
Una clave más en la lista de group_vars/all/vars.yml (o crear la variable de esa persona si es la primera vez) y volver a lanzar el playbook — no hace falta tocar el rol.
hugo_ssh_pubkeys: - "ssh-rsa AAAA... clave-portatil" - "ssh-rsa AAAA... clave-casa"
El hash de la contraseña de sudo, siempre cifrado en group_vars/all/vault.yml, nunca en claro:
openssl passwd -6 # pide la contraseña por teclado y devuelve el hash SHA-512
El nombre del grupo local depende del idioma de instalación de Windows — en español es Administradores, no Administrators. Comprobar dentro de la VM si falla:
Get-LocalGroup | Select-Object Name
El proyecto completo — roles, playbooks y documentación — es público: