Skip to content
Deploy de uma app Rails 8 em uma VPS com Kamal 2: um guia prático

Deploy de uma app Rails 8 em uma VPS com Kamal 2: um guia prático

Um passo a passo completo para colocar em produção uma app Rails 8 com Vite, Solid Queue e PostgreSQL em uma única VPS Ubuntu usando Kamal 2 e Docker Hub, incluindo os erros que você provavelmente vai encontrar e como resolver cada um."

Publicado em 11 min de leitura

Também disponível em English

Compartilhar

Deploy de uma app Rails 8 em uma VPS com Kamal 2: um guia prático

O Kamal 2 vem junto com o Rails 8 e transforma um servidor Linux comum em um host de produção com deploys sem downtime e HTTPS automático. O caminho feliz é curto. O caminho real tem algumas armadilhas: um lock de deploy esquecido, um Dockerfile sem Node.js, um host de banco que resolve silenciosamente para localhost e o Let's Encrypt se recusando a emitir o certificado. Este guia percorre a configuração completa e trata cada um desses pontos.

Todos os valores deste post são placeholders. Troque pelos seus:

  • IP do servidor: 203.0.113.10
  • Domínios: myblog.example.com e www.myblog.example.com
  • Usuário SSH: deploy
  • Usuário do Docker Hub: yourname
  • Nome do serviço no Kamal: myblog

Como o Kamal 2 serve a sua app

Não existe nginx. Uma requisição passa por dois proxies pequenos antes de chegar ao Puma:

client -> kamal-proxy (:80/:443 no host) -> Thruster (:80 no container) -> Puma

O kamal-proxy cuida do TLS via Let's Encrypt, do roteamento por host e da troca entre versões com health check. O Thruster, incluído na imagem do Rails 8, cuida de compressão e cache de assets. Você configura o kamal-proxy pelo bloco proxy: em config/deploy.yml, e não por um arquivo de configuração no servidor.

Prepare o servidor

O Kamal precisa de SSH e Docker. Instale sua chave pública para o usuário de deploy, depois instale o Docker e adicione o usuário ao grupo docker. O Kamal só consegue instalar o Docker sozinho quando conecta como root.

# na sua máquina
ssh-copy-id -i ~/.ssh/id_rsa.pub deploy@203.0.113.10

# no servidor
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker deploy
sudo ufw allow OpenSSH && sudo ufw allow 80 && sudo ufw allow 443 && sudo ufw enable

Confirme que o login por chave funciona antes de seguir. Se o SSH cair num prompt de senha, o Kamal trava ali, porque não consegue responder prompts interativos:

ssh -i ~/.ssh/id_rsa -o IdentitiesOnly=yes -o PasswordAuthentication=no deploy@203.0.113.10

Se falhar, as causas mais comuns são a chave instalada para o root em vez do usuário de deploy, ou permissões abertas demais em ~/.ssh (use 700 no diretório e 600 no authorized_keys).

Configure o Docker Hub

O Docker Hub é o registry padrão do Kamal, então você só precisa de um usuário e um token. Crie um repositório chamado myblog e depois um personal access token com escopo Read & Write. Esse token é o que o Kamal chama de KAMAL_REGISTRY_PASSWORD. No Mac, dá para guardá-lo no keychain em vez de deixá-lo no histórico do shell:

security add-generic-password -a "$USER" -s dockerhub-kamal -w

O plano gratuito do Docker Hub permite um repositório privado. Evite builder.cache.type: registry, que faz push de um segundo repositório de cache.

Escreva o config/deploy.yml

Esta configuração roda uma role web e uma role job do Solid Queue, faz o build no próprio servidor (builds amd64 nativos e rápidos, mesmo a partir de um Mac com Apple Silicon) e roda o PostgreSQL como accessory do Kamal:

service: myblog
image: yourname/myblog

