Mova uma aplicação Rails do Heroku pra Hetzner, passo a passo
Siga isso no seu próprio servidor. Comece grátis, sem cartão
Os dynos, as config vars e o heroku pg:backups do Heroku escondem uma quantidade real de trabalho de infraestrutura atrás de um CLI, que é exatamente por que sair é mais trabalhoso do que um git push pra outro lugar. Uma aplicação Rails no Heroku precisa de um servidor web, um worker de fila se tiver um, um banco de dados Postgres com os dados reais dentro, toda config var mapeada pra um ambiente real, e um domínio com cutover feito sem uma lacuna onde nenhum dos dois servidores tem dados atuais. Este guia faz tudo isso contra um servidor Hetzner Cloud, no Docker, terminando com a mesma aplicação rodando numa infraestrutura que você controla.
O que você vai precisar
- Um servidor Hetzner Cloud. Um CX23 (2 vCPU, 4 GB de memória, €5,99/mês sem IVA) corresponde ao que muitas aplicações Rails pequenas já rodam num único dyno do Heroku mais um add-on Postgres
hobby-devoustandard-0; vá pra um CX33 (4 vCPU, 8 GB, €8,99/mês) se a aplicação roda um worker de fila junto com o processo web. - O Heroku CLI, já autenticado, pra baixar config vars e um dump de banco de dados.
- Um domínio apontado atualmente pro Heroku, com acesso pra mudar o DNS dele.
- O código fonte da aplicação num repositório Git. Este guia usa Rails 8.1, que roda em Ruby 4.0 ou mais novo; o Dockerfile abaixo fixa Ruby 4.0.8.
- Docker e o plugin Compose, tanto localmente pra testar o build primeiro quanto no servidor.
Passo 1: faça um inventário do que realmente está rodando no Heroku
A API do Heroku não expõe o seu repositório diretamente, então essa etapa é manual, e pular ela é o jeito mais fácil de perder algo no servidor novo. Baixe as três coisas que você precisa:
heroku config -a your-app-name > heroku-config.txt
heroku ps -a your-app-name
heroku addons -a your-app-name
heroku ps mostra a sua formation atual (web, worker, qualquer outro tipo de processo e as contagens de dyno deles); heroku addons mostra o que de fato está provisionado (plano de Postgres, Redis, qualquer outra coisa cobrada separadamente) que precisa de um equivalente no servidor novo.
Passo 2: crie o servidor
No Hetzner Cloud Console, Add Server: Ubuntu 24.04, CX23, uma localização, a sua chave SSH.
ssh root@your-server-ip
curl -fsSL https://get.docker.com | sh
Passo 3: escreva o Dockerfile
FROM ruby:4.0.8-slim-bookworm AS build
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends build-essential libpq-dev git pkg-config \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /rails
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local without 'development test' \
&& bundle install --jobs 4 --retry 3
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 bin/rails assets:precompile
FROM ruby:4.0.8-slim-bookworm
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends libpq5 curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /rails
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails
ENV RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=1
ENV RAILS_SERVE_STATIC_FILES=1
EXPOSE 3000
CMD ["bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]
SECRET_KEY_BASE_DUMMY=1 durante a pré-compilação de assets evita precisar do secret real da aplicação em tempo de build, o mesmo workaround que o próprio Dockerfile padrão do Rails (gerado por rails new desde a versão 7.1) usa, já que a compilação de assets não precisa do secret real mas vai falhar sem um presente.
Passo 4: escreva o arquivo Compose
services:
app:
build: .
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
expose:
- "3000"
worker:
build: .
restart: unless-stopped
env_file:
- .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: ["bundle", "exec", "sidekiq"]
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7.4-alpine
restart: unless-stopped
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:
caddy-data:
caddy-config:
Remova o serviço worker totalmente se a saída do heroku ps do Passo 1 não mostrou dyno worker nenhum; uma aplicação Rails sem processamento de jobs em segundo plano não precisa de Sidekiq nem Redis rodando de forma alguma.
Passo 5: mapeie as config vars do Heroku pro .env
Abra o heroku-config.txt do Passo 1 e traduza ele, variável por variável, pro .env do servidor:
POSTGRES_DB=app_production
POSTGRES_USER=app
POSTGRES_PASSWORD=change-this
DATABASE_URL=postgres://app:change-this@db:5432/app_production
REDIS_URL=redis://redis:6379/0
RAILS_MASTER_KEY=paste-from-config-or-config-master-key
RAILS_MASTER_KEY é a única que o Heroku quase certamente já tinha como RAILS_MASTER_KEY nas próprias config vars dele (as credenciais do Rails são criptografadas com ela, e as config vars do Heroku são exatamente onde essa chave normalmente mora em produção); copie ela exatamente em vez de regenerar, ou toda credencial criptografada em config/credentials.yml.enc se torna ilegível. Toda outra variável específica da aplicação do heroku-config.txt (chaves de API, feature flags, qualquer coisa que ENV.fetch lê no seu próprio código) também pertence a esse arquivo; DATABASE_URL e REDIS_URL especificamente recebem valores novos apontando pros containers do Passo 4 em vez dos próprios do Heroku.
Passo 6: migre o banco de dados
Com a aplicação ainda não rodando no servidor novo, baixe um dump novo do Heroku:
heroku pg:backups:capture -a your-app-name
heroku pg:backups:download -a your-app-name -o latest.dump
Copie ele pro servidor novo e restaure dentro do container Postgres recém-criado (vazio):
scp latest.dump root@your-server-ip:/opt/app/
docker compose up -d db
docker compose exec -T db pg_restore -U app -d app_production --no-owner --role=app /tmp/latest.dump
--no-owner importa especificamente aqui: o dump do Heroku pertence a um nome de role que não existe no seu container Postgres novo, e sem --no-owner, a restauração falha no meio do caminho em todo objeto que ela não consegue reatribuir.
Passo 7: suba a stack completa e verifique
docker compose up -d
docker compose exec app bin/rails db:migrate
docker compose ps
Você deve ver app, worker (se presente), db, redis e caddy todos running. Antes de fazer o cutover de DNS, teste contra o IP puro do servidor com um header Host pra confirmar que a aplicação de fato serve páginas reais com dados reais:
curl -H "Host: yourdomain.com" http://your-server-ip
Passo 8: escreva o Caddyfile e faça o cutover de DNS
yourdomain.com {
reverse_proxy app:3000
}
Reduza o TTL de DNS do seu domínio um dia ou dois antes do cutover se ele estiver alto atualmente, depois aponte o registro A pro IP do servidor novo. O Heroku continua servindo os dados antigos até o DNS se propagar, que também é a janela de risco real: qualquer escrita que aconteça no Heroku depois do dump do Passo 6 mas antes do tráfego se mover totalmente pro servidor novo é uma linha que essa migração não tem. Uma janela de manutenção curta, ou um dump incremental final tirado bem antes da troca de DNS, fecha essa lacuna pra qualquer coisa que não possa tolerar perder alguns minutos de escritas.
O caminho mais rápido: faça isso com a Railyard
Colar o nome de uma aplicação do Heroku (ou conectar o Heroku direto) em import from another platform lê as mesmas três coisas que o Passo 1 baixou manualmente: config vars, formation (tipos de processo web e worker com as contagens deles), e add-ons, mapeando heroku-postgresql e heroku-redis pra Postgres e Redis provisionados automaticamente. O Heroku não expõe o seu repositório através da API dele de qualquer jeito, então você ainda cola a URL do Git da qual você faz push, a única etapa manual honesta que a Railyard também não consegue pular. A migração de banco de dados dos Passos 6 e 7 se torna uma importação só: aponte pro DATABASE_URL em produção ou envie um dump, e ela faz backup do que está ali primeiro, restaura, depois roda as migrations automaticamente, o mesmo comportamento de pg_restore --no-owner por baixo sem você precisar lembrar da flag manual. Veja database imports pro fluxo exato.
Resolução de problemas
pg_restore: error: could not execute query ... role "..." does not exist:--no-ownerficou de fora do comando de restauração no Passo 6; adicione e os mesmos objetos restauram com sucesso sob o role conectando em vez disso.- A aplicação sobe mas toda página retorna 500 em credenciais criptografadas:
RAILS_MASTER_KEYnão corresponde ao que criptografou oconfig/credentials.yml.enc, geralmente porque foi regenerada em vez de copiada das próprias config vars do Heroku no Passo 5. - Jobs em segundo plano nunca processam: confirme que o serviço
workerde fato está presente nodocker compose pse que oREDIS_URLcorresponde entreappeworker; um job colocado numa fila contra uma instância de Redis e um worker consultando outra nunca se encontram. - O HTTPS nunca é emitido depois do cutover de DNS: a propagação de DNS pode levar mais tempo do que esperado dependendo do TTL anterior;
dig +short yourdomain.comdeve corresponder ao IP do servidor novo consistentemente a partir de vários lugares antes de assumir que o problema é o Caddy. - Os dados parecem obsoletos depois do cutover: isso é a lacuna de escrita do Passo 8; se isso importar pra essa aplicação, refaça o dump do Passo 6 e a restauração do Passo 7 imediatamente antes de trocar o DNS, com as escritas pausadas no Heroku nesse meio tempo.
Perguntas frequentes
Eu consigo fazer isso com zero downtime? Não com um único dump-e-restauração do jeito que este guia faz; uma configuração de replicação lógica do Heroku Postgres pro servidor novo elimina a lacuna de escrita totalmente, mas é significativamente mais configuração do que a maioria das aplicações pequenas precisa pelos poucos minutos de risco que uma janela de manutenção cobre.
E o sistema de arquivos efêmero do Heroku, alguma coisa depende dele? Se a aplicação escreve uploads direto no disco local esperando que persistam, isso já estava quebrado no Heroku entre restarts de dyno; essa migração é de fato um bom momento pra mover o armazenamento de arquivos pra um object store ou um volume Docker se isso ainda não tinha sido feito.
Eu preciso migrar jobs agendados também? As entradas do Heroku Scheduler não são cobertas por heroku config ou heroku ps; cheque o painel do add-on Heroku Scheduler separadamente e recrie elas como entradas de cron ou um agendamento de cron dedicado da Railyard, já que nada nos Passos 1 a 8 acima mostra elas automaticamente.
Um CX23 é mesmo comparável a um dyno do Heroku? A CPU e a memória reais de um dyno standard-1x ou standard-2x são modestas em comparação; os 4 GB de um CX23 geralmente superam um único dyno pequeno pelo mesmo custo mensal ou menos, embora o teste real seja a pegada de memória real da sua própria aplicação sob tráfego real, não uma folha de especificações.
E se a migração der errado depois que o DNS já trocou? Mantenha a aplicação do Heroku rodando (não reduzida a zero) por pelo menos alguns dias depois do cutover especificamente pra o DNS poder ser apontado de volta pra ela imediatamente se algo estiver errado no servidor novo; esse é o plano de rollback real, não um arquivo de backup.
Uma aplicação Rails que estava custando um dyno do Heroku e um add-on de Postgres todo mês agora roda num servidor Hetzner a €5,99, com os mesmos dados de Postgres, as mesmas credenciais criptografadas, e os mesmos jobs em segundo plano, só rodando em algum lugar que você provisionou e entende completamente.
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.