Pular para o conteúdo
Development

Deploy automático do Rails 8 para staging com Kamal e um runner self-hosted do GitHub Actions

Por Victor Da Luz
railskamalgithub-actionscidev-logblog-manager

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

Você também pode achar útil

Airalo

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 mais
RackNerd

RackNerd VPS

Hospedagem VPS econômica para serviços leves que funcionam continuamente.

Como afiliado da RackNerd, ganho com compras qualificadas.

Saiba mais
NordPass

NordPass

Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.

Como afiliado da NordPass, ganho com compras qualificadas.

Saiba mais