
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."
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.comewww.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 Dockerkamal, pelo nomemyblog-db. Nada fica exposto para a internet. - A imagem oficial
postgres:18espera 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_pathaponta 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_PUMApor completo quando tiver uma rolejobdedicada. Oconfig/puma.rbdo Rails 8 checaif ENV["SOLID_QUEUE_IN_PUMA"], e a string"false"é truthy em Ruby. Definir comofalseliga 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
Aparamyblog.example.comapontando para203.0.113.10 - Um registro
Aparawww.myblog.example.comapontando para203.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_dumpnoturno 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.