Desenredando la dependencia de Colima en los deploys de Kamal
Cada vez que necesitaba lanzar un deploy de producción para blog-manager, tenía que acordarme de una cosa: ¿está corriendo Colima? Si no, kamal deploy simplemente se quedaba colgado. El daemon local de Docker de mi Mac era una pieza estructural del camino de deploy a producción, y se me seguía olvidando.
La causa raíz: config/deploy.yml tenía builder.remote: ssh://kamal@blog-manager.internal. Kamal se conectaba por SSH al LXC de producción, pero necesitaba un daemon de Docker local para el contexto de buildx. Sin Colima, no había build, ni deploy.
Staging tenía el mismo problema, solo que mejor escondido. El workflow de deploy de staging corría en un runner autoalojado dentro del LXC de staging y llamaba a bin/kamal deploy -d staging, que usaba builder.remote: ssh://kamal@blog-manager-staging.internal para construir la imagen contra sí mismo. Funcionaba, pero cada merge producía una imagen recién construida que nunca se probaba en un runner de GitHub Actions, y que se volvía a construir otra vez en la máquina de desarrollo al hacer deploy a producción. “Pasó staging” era, técnicamente, una señal de confianza sin ningún significado real.
La solución es el patrón estándar build-once-deploy-many de Kamal: CI construye y sube la imagen una sola vez, y cada entorno hace pull y deploy de esa misma imagen.
El plan
Tres piezas:
- Un nuevo workflow
build.ymlque construye y subegitea.example.net/vic/blog-manager:<sha>en cada push amain. Los PR obtienen una corrida solo de build (sin push) para detectar Dockerfiles rotos antes. - Un
deploy-staging.ymlreescrito que se encadena abuild.ymlvíaworkflow_runy hace deploy con--skip-push --version=<sha>en vez de construir localmente. - Quitar
builder.remotede ambas configuraciones de deploy.
Para producción: bin/kamal deploy --skip-push --version=<sha> desde cualquier máquina. Sin Colima. Sin daemon de Docker local. Solo acceso SSH al host.
Tres cosas en las que la investigación se equivocó
La descripción original del issue decía que había que hacer push a registry.internal/blog-manager; en realidad eso es un caché pull-through de Docker Hub, no un destino de push. El registro real es la instancia de Gitea (contenedor 1009 en Proxmox).
La investigación también decía que Gitea era accesible públicamente vía Cloudflare. No lo es. dig +short gitea.example.net @1.1.1.1 devuelve 192.168.x.x, una dirección RFC1918 interna. El runner ubuntu-latest alojado por GitHub no puede alcanzarla. Lo descubrí de la peor manera, cuando el primer intento de build se agotó por timeout tratando de iniciar sesión en el registro. La solución: correr el job de build en el runner [self-hosted, blog-manager-staging], que está dentro del homelab.
La tercera cosa: kamal deploy --skip-push valida que la imagen tenga un label service que coincida con el nombre del servicio en config/deploy.yml. kamal build push lo agrega automáticamente. docker/build-push-action no. El deploy hizo pull de la imagen sin problema, y después la rechazó:
Image gitea.../blog-manager:<sha> is missing the 'service' label
Una línea en el workflow lo arregló:
labels: service=blog_manager
La trampa de dotenv, otra vez
Hay una arruga más con los secretos de Kamal en CI, la misma que ya había complicado la configuración de staging. Kamal evalúa .kamal/secrets-common usando Dotenv.parse, no un subproceso de bash. Eso significa que las variables de entorno de CI no están disponibles dentro de la expansión de parámetros ${VAR:-fallback}, solo en subprocesos $(cmd). La solución alternativa: escribir config/master.key explícitamente desde el secreto de CI antes de correr cualquier comando kamal.
La trampa del caché de capas
El plan pedía un caché type=registry,mode=max; es la respuesta “correcta” para cachear capas de Docker en un registro autoalojado. Excepto que está roto contra Gitea (gitea#28973, el registro de Gitea devuelve un error en PATCH durante el push del caché). type=gha funciona correctamente y no necesita ningún soporte del registro.
Cómo se ve el pipeline ahora
push to main
└─ Build and push image (self-hosted, blog-manager-staging)
└─ Deploy staging (self-hosted, blog-manager-staging, --skip-push)
Deploy de producción: bin/kamal deploy --skip-push --version=<sha>. Staging y producción ahora corren exactamente la misma imagen.
Lo que queda pendiente es el auto-deploy a producción con un gate de aprobación de GitHub Environments. Eso requiere un runner autoalojado en el LXC de producción (1027), la misma configuración que staging pero en el otro host. Planeado para un follow-up.
Lecturas relacionadas
Desplegando Rails 8 a staging automáticamente con Kamal y un runner autoalojado de GitHub Actions
Hacer que cada merge despliegue staging automáticamente: un runner autoalojado, cuatro obstáculos seguidos, y la trampa de los secretos de Kamal que más costó resolver.
Mudar el CI a un runner propio después de que GitHub rompiera la facturación
CI muerto, despliegues vivos: fusionar todos los jobs en el runner del homelab, borrar la maquinaria de compensación para runners alojados, y el backlog de CVEs esperando detrás del portón.
Un manual de fallas de CI para un proyecto Rails de una sola persona
Escribir las reglas de qué hacer cuando el CI se pone en rojo en un proyecto Rails en solitario, y la limitación de GitHub que convirtió la puerta de fusión en un comentario que sostiene todo el peso.