En Mi homelab como código terminaba con una lista de cosas pendientes, y la primera era esta: CI/CD en mi Forgejo, con plan al abrir una PR y apply al hacer merge. Ya está hecho. Bueno, está hecho algo: al ponerme, acabé montando otra cosa, y creo que es lo que de verdad me hacía falta.
Lo que prometí y por qué cambié de idea
El flujo de PR, plan comentado, revisión, merge y apply automático es el habitual en los equipos que trabajan con Terraform, y ahí tiene todo el sentido: hay varias personas, cambios que otro tiene que revisar y alguien que responde de lo que se aplica. Cuando me puse a montarlo, me di cuenta de que en mi caso, trabajando solo y directamente en main, casi nada de eso encaja. Y el apply en la CI tiene dos problemas en un homelab:
- El runner pasaría a ser la pieza más delicada de la casa. Para aplicar necesita tokens que pueden crear y borrar VMs. Cualquiera que consiga ejecutar algo en él tendría las llaves de todo.
- Todo vive en el mismo Proxmox que gestiona. Forgejo, el runner, el Postgres del estado e Infisical son VMs de ese Proxmox. Si se rompe alguna de esas piezas, la CI no puede aplicar justo cuando más falta hace, así que el
tfi applydesde mi máquina tiene que seguir funcionando de todas formas.
Así que le di la vuelta: la CI mira, pero no aplica. Comprueba lo que subo y, sobre todo, me avisa de la deriva (drift): cuando lo que hay en el repo y lo que hay de verdad dejan de coincidir. El apply lo sigo lanzando yo.
Un runner que también es código
Ya tenía un runner de Forgejo Actions, pero era una VM montada a mano, justo lo contrario de lo que intento hacer con el resto. Lo he rehecho dentro del repo: una VM más en el terraform.tfvars y un rol de Ansible, forgejo_runner, que descarga una versión fijada del binario, comprueba su checksum, lo registra en Forgejo y lo deja como servicio de systemd. Cada job corre en su propio contenedor Docker.
Hay dos detalles de la configuración que merece la pena contar:
runner:
labels:
- "ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest"
- "homelab-iac:docker://node:22-bookworm"
# ...
container:
privileged: false
docker_host: "-" # los jobs no ven el socket de Docker
valid_volumes: []
- Las etiquetas van en
config.yml, no en el registro. Las del fichero ganan a las que se dieron al registrar el runner, así que cambiarlas es editar el rol y relanzarlo, sin volver a registrar nada. - Los jobs no tienen el socket de Docker. Un contenedor con el socket es, en la práctica, root en la máquina. Como estos jobs llevan secretos de la infraestructura, prefiero que no puedan salir de su contenedor.
La caché de actions la he dejado apagada. El servidor de caché escucha en la propia VM, así que habría que abrir un puerto en UFW para que los contenedores llegaran a él, y no me hace falta.
Lint en cada push
Lo primero, y lo más fácil, es comprobar en cada push lo que no necesita secretos: terraform fmt -check, terraform validate (con init -backend=false, sin tocar el estado), --syntax-check de todos los playbooks y ansible-lint.
La CI instala las mismas versiones que uso en local, comprobando los checksums. El primer job salió en rojo justo por eso: Ansible 14 pide Python 3.12, y la imagen de Debian bookworm trae la 3.11. Lo resolví instalándolo con uv, que es como lo instalo en local y que además se descarga él solo el Python que haga falta.
Meter ansible-lint costó más, y lo cuento más abajo.
Deriva, cada noche
Esta es la parte que más me interesaba. Un workflow programado lanza cada noche un terraform plan de los dos módulos (Proxmox y Cloudflare) contra main, con -detailed-exitcode:
| Código | Significa |
|---|---|
0 | No hay cambios: la realidad coincide con el repo |
2 | Hay cambios: algo se ha tocado fuera del repo, o hay algo en main sin aplicar |
1 | El plan ha fallado (credenciales, red, código) |
Con un 2, el job sale en rojo con el plan en el log. Pasa si cambio la memoria de una VM desde la web de Proxmox, si edito un registro en el panel de Cloudflare o si hago apply desde una copia sin git pull. Es la misma idea del un plan sin cambios es el mejor test del otro post, pero comprobada cada noche y sin tener que acordarme.
Credenciales que solo leen
Si la CI solo mira, sus credenciales deberían poder solo mirar. Sus secretos están en una carpeta aparte de Infisical, /ci, y el workflow entra con una machine identity propia, no con mi sesión.
Para el estado creé en Postgres un usuario que solo tiene SELECT sobre las tablas de Terraform. Y aquí vino la sorpresa: un plan solo necesita leer el estado, pero al hacer init el backend pg intenta crear el esquema, la tabla y el índice si no existen, y eso necesita unos permisos que este usuario no tiene. El backend trae opciones para saltarse ese paso:
terraform init -input=false -lock=false \
-backend-config=skip_schema_creation=true \
-backend-config=skip_table_creation=true \
-backend-config=skip_index_creation=true
terraform plan -input=false -lock=false -detailed-exitcode
El -lock=false no es por los permisos. Un plan no escribe nada, así que no le hace falta bloquear el estado, y así el plan nocturno nunca se queda con el bloqueo justo cuando yo quiero aplicar.
Cloudflare también tiene su token propio, que solo puede leer el DNS de la zona.
La excepción
El token de Proxmox de la CI, de momento, no es de solo lectura: es un token sin limitar del usuario de Terraform. Lo sé y lo he aceptado por ahora, porque el runner es una VM dedicada, sin nada más, y el repo solo lo toco yo. El riesgo es que, si alguien consiguiera ejecutar código en ese runner, podría cambiar VMs aunque el workflow solo haga plan.
Arreglarlo es poco trabajo: un token con separación de privilegios y un rol de solo auditoría. Tiene un detalle: el provider lee las IPs de las VMs a través del agente de QEMU, así que, además de los permisos *.Audit de siempre, el rol necesita poder consultar el agente (VM.GuestAgent.Audit en Proxmox 9, VM.Monitor en la 8). Es lo primero que haré antes de abrir más la CI.
Avisos al móvil, con mi propio ntfy
Un job en rojo en Forgejo no sirve de mucho si nadie entra a mirarlo, así que hacía falta un aviso. Elegí ntfy: un curl desde el workflow y una notificación en el móvil.
Lo más rápido era usar el servicio público, ntfy.sh, con un topic largo y aleatorio. Pero ahí el topic hace de contraseña y los avisos salen de casa, así que monté mi propio servidor:
Una instancia de ntfy en la VM de monitorización, desplegada con un rol de Ansible, igual que Loki y Grafana.
Sin acceso anónimo:
auth-default-access: deny-ally sin registro desde la web.Usuarios, permisos y tokens declarados en
server.yml, que ntfy sincroniza con su base al arrancar. Así los gestiona Ansible, y no un comando suelto que nadie recuerda haber lanzado:auth-default-access: deny-all auth-users: - "victor:<hash bcrypt>:admin" - "ci:<hash bcrypt>:user" auth-access: - "ci:homelab-iac:wo" # la CI solo puede publicar, y solo en su topic auth-tokens: - "ci:<token>:Forgejo Actions"Publicado en Internet a través de mi Cloudflare Tunnel. No le puse Cloudflare Access delante porque la app de ntfy no sabe pasar por su login, así que lo protege la autenticación del propio ntfy. La CI, en cambio, publica por la red interna, sin pasar por Cloudflare.
El script de aviso distingue entre que haya deriva y que el plan haya fallado, con títulos y prioridades distintos, y al pulsar la notificación se abre la ejecución en Forgejo.
La letra pequeña de iOS
Con un servidor propio y un iPhone hay una pieza que no es evidente. iOS no deja que una app mantenga en segundo plano su propia conexión con tu servidor, así que las notificaciones tienen que pasar por Apple. ntfy lo resuelve con upstream-base-url: https://ntfy.sh.
Al publicar un mensaje, mi servidor avisa a ntfy.sh, pero sin mandarle el contenido: solo le pasa un hash de la URL del topic y el ID del mensaje. ntfy.sh hace que Apple despierte la app, y la app se descarga el mensaje de verdad desde mi servidor. El contenido nunca pasa por ntfy.sh.
Las primeras pruebas no me llegaron. Activé un momento el log en modo debug y se veía la cadena entera: el Publishing poll request to https://ntfy.sh/<hash> en el mismo segundo del envío y, cinco segundos después, el iPhone pidiendo ese mensaje concreto. Desde entonces llegan todos.
Lo que me ha mordido
ansible-lint --fixreescribe todos los YAML. Quería que me pusiera los nombres completos de los módulos (ansible.builtin.apten vez deapt). Lo hizo, pero de paso pasó todos los ficheros por su formateador: juntó los comentarios que tenía alineados en el inventario, cambió comillas y partió por la mitad las URLs largas. También tocó elsecret.yml, que no está en git y no podía deshacer; sigue cargando bien, pero ya no es el fichero que era. Deshice el resto con git e hice el cambio con un script mío, que lee del propioansible-lintla línea de cada aviso y cambia solo esa palabra: 111 cambios y ninguno más.- El modo debug de ntfy deja los tokens en el log. La web de ntfy pasa el token de la sesión como parámetro en la URL del websocket (
?auth=...), y en debug el log lo recoge. Y Alloy manda ese log a Loki, donde se queda 30 días. Después del diagnóstico, toca cerrar sesión en la web para invalidar el token. - El CNAME lo crea el tunnel. Al dar de alta un hostname público en un Cloudflare Tunnel gestionado desde el panel, Cloudflare crea él solo el registro DNS. Si Terraform lo creara antes, chocarían. El orden bueno es el contrario: primero el tunnel y después importar el registro en Terraform, hasta que el
plandiga1 to import, 0 to add, 0 to change. Es lo mismo que hice con los 50 registros del principio, y casi se me olvida. - La API de mi Forgejo no devuelve los logs de los jobs. Desde la terminal veo si una ejecución ha ido bien o mal, pero el log solo está en la web. Para depurar el primer fallo, reproduje el job a mano en el runner, con la misma imagen y los mismos pasos.
Lo que queda
- El token de Proxmox de solo lectura, lo dicho arriba.
- Retirar el runner antiguo, cuando compruebe que todo lo que corría en él funciona en el nuevo.
- El plan en las PR, pero solo cuando empiece a trabajar con ramas. Ya sé cómo lo quiero: planificar solo los módulos que cambian, un único comentario por PR que se actualice en cada push, una alarma si el plan quiere destruir o recrear algo, y
mainprotegida para que no se pueda hacer merge sin lint y plan en verde. El requisito previo es, otra vez, el token de solo lectura: una PR ejecuta el código de su rama, así que quien pueda subir una rama puede cambiar el workflow.
La CI no ha cambiado cómo trabajo: sigo haciendo plan y apply desde mi devbox. Lo que ha cambiado es que ahora, si algo se desvía, me entero esa misma noche y no el día que vaya a tocarlo.