Fazendo deploy de uma aplicação Rails na Hetzner
Siga isso no seu próprio servidor. Comece grátis, sem cartão
Os servidores de nuvem da Hetzner são um dos computes reais mais baratos que você consegue alugar por mês, mas um servidor barato não roda sua aplicação por conta própria. Você ainda tem que provisionar ele, instalar o Docker, colocar o Postgres rodando ao lado da sua aplicação, colocar HTTPS na frente dos dois, manter um backup em algum lugar que não seja o próprio servidor, e ter um jeito de lançar sua próxima release sem começar do zero cada vez. Ao final deste guia você vai ter uma aplicação Rails, construída a partir do próprio Dockerfile gerado por ela, rodando num servidor Hetzner por trás do Caddy com um certificado de verdade, apoiada pelo Postgres, com backups noturnos e um jeito curto e repetível de fazer deploy de uma versão nova.
O que você vai precisar
- Uma conta Hetzner Cloud e um servidor. O CX23 otimizado por custo (2 vCPU, 4 GB de memória, 40 GB NVMe) está listado a €5,99/mês sem IVA, que se aplica dependendo de onde você é faturado. Cheque o preço e a disponibilidade atuais no Hetzner Cloud Console antes de pedir, já que as linhas de nuvem e os preços mudam.
- Um nome de domínio ao qual você pode adicionar um registro de DNS.
- Um par de chaves SSH na sua máquina (
ssh-keygen -t ed25519se você ainda não tiver um). - Uma aplicação Rails, ou uma nova: este guia constrói uma do zero com
rails new, então você consegue acompanhar mesmo sem uma aplicação existente. - Cerca de quinze minutos pro servidor e o DNS se acomodarem, a maior parte gasta esperando em vez de digitando.
Passo 1: criar o servidor e anotar o IP dele
No Hetzner Cloud Console, abra (ou crie) um projeto, depois escolha Add Server. Escolha uma localização: Nuremberg, Falkenstein ou Helsinki mantêm seus dados na UE, enquanto Ashburn, Hillsboro ou Singapura também estão disponíveis se seus usuários estiverem em outro lugar. Escolha Ubuntu 24.04 como imagem, depois em tipo de servidor escolha o vCPU compartilhada CX23 (2 vCPU, 4 GB de memória, 40 GB NVMe). Em chaves SSH, adicione a chave pública da sua máquina em vez de depender de uma senha gerada, depois crie o servidor.
Quando ele iniciar, o console mostra seu endereço IPv4 público. Conecte pra confirmar o acesso:
ssh root@YOUR_SERVER_IP
Você deve ver o banner de boas-vindas do Ubuntu e cair num shell root. Se a conexão for recusada, confira se a chave SSH anexada ao servidor combina com a chave privada na sua máquina.
Passo 2: apontar seu domínio pro servidor
Adicione um registro A no seu provedor de DNS pro hostname que você quer usar, apontando pro IP do servidor:
Type: A
Name: myapp (or @ for the bare domain)
Value: YOUR_SERVER_IP
TTL: 300
A propagação de DNS pode levar alguns minutos. Confira a partir da sua própria máquina antes de seguir, já que o Caddy precisa disso já resolvendo corretamente numa etapa posterior:
dig +short myapp.example.com
Você deve ver o IP do seu servidor impresso de volta. Se não aparecer nada, espere alguns minutos e tente de novo antes de continuar.
Passo 3: instalar o Docker no servidor
O Dockerfile gerado pelo Rails precisa do Docker Engine e do plugin do Compose no servidor. O próprio script de instalação do Docker cuida dos dois numa máquina Ubuntu nova:
apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker
A Hetzner não coloca um firewall na frente de um servidor novo por padrão, então restrinja o host a só o que você precisa com ufw:
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable
Cheque a instalação:
docker --version
docker compose version
Você deve ver uma versão do Docker e uma versão do Compose impressas, não um erro de “comando não encontrado”.
Passo 4: gerar a aplicação Rails com um Dockerfile e Postgres
Na sua própria máquina, gere uma aplicação nova configurada pro Postgres em vez do SQLite padrão:
gem install rails -v 8.1.4
rails new myapp --database=postgresql
cd myapp
rails new cria um Dockerfile por padrão, listado entre os arquivos gerados junto do .dockerignore, e ele é ajustado pra produção: instala suas gems e precompila assets num único estágio de build, depois copia só o necessário pra uma imagem de runtime magra que roda como um usuário não-root. O Rails 8 também inclui o Thruster, um pequeno proxy HTTP na frente do Puma que trata HTTP/2, compressão e servimento de assets, e é por isso que a imagem gerada expõe a porta 80 internamente e inicia com ./bin/thrust ./bin/rails server em vez de iniciar o Puma direto. Você não vai acessar essa porta diretamente pela internet, o Caddy fica na frente dela no Passo 7.
O script de entrypoint do Dockerfile também roda ./bin/rails db:prepare automaticamente, toda vez que o comando do container é um servidor Rails, antes do servidor de fato iniciar. Isso significa que você não precisa de uma etapa de migration separada mais adiante neste guia: o primeiro boot cria o banco de dados, e todo boot seguinte aplica qualquer migration que tenha vindo naquela release.
Porque você passou --database=postgresql, o config/database.yml já está configurado pra gem pg em vez do SQLite. Se você está no Rails 8, o mesmo arquivo também conecta entradas separadas de banco cache, queue e cable pro Solid Cache, Solid Queue e Solid Cable, os backends padrão do próprio Rails pra jobs em segundo plano e cache. Os quatro, o banco principal mais esses três, leem da mesma DATABASE_URL e diferem só no nome do banco que cada um pede (myapp_production, myapp_production_cache, e assim por diante), então um único container Postgres e uma senha cobrem todos eles; o db:prepare cria os quatro na primeira vez que roda.
Confirme que os arquivos estão lá:
ls Dockerfile .dockerignore config/database.yml
Você deve ver os três caminhos impressos sem nenhum erro.
Passo 5: tirar seus secrets da aplicação e colocar no servidor
O Rails criptografa o config/credentials.yml.enc com o config/master.key, um arquivo que está no .gitignore de propósito e nunca é enviado pro servidor. Leia o valor dele uma vez, na sua própria máquina:
cat config/master.key
No servidor, crie um diretório pra aplicação e um arquivo .env com essa chave e uma senha de banco de dados que você mesmo gera:
mkdir -p /opt/myapp
cat > /opt/myapp/.env <<'EOF'
RAILS_MASTER_KEY=paste-the-value-from-config-master-key-here
POSTGRES_PASSWORD=a-long-random-password
EOF
chmod 600 /opt/myapp/.env
Confira:
cat /opt/myapp/.env
Você deve ver as duas variáveis com valores reais, não placeholders, e o arquivo não deve ser legível por todo mundo (-rw------- no ls -l).
Passo 6: escrever o arquivo do Docker Compose
Copie sua aplicação pro servidor (scp, rsync, ou um git clone do seu repositório em /opt/myapp), depois adicione compose.yml ao lado dele:
services:
app:
build: .
image: myapp:latest
restart: unless-stopped
env_file: .env
environment:
RAILS_ENV: production
DATABASE_URL: postgres://myapp:${POSTGRES_PASSWORD}@db:5432/myapp_production
depends_on:
db:
condition: service_healthy
volumes:
- storage:/rails/storage
expose:
- "80"
db:
image: postgres:16.4-alpine
restart: unless-stopped
environment:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: myapp_production
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 10
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
volumes:
db_data:
storage:
caddy_data:
caddy_config:
O serviço app alcança o db pelo nome do serviço, não localhost, já que o Compose coloca os dois containers na mesma rede privada. depends_on: condition: service_healthy evita que a aplicação inicie antes do Postgres de fato poder aceitar conexões, não só antes do container dele existir. Volumes nomeados mantêm seu banco de dados e seus arquivos do Active Storage em disco entre restarts e rebuilds; só o caddy publica portas pro host, então o Postgres nunca é alcançável de fora desse servidor.
Cheque se o arquivo é lido sem erros antes de seguir:
docker compose config
Você deve ver a configuração totalmente resolvida impressa sem nenhuma linha de erro.
Passo 7: adicionar o Caddy como reverse proxy com HTTPS
Adicione um Caddyfile ao lado do compose.yml:
myapp.example.com {
reverse_proxy app:80
}
Troque myapp.example.com pelo domínio que você apontou pro servidor no Passo 2. O Caddy faz duas coisas por conta própria a partir desse único bloco: redireciona via proxy toda requisição pra porta 80 do container app, e solicita um certificado TLS gratuito do Let’s Encrypt pra esse hostname exato, renovando ele automaticamente antes que expire. Isso só funciona se o domínio já resolver pra esse servidor, que é por isso que o DNS veio antes do Caddy neste guia.
Passo 8: iniciar o stack
A partir de /opt/myapp:
docker compose up -d --build
Por causa do comportamento do entrypoint do Passo 4, esse único comando constrói a imagem, inicia o Postgres, espera ele reportar saudável, depois inicia a aplicação, que cria o banco de dados e roda as migrations por conta própria antes do Puma (por trás do Thruster) de fato iniciar.
Cheque o stack:
docker compose ps
curl -I https://myapp.example.com
Você deve ver os três serviços como Up (e db como healthy), e o curl deve retornar HTTP/2 200 sem nenhum aviso de certificado.
Passo 9: fazer backup do Postgres
Um dump feito fora do volume de dados é o que realmente te protege se o servidor desaparecer. Adicione um script de backup:
cat > /opt/myapp/backup.sh <<'EOF'
#!/bin/sh
cd /opt/myapp
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U myapp myapp_production | gzip > /opt/myapp/backups/$TIMESTAMP.sql.gz
find /opt/myapp/backups -name '*.sql.gz' -mtime +14 -delete
EOF
mkdir -p /opt/myapp/backups
chmod +x /opt/myapp/backup.sh
Agende com cron, diariamente às 3h:
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -
Rode uma vez à mão e cheque que funcionou:
/opt/myapp/backup.sh
ls -lh /opt/myapp/backups
Você deve ver um arquivo .sql.gz com o timestamp de hoje e um tamanho diferente de zero. Pra qualquer coisa além de um projeto pessoal, copie esses arquivos pra fora do servidor também, pra outra máquina ou um armazenamento de objetos, pra que perder o servidor não signifique perder o backup também.
Passo 10: lançar uma atualização
Toda mudança futura segue o mesmo formato: trazer o código novo pro servidor, reconstruir a imagem, substituir o container em execução.
git pull
docker compose build app
docker compose up -d app
O script de entrypoint roda db:prepare de novo nesse container novo antes do servidor iniciar, então qualquer migration que veio com essa release é aplicada automaticamente. docker compose up -d app só recria o container app, não db nem caddy, então o Postgres continua rodando e o estado do certificado do Caddy fica intocado. Existe um intervalo curto, um segundo ou dois, enquanto o container antigo para e o novo inicia, já que essa configuração manual roda um único container de aplicação. Cheque que foi aplicada:
docker compose logs --tail=50 app
curl -I https://myapp.example.com
Você deve ver sua aplicação inicializar limpa nos logs e o curl ainda retornando 200 na nova versão.
O caminho mais curto: fazer isso com o Railyard
Tudo acima, o servidor, Docker, Postgres, Caddy, backups, uma release repetível, também é o que o Railyard automatiza pra uma aplicação Rails na Hetzner, sem você escrever nenhum dos arquivos acima à mão.
Cole a URL do seu repositório em https://app.railyard.run/deploy?repo= e escolha a Hetzner como provedor. Na primeira vez, você vai gerar um token no Hetzner Cloud Console (abra seu projeto, depois Security, depois API tokens, e gere um com permissão Read & Write) pra que o Railyard consiga provisionar o servidor em seu nome. Pra uma aplicação Rails na Hetzner, o padrão do Railyard é um servidor de 2 vCPU / 4 GB, o mesmo tamanho usado manualmente acima, e você pode escolher qualquer região da Hetzner, incluindo Nuremberg, Falkenstein ou Helsinki se residência de dados na UE importa pra você.
A partir daí, o Railyard lê sua aplicação do mesmo jeito que este guia fez à mão: ele vê pg no seu Gemfile.lock e provisiona um banco Postgres gerenciado em vez do SQLite, e roda bin/rails db:prepare antes de cada release do mesmo jeito que o próprio entrypoint do Dockerfile fez manualmente no Passo 8. Se você está no Rails 8, ele dá aos bancos de cache, queue e cable suas próprias connection strings em vez de você mesmo ter que raciocinar sobre isso, do jeito que o Passo 4 explicou. O Active Storage em disco recebe um volume persistente automaticamente, combinando com o volume storage do Passo 6, e o SECRET_KEY_BASE é gerado uma vez e guardado, então você nunca gerencia ele à mão. Se seu Gemfile.lock inclui Sidekiq, Solid Queue, GoodJob, Resque ou Delayed Job, o Railyard oferece ele como um processo worker ao lado do seu processo web, sem nenhum serviço de compose separado pra escrever. O HTTPS e o reverse proxy do Passo 7 são tratados do mesmo jeito, apontados pro seu domínio quando você o adicionar.
Leia mais em deploying from a repository, databases and backups, o botão de deploy em um clique, getting started, o guia de referência de Rails na Hetzner, e comparações de custo de hospedagem se você quiser ver como o custo só-do-servidor aqui se compara a um caminho gerenciado. O plano Free do Railyard cobre um servidor mais um servidor de banco de dados e três aplicações sem cartão necessário, o Pro é US$19/mês por equipe, e o Team é US$49/mês por equipe.
Resolução de problemas
Porta já em uso. Se docker compose up falha com algo vinculado à porta 80 ou 443, o Apache ou Nginx padrão do Ubuntu pode já estar instalado e rodando. Cheque com ss -tlnp | grep -E ':80|:443' e pare ou remova o serviço que estiver ocupando elas antes do Caddy tentar se vincular.
RAILS_MASTER_KEY ou credentials errados. Um erro como ActiveSupport::MessageEncryptor::InvalidMessage na inicialização quase sempre significa que o RAILS_MASTER_KEY em /opt/myapp/.env não combina com a chave que criptografou o config/credentials.yml.enc. Rode cat config/master.key de novo localmente e compare caractere por caractere com o que está no .env no servidor.
Conexão com o Postgres recusada. Se os logs da aplicação mostrarem could not connect to server: Connection refused, cheque se DATABASE_URL usa db como hostname, não localhost ou 127.0.0.1. Dentro da rede do Compose, db é o único endereço que resolve pro container do Postgres; localhost significa o próprio container app, que não tem nenhum banco de dados rodando nele.
HTTPS não é emitido. Se docker compose logs caddy mostra erros repetidos de certificado ou NXDOMAIN, seu registro DNS do Passo 2 ainda não propagou ou está apontando pro IP errado. Rode dig +short myapp.example.com de novo, confirme que combina com o servidor, depois reinicie o Caddy com docker compose restart caddy quando combinar.
Falta de memória durante o precompile de assets. Os 4 GB de memória de um CX23 geralmente são suficientes, mas o docker compose build ainda pode ser morto na metade da compilação de assets numa aplicação maior, principalmente se estiver construindo e rodando ao mesmo tempo. Adicione um arquivo de swap de 2 GB antes de construir: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile, depois torne isso permanente adicionando ao /etc/fstab.
Perguntas frequentes
Um CX23 é suficiente pra produção? Pra uma aplicação Rails pequena a média com tráfego modesto, sim, já que o Puma, o Postgres e o Caddy juntos cabem com conforto em 4 GB, e a correção de swap acima cobre o único caso, builds de assets grandes, em que a memória fica apertada.
Eu posso pular o Postgres e manter o SQLite? Você pode; o SQLite funciona bem num servidor único desde que você monte o arquivo do banco dele num volume persistente pra que ele sobreviva a restarts de container, mas o Postgres te dá um caminho mais limpo se você algum dia adicionar um segundo servidor de aplicação.
Como eu adiciono Redis ou Sidekiq? Adicione um serviço redis ao compose.yml usando a imagem oficial redis com seu próprio volume nomeado, adicione REDIS_URL ao ambiente do app apontando pra redis://redis:6379/0, e adicione um serviço worker que roda bundle exec sidekiq contra a mesma imagem em vez do comando do servidor Rails.
Isso sobrevive a um reboot do servidor? Sim, restart: unless-stopped em cada serviço significa que o Docker traz o stack todo de volta automaticamente depois que o servidor reinicia, desde que o próprio serviço systemd do Docker esteja habilitado, o que o script de instalação no Passo 3 faz por padrão.
O que são Solid Cache, Solid Queue e Solid Cable? São os backends padrão do Rails 8 pra cache, jobs em segundo plano e Action Cable, todos apoiados num banco de dados em vez de um serviço separado como o Redis. É por isso que uma aplicação Rails 8 nova precisa de quatro nomes de banco de dados em vez de um, e por que o único container Postgres deste guia e uma senha cobrem todos eles.
Essa é uma configuração completa de produção: um servidor, Docker Compose rodando sua aplicação e o Postgres, Caddy cuidando do HTTPS, um backup noturno, e uma release de dois comandos. Mantenha esse compose.yml e Caddyfile no seu repositório junto da aplicação, e todo deploy futuro é o mesmo git pull, build, restart que você acabou de rodar no Passo 10.
Por que escolhem o Railyard para isso
- Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
- Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
- Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
- Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
- Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.