Desplegando Rails 8 a staging automáticamente con Kamal y un runner autoalojado de GitHub Actions
Se me seguía olvidando desplegar a staging. Mergeaba una rama, seguía con lo siguiente, y tres días después me preguntaba por qué staging estaba desactualizado. La solución era obvia, automatizar los despliegues, pero lo postergué porque asumí que sería complicado.
Era complicado. Solo que no de las formas que esperaba.
Lo que intentaba hacer
blog-manager es una app de Rails 8 que despliego con Kamal 2 a un servidor del homelab. Staging corre en un contenedor LXC separado en el mismo host de Proxmox. Quería que cada merge a main desplegara automáticamente a staging, manteniéndolo siempre actualizado.
Por qué un runner autoalojado
Los runners alojados por GitHub no pueden alcanzar blog-manager-staging.internal; es una dirección de LAN privada. Y aunque hubiera un túnel, igual necesitaría Docker disponible en el runner para que Kamal pudiera construir y subir la imagen.
La respuesta limpia es un runner autoalojado en la LAN del homelab. Tiene acceso directo al host de staging, Docker ya está instalado, y controlo el entorno por completo.
Configuré un nuevo contenedor LXC en Proxmox (Debian 13), instalé el runner de GitHub Actions como un servicio systemd, y lo registré con la etiqueta blog-manager-staging. El workflow lo apunta con runs-on: [self-hosted, blog-manager-staging].
El primer obstáculo: Ruby
El workflow necesitaba bundle install para obtener las dependencias de gemas de Kamal. Mi primer intento usó actions/setup-ruby@v1, y falla en runners autoalojados. Busca Ruby en $RUNNER_TOOL_CACHE, que no existe a menos que se haya configurado la infraestructura de caché de herramientas.
La solución: instalar Ruby directamente en el runner usando mise.
mise settings ruby.compile=false # use prebuilt binaries, don't compile from source
mise use --global ruby@3.3.6
La configuración ruby.compile=false importa. Sin ella, mise intenta compilar Ruby desde el código fuente, lo que toma más de 20 minutos en un contenedor de specs bajas. Con binarios precompilados, 30 segundos.
Después agregué la ruta del bin de Ruby al archivo .env del runner (~/actions-runner/.env), que define variables de entorno para cada job.
El segundo obstáculo: OOM
bundle install con extensiones nativas de gemas necesita memoria. El contenedor LXC tenía 512MB de RAM y sin swap (los contenedores LXC basados en ZFS no pueden usar archivos de swap). Código de salida 137. OOM kill.
La solución fue subir la RAM del contenedor en el host de Proxmox:
pct set 1028 -memory 2048
Proxmox aplica esto en caliente, sin necesidad de reiniciar. 2GB es cómodo para bundle install con extensiones nativas.
El tercer obstáculo: permisos de Docker
Kamal necesita Docker para construir y subir la imagen. El runner corre como gh-runner, que no estaba en el grupo docker. Después de agregarlo y reiniciar el servicio del runner, Kamal pudo autenticarse en el registry y empezar la construcción.
El cuarto obstáculo: el que más costó
Con Docker funcionando, la imagen se construyó y se subió sin problemas. Pero el contenedor seguía fallando su chequeo de salud:
ArgumentError: Missing `secret_key_base` for 'production' environment
secret_key_base está en las credenciales de Rails, que necesitan RAILS_MASTER_KEY para desencriptarse. El workflow ya estaba definiendo esto como variable de entorno. Entonces, ¿por qué no estaba llegando al contenedor?
Kamal inyecta secretos en el contenedor leyendo .kamal/secrets-common, resolviendo los valores, y escribiéndolos en un archivo en el host remoto. Ese archivo es lo que el contenedor lee al arrancar.
El archivo de secretos decía: RAILS_MASTER_KEY=$(cat config/master.key)
En el runner, config/master.key está en el gitignore y no existe después del checkout. Entonces cat falla, RAILS_MASTER_KEY queda vacío, y Kamal escribe un valor vacío en el archivo de entorno del contenedor.
Mi primer intento fue recurrir a la variable de entorno si el archivo no existía:
RAILS_MASTER_KEY=${RAILS_MASTER_KEY:-$(cat config/master.key)}
Esto tampoco funcionó. Después de revisar el código fuente de Kamal, esta es la razón: Kamal evalúa los archivos de secretos usando Dotenv.parse, no un subproceso de bash. Dotenv maneja $(cmd) ejecutándolo como un subproceso de Ruby con backticks (que hereda el entorno del sistema). Pero ${VAR:-fallback} es la sustitución de variables propia de dotenv, y solo ve el entorno local de dotenv, no las variables de entorno del workflow.
Esto también explica por qué el login del registry funcionaba todo el tiempo: KAMAL_REGISTRY_PASSWORD=$(bin/rails credentials:fetch ...) usa la sintaxis $(cmd), así que el subproceso hereda el RAILS_MASTER_KEY real. Un estado a medias exasperante: la imagen se construye y se sube, pero el contenedor no arranca.
La solución: escribir config/master.key a partir del secreto antes de correr kamal deploy.
- name: Write Rails master key
env:
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: echo "$RAILS_MASTER_KEY" > config/master.key
- name: Deploy to staging
env:
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: bin/kamal deploy -d staging
El workflow final
name: Deploy staging
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-staging
cancel-in-progress: false
jobs:
deploy:
runs-on: [self-hosted, blog-manager-staging]
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
- name: Install gems
run: bundle install
- name: Write Rails master key
env:
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: echo "$RAILS_MASTER_KEY" > config/master.key
- name: Deploy to staging
env:
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
run: bin/kamal deploy -d staging
Lo que me habría dicho a mí mismo antes de empezar
No usar las actions de setup-ruby en un runner autoalojado. Instalar Ruby directamente con mise y ponerlo en el PATH del runner mediante .env. Definir ruby.compile=false primero, o habrá que esperar 20 minutos por una compilación desde el código fuente.
Darle al contenedor al menos 2GB de RAM. 512MB no alcanza para bundle install. Las gemas nativas necesitan espacio para compilar. El swap de ZFS no funciona en LXC.
Agregar gh-runner al grupo docker. Reiniciar el servicio del runner después.
Entender cómo Kamal lee los secretos. Usa dotenv, no bash. Las variables de entorno del entorno de CI no están disponibles dentro de ${VAR:-fallback} en el archivo de secretos. Escribir config/master.key a partir del secreto de CI antes de correr kamal deploy. Que el login del registry funcione mientras el arranque del contenedor falla es la pista reveladora; esa asimetría viene de la diferencia entre los subprocesos $(cmd) (que heredan el entorno) y la sustitución de variables de dotenv (que no lo hace).
El despliegue a staging ahora corre automáticamente en cada merge a main. Staging siempre está al día. No he vuelto a pensar en eso desde entonces.
Lecturas relacionadas
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.
Un ticket de hardening que hubo que re-derivar antes de poder implementarlo
Una tarea desactualizada que habría deshecho la migración de CI, un arreglo de master key que escaló de reducir la ventana a cerrarla del todo, y un parser que se traga en silencio una sintaxis que parecía razonable.