servers:
  web:
    - 203.0.113.10
  job:
    hosts:
      - 203.0.113.10
    cmd: bin/jobs

proxy:
  ssl: true
  hosts:
    - myblog.example.com
    - www.myblog.example.com
  app_port: 80
  healthcheck:
    path: /up

registry:
  username: yourname          # sem a chave server: usa o Docker Hub por padrão
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64
  remote: ssh://deploy@203.0.113.10

ssh:
  user: deploy
  keys: ["~/.ssh/id_rsa"]
  keys_only: true

env:
  clear:
    DB_HOST: myblog-db
    # NÃO defina SOLID_QUEUE_IN_PUMA aqui. Veja a nota abaixo.
  secret:
    - RAILS_MASTER_KEY
    - POSTGRES_PASSWORD

volumes:
  - "myblog_storage:/rails/storage"

asset_path: /rails/public/vite

logging:
  options:
    max-size: 100m

accessories:
  db:
    image: postgres:18
    host: 203.0.113.10
    env:
      clear:
        POSTGRES_USER: myblog
        POSTGRES_DB: myblog_production
      secret:
        - POSTGRES_PASSWORD
    volumes:
      - "myblog_pgdata:/var/lib/postgresql"
    options:
      shm-size: 256m

aliases:
  console: app exec --interactive --reuse "bin/rails console"
  logs: app logs -f
  dbc: app exec --interactive --reuse "bin/rails dbconsole"

Algumas escolhas que valem explicação:

  • O accessory não tem entrada port, então o PostgreSQL só é acessível pelos containers da rede Docker kamal, pelo nome myblog-db. Nada fica exposto para a internet.
  • A imagem oficial postgres:18 espera o volume em /var/lib/postgresql, e não no antigo /var/lib/postgresql/data.
  • O limite de logging impede que os logs dos containers encham o disco.
  • asset_path aponta para a saída do Vite, para que requisições em andamento não tomem 404 em bundles antigos durante o deploy.
  • Remova SOLID_QUEUE_IN_PUMA por completo quando tiver uma role job dedicada. O config/puma.rb do Rails 8 checa if ENV["SOLID_QUEUE_IN_PUMA"], e a string "false" é truthy em Ruby. Definir como false liga o plugin do Puma, e você acaba com um segundo supervisor do Solid Queue rodando dentro de cada container web.
  • Fazer o build no host de produção é prático numa máquina só, mas o build disputa CPU e memória com a app rodando. Numa VPS pequena, considere uma máquina de build separada ou o CI.

Mantenha os secrets fora do git

env.secret lista nomes. O .kamal/secrets mapeia esses nomes para valores, e deve lê-los de outro lugar em vez de guardá-los literalmente:

KAMAL_REGISTRY_PASSWORD=$(security find-generic-password -a "$USER" -s dockerhub-kamal -w)
RAILS_MASTER_KEY=$(cat config/master.key)
POSTGRES_PASSWORD=$POSTGRES_PASSWORD   # exportada no seu shell ou lida de um gerenciador de senhas

Guarde secrets da aplicação, como chaves de API, nas credentials do Rails, para que o único secret de app que o Kamal manipula seja o RAILS_MASTER_KEY. Reserve o .kamal/secrets para valores de infraestrutura, como o token do registry e a senha do banco.

Aponte o database.yml para o accessory

O bloco de produção padrão não tem host, o que faz o client do PostgreSQL cair para localhost. Dentro de um container, localhost é o próprio container, então a app falha com connection to server at ::1, port 5432 failed. Leia o host do ambiente, com um valor padrão:

production:
  primary: &primary_production
    <<: *default
    host: <%= ENV.fetch("DB_HOST", "myblog-db") %>
    port: 5432
    database: myblog_production
    username: myblog
    password: <%= ENV["POSTGRES_PASSWORD"] %>
  cache:
    <<: *primary_production
    database: myblog_production_cache
    migrations_paths: db/cache_migrate
  queue:
    <<: *primary_production
    database: myblog_production_queue
    migrations_paths: db/queue_migrate
  cable:
    <<: *primary_production
    database: myblog_production_cable
    migrations_paths: db/cable_migrate

