Deploy automático do Rails 8 para staging com Kamal e um runner self-hosted do GitHub Actions
Eu sempre esquecia de fazer deploy para staging. Dava merge numa branch, seguia em frente, e três dias depois ficava pensando por que o staging estava desatualizado. A solução é óbvia, tornar os deploys automáticos, mas eu adiava porque supunha que seria complicado.
E era complicado. Só que não do jeito que eu esperava.
O que eu estava tentando fazer
blog-manager é um app Rails 8 que eu faço deploy com o Kamal 2 para um servidor do homelab. O staging roda num contêiner LXC separado no mesmo host Proxmox. Eu queria que todo merge na main disparasse deploy automático para staging, mantendo-o sempre atualizado.
Por que um runner self-hosted
Runners hospedados pelo GitHub não conseguem alcançar blog-manager-staging.internal, é um endereço de LAN privado. E mesmo com um túnel, eu ainda precisaria do Docker disponível no runner para o Kamal construir e enviar a imagem.
A resposta limpa é um runner self-hosted na LAN do homelab. Ele tem acesso direto ao host de staging, o Docker já está instalado, e eu controlo o ambiente por completo.
Criei um novo contêiner LXC no Proxmox (Debian 13), instalei o runner do GitHub Actions como um serviço systemd, e o registrei com o rótulo blog-manager-staging. O workflow o direciona com runs-on: [self-hosted, blog-manager-staging].
A primeira parede: Ruby
O workflow precisava de bundle install para buscar as dependências de gems do Kamal. Minha primeira tentativa usou actions/setup-ruby@v1, e isso falha em runners self-hosted. Ele procura o Ruby em $RUNNER_TOOL_CACHE, que não existe a menos que você tenha configurado a infraestrutura de tool cache.
A solução: instalar o Ruby diretamente no runner usando o mise.
mise settings ruby.compile=false # use prebuilt binaries, don't compile from source
mise use --global ruby@3.3.6
A configuração ruby.compile=false importa. Sem ela, o mise tenta compilar o Ruby a partir do código-fonte, o que leva mais de 20 minutos num contêiner de baixa especificação. Com binários pré-compilados, 30 segundos.
Depois adicionei o caminho do bin do Ruby ao arquivo .env do runner (~/actions-runner/.env), que define variáveis de ambiente para todo job.
A segunda parede: OOM
bundle install com extensões nativas de gems precisa de memória. O contêiner LXC tinha 512MB de RAM e nenhum swap (contêineres LXC baseados em ZFS não conseguem usar arquivos de swap). Código de saída 137. Morte por OOM.
A solução foi aumentar a RAM do contêiner no host Proxmox:
pct set 1028 -memory 2048
O Proxmox aplica isso ao vivo, sem precisar reiniciar. 2GB é confortável para o bundle install com extensões nativas.
A terceira parede: permissões do Docker
O Kamal precisa do Docker para construir e enviar a imagem. O runner roda como gh-runner, que não estava no grupo docker. Depois de adicioná-lo e reiniciar o serviço do runner, o Kamal conseguiu se autenticar no registry e começar o build.
A quarta parede: a que demorou mais
Com o Docker funcionando, a imagem foi construída e enviada com sucesso. Mas o contêiner continuava falhando no health check:
ArgumentError: Missing `secret_key_base` for 'production' environment
secret_key_base fica nas credentials do Rails, que precisam de RAILS_MASTER_KEY para descriptografar. O workflow já estava definindo isso como uma variável de ambiente. Então por que não estava chegando ao contêiner?
O Kamal injeta secrets no contêiner lendo .kamal/secrets-common, resolvendo os valores, e escrevendo-os num arquivo no host remoto. É esse arquivo que o contêiner lê na inicialização.
O arquivo de secrets dizia: RAILS_MASTER_KEY=$(cat config/master.key)
No runner, config/master.key está no gitignore e não existe depois do checkout. Então o cat falha, RAILS_MASTER_KEY fica vazio, e o Kamal escreve um valor vazio no arquivo de env do contêiner.
Minha primeira tentativa foi recorrer à variável de ambiente caso o arquivo não existisse:
RAILS_MASTER_KEY=${RAILS_MASTER_KEY:-$(cat config/master.key)}
Isso também não funcionou. Depois de vasculhar o código-fonte do Kamal, eis o motivo: o Kamal avalia os arquivos de secrets usando Dotenv.parse, não um subprocesso bash. O dotenv trata $(cmd) rodando como um subprocesso Ruby via crase (que herda o ambiente do sistema). Mas ${VAR:-fallback} é a substituição de variável própria do dotenv, e ela só enxerga o ambiente local do dotenv, não as variáveis de ambiente do workflow.
É também por isso que o login no registry funcionou o tempo todo: KAMAL_REGISTRY_PASSWORD=$(bin/rails credentials:fetch ...) usa a sintaxe $(cmd), então o subprocesso herda o RAILS_MASTER_KEY de verdade. Um estado enlouquecedor de meio-funcionando, a imagem constrói e envia, mas o contêiner falha ao iniciar.
A solução: escrever config/master.key a partir do secret antes de rodar o 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
O 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
O que eu diria a mim mesmo antes de começar
Não tente usar as actions de setup-ruby num runner self-hosted. Instale o Ruby diretamente com o mise e coloque no PATH do runner via .env. Defina ruby.compile=false antes, ou você vai esperar 20 minutos por um build a partir do código-fonte.
Dê ao contêiner pelo menos 2GB de RAM. 512MB não é suficiente para o bundle install. Gems nativas precisam de espaço para compilar. Swap em ZFS não funciona em LXC.
Adicione gh-runner ao grupo docker. Reinicie o serviço do runner depois.
Entenda como o Kamal lê os secrets. Ele usa dotenv, não bash. Variáveis de ambiente do seu ambiente de CI não ficam disponíveis dentro de ${VAR:-fallback} no arquivo de secrets. Escreva config/master.key a partir do seu secret de CI antes de rodar kamal deploy. O login do registry funcionando enquanto a inicialização do contêiner falha é o sinal, essa assimetria vem da diferença entre subprocessos $(cmd) (herdam o ambiente) e a substituição de variável do dotenv (não herda).
O deploy de staging agora roda automaticamente em todo merge na main. O staging está sempre atualizado. Não pensei mais nisso desde então.
Leitura relacionada
Movendo o CI para um runner self-hosted depois que o GitHub quebrou nosso billing
CI morto, deploys ao vivo: dobrando todo job no runner do homelab, apagando a maquinaria de compensação de runner hospedado, e o backlog de CVEs esperando atrás do gate.
Um playbook de falhas de CI para um projeto Rails de uma pessoa só
Escrevendo as regras do que fazer quando o CI fica vermelho num projeto Rails solo, e a limitação do GitHub que transformou o gate de merge num comentário estrutural.
Um ticket de hardening que precisou ser re-derivado antes de eu poder implementá-lo
Uma tarefa desatualizada que desfaria a migração de CI, uma correção de master key que foi de encolher a janela a fechá-la de vez, e um parser que engole em silêncio sintaxe plausível.
Você também pode achar útil
eSIM Airalo
eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.
Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.
Saiba maisRackNerd VPS
Hospedagem VPS econômica para serviços leves que funcionam continuamente.
Como afiliado da RackNerd, ganho com compras qualificadas.
Saiba maisNordPass
Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.
Como afiliado da NordPass, ganho com compras qualificadas.
Saiba mais