TPTito Pinardo Gutiérrez
← Artículos
English

Mi propio CI/CD con Forgejo: un runner sin secretos, despliegue por PR y esta web publicada sola

La infraestructura de mi homelab vivía en un repositorio sin interfaz y nadie revisaba nada antes de que entrara. En un día lo pasé a Forgejo: CI en cada push en un runner sin un solo secreto, cambios de infraestructura por PR con el plan a la vista, despliegue con un clic y, al final, esta web desplegándose sola.

  • Forgejo · Actions
  • Git
  • Terraform
  • Ansible
  • Rust

Por qué

La infraestructura vivía en un repositorio sin interfaz, copiado a otro de respaldo por un hook. Funcionaba, pero nadie miraba nada antes de que entrara: un fichero de Terraform mal formateado o un secreto en claro se copiaban tal cual a los dos sitios.

Después de auditar el día 5 todo lo que tenía publicado en busca de fugas, lo que más valía era que gitleaks pasara solo en cada push. Para eso hacía falta un servidor Git con CI. Elegí Forgejo: un binario y SQLite, que caben en un contenedor con un gigabyte de memoria.

Dos runners, separados por lo que pueden tocar

El runner del CI vive en su propio contenedor y no tiene nada que robar: ni la clave de la API de Proxmox, ni el estado de Terraform, ni la contraseña de los vaults. Comprueba el formato y la validez de Terraform sin estado ni credenciales, pasa ansible-lint, comprueba que cada vault esté cifrado y pasa gitleaks sobre todo el historial. Nunca aplica nada.

Algunas herramientas no arrancan sin ciertos ficheros, así que el runner tiene una contraseña de vault y una clave SSH falsas a propósito. Dejan arrancar y no abren nada.

El runner de despliegue vive en el nodo de control, que ya podía tocarlo todo, y solo atiende a su repositorio. No da más acceso del que ya había.

Los cuatro tropiezos del primer día

El primer CI salió en rojo, y era lo esperado. terraform validate no usa estado, pero sí evalúa file(), y el código lee una clave pública que en el runner no existía. Se arregló con el fichero falso.

Los trabajos se quedaban «en espera» sin un error. El runner se había registrado sin etiqueta, así que no casaba con ningún runs-on. Al ponérsela siguió igual: solo vuelve a pedir trabajo cuando cambia la cola del servidor. Bastó con reiniciarlo una vez.

Forgejo no arrancaba sin un secreto de OAuth2, aunque OAuth2 estuviera apagado. Ahora se genera la primera vez en el propio contenedor, como los demás secretos internos, y no sale de ahí.

ansible-lint daba en el runner un aviso que en mi terminal no salía: en el nodo de control faltaba una colección y ese rol se saltaba sin decirlo. Desde entonces la referencia es el CI, que parte de una instalación limpia.

Y al dar de alta las claves SSH de mis equipos, dos commits salieron en rojo por líneas de más de 160 caracteres: las propias claves. Llegaron los dos avisos ❌ a Element y el arreglo salió en verde. Aquel día salieron ocho avisos y llegaron los ocho.

Infraestructura por PR, con el plan a la vista

La rama principal de la infraestructura no admite pushes: todo entra por PR con el CI en verde. En cada PR, el runner de despliegue calcula el plan de Terraform y pasa en modo ensayo los playbooks afectados por los ficheros cambiados. El resumen queda como comentario: antes de fusionar ya sé qué se crea, qué cambia y qué se recrearía.

Fusionar no aplica nada. Sale el plan de la rama principal, lo leo en el móvil y lo apruebo con un clic. Se aplica ese plan guardado: si el estado cambió entretanto, Terraform se niega en vez de hacer otra cosa.

Lo que no se lanza solo está en una lista, con su motivo: el propio nodo de control, Forgejo, el host y el mantenimiento. Y no uso --diff a propósito: las plantillas llevan secretos y acabarían en el registro, en el PR y en Element.

🧭 Plan del PR #1
Terraform (homelab): 1 update
  ~ proxmox_virtual_environment_container.forgejo_runner
Ansible monitoring.yml (forgejo-runner): 0 tarea(s) cambiarian
A mano (no se lanza solo): forgejo.yml — reinicia Forgejo y el runner de despliegue

Esta web, de un push a internet

Esta web era lo siguiente. Su origen pasó a Forgejo y su CI al mismo runner, que ahora también tiene Rust, un PostgreSQL solo por socket para las pruebas y Node para comprobar el JavaScript minificado. Compilar pide unos 1,3 GB de memoria con dos trabajos a la vez: cabe en los 2 GB del runner, y lo compilado se reutiliza entre ejecuciones.

Aquí no hay clic: con el CI en verde en main, el runner de despliegue pasa un control de fugas a todo lo que aún no es público. Busca con gitleaks, exige que el único autor sea mi identidad pública y rechaza direcciones o nombres internos. Después compila, despliega con Ansible, espera a que la web responda y solo entonces empuja ese commit a GitHub.

Así, la copia pública nunca va por delante de lo desplegado. Si GitHub tuviera algo que Forgejo no tiene, el despliegue se para en vez de forzar nada.

Resultado

Cada push, de la infraestructura o de esta web, pasa por el CI, y su resultado llega a Element cifrado. Los cambios de infraestructura se ven antes de fusionar y se aplican con el plan exacto. Esta web va sola de un push a internet, y GitHub queda como escaparate.

Lo siguiente: que los planes que solo actualizan se apliquen solos, y que los que destruyen o recrean sigan esperando mi clic.