O valor padrão importa. ENV.fetch("DB_HOST") sem ele levanta um KeyError durante o build da imagem, porque o assets:precompile carrega o Rake, o Rake renderiza o database.yml e o DB_HOST só existe em runtime.

Adicione Node.js ao Dockerfile por causa do Vite

O Dockerfile gerado pelo Rails não tem Node.js. Se a sua app usa Vite Ruby, o assets:precompile falha com No such file or directory - npx. Adicione o Node só no estágio de build, para a imagem final continuar pequena:

ARG NODE_VERSION=24
FROM docker.io/library/node:${NODE_VERSION}-slim AS node

# ... estágio base sem alterações ...

FROM base AS build
# ... pacotes apt, como gerado ...

COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
    ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx

# ... bundle install, como gerado ...

COPY package.json package-lock.json ./
RUN npm ci --include=dev

COPY . .
RUN bundle exec bootsnap precompile -j 1 app/ lib/

RUN SECRET_KEY_BASE_DUMMY=1 VITE_RUBY_SKIP_ASSETS_PRECOMPILE_INSTALL=true \
    ./bin/rails assets:precompile && rm -rf node_modules

O --include=dev é necessário porque vite e vite-plugin-ruby normalmente são dependências de desenvolvimento. Remover o node_modules no final o mantém fora da imagem de runtime. Adicione também /node_modules/ e /public/vite* ao .dockerignore, para que os binários de macOS do seu node_modules local nunca cheguem a um build Linux.

Faça commit antes do deploy

Esse é o que mais faz as pessoas perderem tempo. O Kamal 2 faz o build a partir de um checkout limpo do HEAD commitado, e não do seu diretório de trabalho, e tagueia cada imagem com o SHA do commit. Se você edita o Dockerfile ou o database.yml e faz deploy sem commitar, o Kamal builda o código antigo de novo e você vê o mesmo erro de antes. Se um deploy falhar com a mesma mensagem depois de uma correção, olhe o git status primeiro.

Deploy

kamal registry login      # valida a autenticação no Docker Hub localmente e no servidor
kamal accessory boot db
kamal accessory logs db   # espere por "ready to accept connections"
kamal setup               # primeiro deploy
kamal deploy              # todos os deploys seguintes

No boot, o entrypoint do Rails 8 roda db:prepare, que cria os bancos de cache, queue e cable e carrega os schemas deles. Depois o Kamal espera o /up responder 200 antes de mandar o tráfego para o novo container.

Os erros que você provavelmente vai ver

mkdir: .kamal/lock-myblog: File exists. Uma execução anterior foi interrompida e deixou o lock de deploy para trás. Confirme que não há outro deploy rodando e então:

kamal lock status
kamal lock release

could not translate host name "myblog-db". O accessory não está rodando, e kamal accessory details db mostra uma lista vazia. Suba o accessory e confirme que o container da app consegue resolvê-lo:

kamal accessory boot db
kamal app exec --roles=web 'getent hosts myblog-db'

open .kamal/apps/myblog/env/roles/job.env: no such file or directory. Este é inofensivo. O arquivo de env de uma role é escrito no primeiro boot bem-sucedido, então comandos avulsos falham para a role job até lá. Adicione --roles=web ao comando.

target failed to become healthy within configured timeout. O container subiu, mas o /up nunca respondeu 200. Leia o kamal app logs. Quase sempre é a conexão com o banco ou um RAILS_MASTER_KEY faltando.

Leve os dados de desenvolvimento para produção

Tire um snapshot da produção primeiro, pare a app e então faça o stream de um dump em formato custom direto para o container do accessory:

kamal app stop

