← Blog

Mova uma aplicação Rails do Heroku pra Hetzner, passo a passo

Railyard

25 de julho de 2026 · 8 min de leitura

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-dev ou standard-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-owner ficou 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_KEY não corresponde ao que criptografou o config/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 worker de fato está presente no docker compose ps e que o REDIS_URL corresponde entre app e worker; 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.com deve 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.

Pule a migração manual.

Cole o nome da sua aplicação do Heroku na Railyard e ela lê as suas config vars, formation e add-ons, e depois importa o banco de dados direto.

Plano grátis, sem cartão, e 14 dias de Pro para cada equipe nova. Um preço fixo por equipe.