pg_dump -Fc --no-owner --no-acl -d myblog_development \
  | ssh deploy@203.0.113.10 'docker exec -i myblog-db pg_restore -U myblog \
      -d myblog_production --clean --if-exists --no-owner --no-acl'

O dump carrega environment = development no ar_internal_metadata, o que faz as tasks de banco seguintes se recusarem a rodar. Corrija isso, suba a app e confira os dados:

ssh deploy@203.0.113.10 "docker exec myblog-db psql -U myblog -d myblog_production \
  -c \"UPDATE ar_internal_metadata SET value = 'production' WHERE key = 'environment';\""

kamal app boot
ssh -t deploy@203.0.113.10 'docker exec -it myblog-db psql -U myblog -d myblog_production \
  -c "SELECT count(*) FROM posts;"'

O dump leva os registros do Active Storage, mas não os arquivos em si. Copie os uploads separadamente ou mova o Active Storage para o S3.

HTTPS com Let's Encrypt

Com ssl: true e os dois hosts listados, o kamal-proxy solicita o certificado na primeira requisição HTTPS para cada nome. Isso só funciona quando os dois nomes já resolvem para o seu servidor e a porta 80 está aberta. No seu provedor de DNS, crie:

  • Um registro A para myblog.example.com apontando para 203.0.113.10
  • Um registro A para www.myblog.example.com apontando para 203.0.113.10

Remova qualquer registro AAAA, a menos que o servidor tenha IPv6 funcionando, porque o Let's Encrypt prefere IPv6 ao validar. Se usa Cloudflare, mantenha os registros como DNS only até os certificados serem emitidos. Verifique a partir de resolvers públicos, e não do seu cache local:

dig @1.1.1.1 +short myblog.example.com A
dig @1.1.1.1 +short myblog.example.com AAAA   # deve vir vazio
curl -vI https://myblog.example.com 2>&1 | grep -i issuer

Duas mensagens de log são fáceis de interpretar errado. No server name; using default TLS hostname seguida de acme/autocert: missing certificate vem de bots conectando no IP puro sem SNI. É ruído. Já no viable challenge type found é uma falha real: o Let's Encrypt não conseguiu alcançar o seu servidor para aquele nome, normalmente porque o DNS ainda aponta para outro lugar. Corrija o DNS primeiro e depois rode kamal proxy reboot para limpar a falha em cache. O Let's Encrypt permite só 5 validações falhas por hostname por hora, então não fique tentando às cegas.

O kamal-proxy serve os dois nomes, mas não faz redirect entre eles. Adicione o redirect no topo do config/routes.rb:

constraints(host: "www.myblog.example.com") do
  get "(*path)", to: redirect { |_, req| "https://myblog.example.com#{req.fullpath}" }, status: 301
end

O que o Kamal não faz por você

  • Backups. O accessory do banco não tem nenhum. No mínimo, agende um pg_dump noturno para o S3. Para point-in-time recovery, use WAL-G ou pgBackRest.
  • Alta disponibilidade. Um servidor só significa que uma falha de hardware vira downtime. Para qualquer coisa que gere receita, um banco gerenciado como o RDS vale o custo.
  • Upgrades de versão major do PostgreSQL. Ir do 18 para o 19 exige dump e restore, e não só trocar a tag da imagem. Fixe a versão major e nunca use latest.
  • Observabilidade. Envie os logs para um serviço de logs e adicione um agente de APM via variáveis de ambiente.

Conclusão

Depois que tudo funciona, o loop do dia a dia é um commit seguido de kamal deploy. A maior parte do tempo de setup vai para quatro problemas: Node faltando no Dockerfile, o host do banco caindo para localhost, mudanças não commitadas e DNS ainda não pronto para o Let's Encrypt. Cheque esses primeiro e o primeiro deploy leva bem menos de uma hora.

Comentários

Faça login com GitHub para comentar.

Ainda não há comentários.