Migrar do Heroku pra cluster próprio: guia técnico em 5 passos
O fim do plano gratuito do Heroku em novembro/2022 transformou migração em prioridade pra centenas de times brasileiros. Plano detalhado com checklist, tempo estimado, e armadilhas comuns.
Em 28 de novembro de 2022, a Salesforce desligou o plano gratuito do Heroku. Centenas de milhares de hobby projects foram apagados de uma vez, e o ciclo de notícias durou uns dois meses — gente migrando pra Render, pra Fly.io, pra Railway, pra um VPS qualquer. O que ninguém previu naquele momento é o que aconteceu depois: quatro anos passaram, estamos em 2026, e ainda existem milhares de SaaS brasileiros em produção pagando entre US$25 e US$100 por mês por dyno só porque "migrar" é o décimo terceiro item da backlog. Sempre tem uma feature mais urgente. Sempre tem um cliente perguntando quando sai o módulo X. Migrar dá zero receita nova — então fica.
Esse post é o plano pra fazer essa migração caber em uma semana de trabalho de um dev part-time, e o restante de um mês pra estabilizar. Não é um manifesto, não é uma comparação de fornecedores, não é "venha pro HeroCtl". É um runbook. No final tem uma seção sobre opções de destino, incluindo o nosso produto, mas se você terminar de ler e for pra Render ou pra Coolify ou pra Fly.io, o post fez o trabalho.
Por que ainda dói migrar (a verdade não dita)
A primeira coisa que precisa ficar clara: não é o Dockerfile. Escrever Dockerfile pra um app Rails ou Node é meia tarde — tem template pronto pra cada framework, tem cinco posts no DEV explicando, tem o Copilot escrevendo. Se a sua resistência é "ainda não dockerizamos", essa parte é a menos importante.
A dor está no ecossistema:
- Postgres com extensions específicas que você esqueceu que ativou em 2019.
pg_stat_statements,pgcrypto,hstore,postgis— cada uma é um motivo pra migração quebrar silenciosamente. - Redis Premium com persistência que você usa pra fila do Sidekiq E pra cache E pra rate limit. Pra cache pode reiniciar do zero. Pra fila não pode.
- Sidekiq workers stateful com jobs agendados meses à frente. Migrar enquanto eles rodam é correr atrás do trem em movimento.
- Heroku Scheduler com aquele cron que ninguém olha desde 2020 mas que faz o relatório mensal do CEO.
- Papertrail integrado, NewRelic instrumentado, Bugsnag em todos os erros — três SaaS extras que você nem sabe se vão fazer sentido na nova arquitetura.
- Buildpack que rodou seis anos sem ninguém saber direito o que faz. Tem um
bin/post_compileque minifica algo, tem variável de ambiente que define qual versão do Ruby — em algum lugar, a sua aplicação depende de seis comportamentos do buildpack que nunca foram documentados.
E tem a parte humana: você e o seu time internalizaram primitivas Heroku ao longo de anos. Procfile, slug compilation, dynos, release phase, config vars. Tudo isso virou intuição. Quando a gente vai refazer fora do Heroku, refaz inconscientemente — e em geral mal, porque o Heroku tinha defaults que escondem decisões importantes que agora são suas.
A migração técnica leva uma semana. A migração mental leva um mês. Esse post tenta encurtar os dois.
Pré-flight check — uma a duas horas, antes de qualquer commit
Antes de abrir o editor, você precisa do inventário. A maior parte das migrações que dão errado é por uma surpresa que poderia ter sido descoberta na primeira hora.
Inventário de apps:
heroku apps
Quantos apps existem na conta? Quais ainda estão em uso de verdade? Quais podem virar cron-job e morrer? Quais foram criados pra um cliente que saiu em 2021? Marque cada um numa planilha com três colunas: nome, status (vivo/zumbi/cron), prioridade de migração (alta/média/baixa).
A maioria das contas tem 30% de apps zumbis. Migrar zumbi não tem ROI — destruir tem.
Inventário de addons por app:
heroku addons -a meu-app
Cada linha é uma decisão futura. Postgres? Redis? Papertrail? Heroku Scheduler? SendGrid? Mailgun? Pra cada um, escreva na planilha: vai migrar pra equivalente self-hosted, vai virar SaaS externo, ou vai descartar. Se você não sabe pra que serve, pesquise antes — não na hora do cutover.
Inventário de buildpacks:
heroku buildpacks -a meu-app
Multi-buildpack? Custom buildpack? Se a saída tem mais de uma linha, leia cada um. Buildpack customizado costuma ter hooks (bin/release, bin/compile, bin/post_compile) que executam coisas específicas. Você vai precisar replicar esses passos no Dockerfile ou num release container.
Inventário de env vars:
heroku config -a meu-app
Exporte tudo pra arquivo seguro. NÃO commite. NÃO mande pelo Slack. NÃO cole no ChatGPT. Esse arquivo tem DATABASE_URL, SECRET_KEY_BASE, chave de API de pagamento. Trate como senha, porque é exatamente isso.
Atenção a duas armadilhas:
- Variáveis com caractere
:no nome (algumas libs antigas usam) escapam diferente em containers. BUNDLE_WITHOUT=development:testgravado em produção é bomba relógio depois da migração.
Inventário de Procfile:
Cada linha do Procfile é um serviço:
webvira o container principal.workervira segundo container ou job separado.releasevira step pré-deploy (typicamente migrations).clockouschedulervira cron job.
Se o seu Procfile tem cinco linhas, você vai ter cinco serviços no destino. Não são detalhes — são o desenho da topologia.
Métricas atuais:
heroku ps -a meu-app
heroku logs --tail -a meu-app
Quantos dynos rodando? Qual o tipo (Standard-1X, Performance-M)? Volume de logs por minuto? Latência média no NewRelic? Pico de CPU/memória do mês passado?
Esses números servem pra dimensionar o destino. Migrar e descobrir depois que a memória é metade do necessário é o jeito mais rápido de quebrar a confiança no projeto inteiro.
No fim do pré-flight você tem uma planilha com tudo. Esse arquivo é o coração da migração. Toda decisão volta pra ele.
Passo 1 — Escolha de stack alvo (decisão arquitetural, 30 minutos)
Três caminhos possíveis. Vou ser honesto sobre cada um.
Opção A — VPS único com painel self-hosted. Um servidor na DigitalOcean ou Hetzner, instala Coolify ou Dokploy, deploya seu app pelo painel. Custo: R$30 a R$50 por mês pra começar, escala bem até uns 10 apps em servidor médio. Sem alta disponibilidade — se o servidor cai, tudo cai. SLA que você consegue prometer: best-effort.
Ideal pra: indie hacker, projeto pessoal, MVP, SaaS sem cliente que exige SLA por escrito.
Opção B — Cluster com alta disponibilidade. Três ou mais servidores, orquestrador que coordena entre eles, sobrevive à queda de um servidor sem afetar tráfego. Custo: R$150 a R$300 por mês pra um cluster de três nós modestos. SLA possível: 99,9% sem desespero.
Ideal pra: SaaS B2B com clientes pagantes, qualquer aplicação onde meia hora de downtime gera ticket de suporte.
Opção C — Plataforma gerenciada externa. Render, Railway, Fly.io. Você paga mais, mas zero ops. Custo: R$200 a R$500 por mês pra workload comparável a 2-3 dynos Heroku, escala linear daí em diante.
Ideal pra: time que não tem absolutamente ninguém pra cuidar de servidor e prefere transferir o problema pra outra empresa.
Decisão honesta, em uma pergunta: tem cliente exigindo SLA? Se não, opção A. Se sim, B. Se o time não tem ninguém disposto a aprender mínima ops, C. Não existe resposta certa universal — existe resposta certa pra o seu contexto. Misturar os três também é válido: app principal em B, ferramenta interna em A, scheduler isolado em C.
Passo 2 — Dockerização (meio dia a dois dias por app)
Aqui o trabalho técnico começa. A lógica geral é a mesma pra qualquer stack:
FROM ruby:3.3-slim AS builder
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test
COPY . .
RUN bundle exec rake assets:precompile
FROM ruby:3.3-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 nodejs && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/bundle /usr/local/bundle
COPY --from=builder /app /app
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
Multi-stage. Build pesado fica num estágio que é descartado. Imagem final tem só o necessário pra rodar.
Por linguagem:
- Ruby/Rails:
ruby:3.x-slimcomo base, multi-stage pra reduzir tamanho. O slug compilation do Heroku virou suas próprias linhas no Dockerfile —bundle install,assets:precompile, copiar artefatos. - Node:
node:20-alpineresolve a maioria dos casos. Atenção a deps com binários nativos (sharp, bcrypt, sqlite3, canvas) — Alpine usa musl, e algumas libs precisam de glibc. Se quebrar, troque pranode:20-slim. - Python/Django:
python:3.x-slim, gunicorn ou uvicorn como server.requirements.txtoupyproject.tomlno estágio de build. - Elixir/Phoenix:
elixir:1.x-alpine, release como artefato (mix release), runtime image só com erlang.
Mapeamento Procfile → Docker:
| Procfile | Equivalente em destino |
|---|---|
web: bundle exec puma | CMD do container principal |
worker: bundle exec sidekiq | Container separado, mesma imagem, comando diferente |
release: bundle exec rake db:migrate | Job de release, executa antes do rolling deploy |
clock: bundle exec clockwork | Cron job, ou container singleton |
A maior parte dos orquestradores modernos (HeroCtl, Render, Railway, Coolify) entende esses quatro formatos diretamente.
Assets:
Slug compilation do Heroku faz precompile automático. Em Docker você precisa pensar:
- Rails:
RUN bundle exec rake assets:precompileno estágio de build. - Node:
RUN npm run buildno estágio de build. - Asset host (CDN): se você usa CloudFront ou S3 pra servir static, configurar
RAILS_SERVE_STATIC_FILESeASSET_HOSTcorretamente.
Tempo médio realista:
- App Rails médio (CRUD com Sidekiq): 1 a 2 dias.
- App Node simples (API, sem build pesado de frontend): 4 horas.
- App com 5+ workers stateful e processamento de mídia: 3 a 5 dias.
A primeira app demora mais. A segunda demora metade. Da terceira em diante, é mecânico.
Passo 3 — Migração de banco (a parte mais arriscada, 2 a 8 horas)
Aqui mora o medo. Banco é o único lugar onde "voltar atrás" é caro. Tudo o mais é redeploy.
Postgres:
Heroku Postgres expõe acesso direto via pg_dump se você tiver as credenciais (estão em DATABASE_URL). Antes de qualquer coisa, descubra suas extensions:
SELECT extname, extversion FROM pg_extension;
Comuns: pgcrypto, hstore, postgis, pg_stat_statements, uuid-ossp, unaccent. Se o destino não tem todas, ou tem em versão diferente, você descobre antes — não no meio do restore às 3 da manhã.
Destino possível pra Postgres:
- Postgres rodando como job no próprio cluster (RPO/RTO menor, controle total, você cuida do backup).
- Postgres gerenciado regional — RDS São Paulo, Neon, Supabase, Aiven. Mais caro, menos ops.
Migração com mínimo downtime — opção A (com janela):
# Drena tráfego: coloca app em manutenção, espera Sidekiq esvaziar
heroku maintenance:on -a meu-app
# Dump
pg_dump $HEROKU_DATABASE_URL --no-owner --no-privileges --format=custom --file=dump.sql
# Restore no destino
pg_restore --no-owner --no-privileges --dbname=$DEST_DATABASE_URL dump.sql
# Smoke test no destino
psql $DEST_DATABASE_URL -c 'SELECT count(*) FROM users;'
# Cutover de DNS, app no destino aponta pro novo banco
heroku maintenance:off -a meu-app # opcional, só pra Heroku continuar atendendo /healthz
Janela típica: 30 minutos a 2 horas, dependendo do tamanho do banco. Pra base abaixo de 5GB, 30 min é folgado.
Migração com mínimo downtime — opção B (logical replication):
Replicação lógica do Postgres permite que você inicie a cópia enquanto o app continua escrevendo no Heroku. Quando a réplica chega no estado atual, faz o cutover de DNS e o destino vira o novo primário.
Funciona se o destino conseguir alcançar o Heroku via rede. Pra Heroku Postgres precisa abrir whitelist do IP do destino (Heroku tem mecanismo pra isso em planos pagos). Setup leva uma tarde, cutover dura segundos.
Redis:
Duas naturezas distintas — trate diferente:
- Redis como cache: simplesmente reinicie do zero no destino. O cache reaquece sozinho. Não há nada a migrar.
- Redis como fila Sidekiq/Resque com persistência: aqui dói. Snapshot via
BGSAVE, transfira o RDB, restore no destino. Ou: pause os workers no Heroku, processe a fila até o fim, faça cutover com fila vazia.
Redis Premium do Heroku tem persistência ligada por padrão; Redis simples no destino pode não ter — confira antes.
Passo 4 — DNS, SSL e cutover (1 a 3 horas)
O cutover é a hora da verdade. Tudo o que veio antes era preparação.
24 horas antes:
Reduz o TTL do registro DNS pra 60 segundos. Isso garante que, quando você apontar pro destino, propaga rápido. TTL alto é o que faz cutover virar pesadelo de 6 horas com metade dos clientes ainda hitando o servidor antigo.
Setup paralelo:
App rodando em paralelo nos dois destinos. Heroku continua respondendo no domínio antigo. Destino responde num domínio temporário (ex: app-novo.heroctl.com).
Smoke test no destino:
curl https://app-novo.heroctl.com/healthz
curl https://app-novo.heroctl.com/api/v1/usuarios -H "Authorization: Bearer $TOKEN"
# Hit endpoints críticos manualmente, com olhos humanos
Se algo estiver errado, descubra agora. Depois do cutover você vai estar lidando com tickets de suporte simultaneamente.
Cutover:
Muda o CNAME (ou A record) do domínio de produção pro destino. Em até 60 segundos, novos requests vão pro destino novo. Heroku continua respondendo no domínio antigo (a URL *.herokuapp.com) por 30 dias — isso é cinto de segurança importante.
SSL/TLS:
Heroku tinha certificado automático embutido. No destino, dependendo da escolha:
- HeroCtl, Coolify, Render, Railway, Fly.io: certificado automático via Let's Encrypt, sem você pensar.
- VPS único nu: você configura cert-manager-equivalente, ou Caddy com ACME, ou nginx + certbot.
Antes do cutover de DNS, valida que o destino emitiu o certificado pra o domínio. Let's Encrypt valida via HTTP-01 ou DNS-01 — o desafio HTTP-01 só funciona depois do DNS apontar, então tem ovo-galinha. Solução: emite via DNS-01 antes (não precisa do DNS apontar pra o destino), ou aceita 30 segundos de erro de TLS no momento do cutover.
Sticky sessions:
Se o seu app usa WebSocket, ou tem sessão em memória (em vez de Redis ou banco), você precisa de sticky session no balanceador. Heroku não fazia isso por padrão, mas alguns apps acabam dependendo de roteamento estável sem perceber. No destino, configure cookie-based session affinity se necessário.
Passo 5 — Decommissão Heroku (1 hora, 30 dias depois)
Trinta dias é o cinto de segurança. Mantenha o app no Heroku ligado, sem tráfego (afinal o DNS já apontou pra outro lugar), só pra caso de emergência. Custo: o que você já estava pagando, divido proporcionalmente até a data de cancelamento.
Trinta dias depois, se nada quebrou:
heroku addons:destroy heroku-postgresql -a meu-app
heroku addons:destroy heroku-redis -a meu-app
heroku addons:destroy papertrail -a meu-app
heroku apps:destroy meu-app
Cada addon tem que ser cancelado separadamente — alguns têm billing próprio que continua mesmo com app destruído. Confere a fatura do mês seguinte com lupa.
Heroku faz reembolso pro-rata até o dia da cancelação. Não esqueça de cancelar a conta inteira se for o último app — senão você paga taxa de plataforma todo mês por nada.
Armadilhas comuns
A maior parte das migrações trava nessas oito coisas. Lê tudo antes de começar.
Slug compilation hooks invisíveis. Apps antigos têm bin/release, bin/post_compile, bin/pre_compile. Esses scripts rodam dentro do buildpack e fazem coisas como minificar JS, gerar arquivos derivados, ou rodar uma migração que ninguém lembra. Antes de Dockerizar, abre cada um e replica num step do Dockerfile ou em release container.
Config vars com formato quebrado. Heroku aceita MY:VAR como nome de variável (com :). Containers em geral também, mas algumas ferramentas de orquestração escapam diferente. Renomeia pra MY_VAR antes de migrar.
Redis URL com formato variante. Heroku usa redis://h:senha@host:port. Alguns clients (gems Ruby antigas, principalmente) esperam redis://:senha@host:port. Se ver Redis::CommandError: WRONGPASS, é provavelmente isso.
BUNDLE_WITHOUT=development:test gravado no env. Quando você roda esse mesmo container fora do Heroku, ele continua sem instalar gems de desenvolvimento. Em produção, ok. Em staging onde você precisa rodar testes, quebra. Limpa essa variável antes de usar a config dump em outro ambiente.
Gems específicas do Heroku. rails_12factor (deprecado mas ainda em apps de 2014), heroku_san, taps. Removeu, fim. Se algo depender, troca por equivalente padrão.
DNS com Heroku-DNS-Target. Heroku recomenda usar ALIAS ou ANAME pra apontar pro app, em vez de CNAME, pra raízes de domínio. Quando migrar, troca pra A record direto pro IP do destino. ALIAS apontando pro Heroku é o que vai te ferrar em domínios apex.
Papertrail / NewRelic / Bugsnag desligados sem substituto. Logs e observabilidade são fáceis de deixar pra depois e quebrar na primeira hora pós-migração. Antes do cutover, tem que ter: logs centralizados (HeroCtl tem escritor único embutido; Render expõe via UI; Coolify tem Loki opcional), métricas básicas (CPU, memória, requests), e alguma ferramenta de erros (Sentry self-hosted ou SaaS).
Sidekiq/Resque com jobs em voo durante cutover. Durante o momento do cutover, alguns jobs vão pra fila do destino sem terem sido processados no origem. Se o seu job não é idempotente (pode rodar duas vezes sem efeito colateral), isso é problema. Solução: pause os workers no Heroku 5 minutos antes do cutover, espera fila esvaziar, faz cutover com fila vazia.
Cronograma realista pra startup média (5 a 10 apps Heroku)
Time pequeno, um dev part-time:
- Semana 1: pré-flight completo + escolha de stack + setup do destino (cluster vazio rodando, painel acessível).
- Semana 2: Dockerização do primeiro app de baixo risco + migração de banco em ambiente de staging.
- Semana 3: cutover do primeiro app em produção + validação de 7 dias.
- Semanas 4 a 6: migração dos demais apps em paralelo, ritmo de 1 a 2 por semana.
- Total: 4 a 6 semanas de elapsed time, talvez 80 horas de trabalho efetivo distribuídas.
Time médio (3 devs, 20 apps): 8 semanas, 200 horas de trabalho efetivo.
Time grande (cluster de 50+ apps): trate como projeto formal, com gerente de projeto, e calcule trimestre.
A regra de bolso: nunca migre mais de 2 apps em paralelo se for o mesmo dev fazendo. O custo de contexto-switching engole o ganho de paralelismo.
FAQ
Quanto custa a migração em horas-homem? Pra um SaaS de 5 apps, dev part-time: ~80 horas. A R$200/h, R$16k. Comparado a R$2k/mês de fatura Heroku que você economiza, payback em 8 meses. Nos 4 anos seguintes, é só economia.
E se eu não tiver Docker setup? Não precisa pré-instalar nada — as plataformas de destino constroem a imagem por você (Render, Railway, Fly.io aceitam Dockerfile direto do git). HeroCtl exige imagem em registry, então você sobe pra ECR, GCR, Docker Hub ou GHCR. Pra uso local, instala Docker Desktop e tá pronto.
Heroku Postgres tem export limit?
Tem limite de IOPS durante pg_dump em planos baixos. Bancos acima de 5GB em plano Hobby podem precisar pg_dump em modo paralelo (-j) ou usar logical replication pra evitar carga grande. Pra Standard ou superior, sem problema relevante.
Sidekiq scheduled jobs sobrevivem? Sobrevivem se você migrar o Redis com snapshot (BGSAVE → restore). Se reiniciar Redis do zero no destino, perde scheduled jobs. Considera isso no cutover: ou faz a transferência de Redis junto, ou aceita reagendar manualmente alguns jobs.
Posso testar com 1 app antes? Esse é o caminho recomendado. Pega o app menos crítico (interno, ou de baixíssimo tráfego), faz a migração inteira nele primeiro. Aprende com os tropeços ali. Depois migra os de produção com confiança. A primeira migração ensina mais que ler 10 posts como esse.
E se a migração falhar? Os 30 dias de Heroku rodando em paralelo são a sua rede. Se o destino quebrar de forma irreversível na primeira hora, volta o DNS pro Heroku, leva 60 segundos, vida normal. O único caso onde rollback é caro é se você fez cutover de banco com escritas no destino — aí precisa replicar de volta. Por isso a recomendação é cutover de DNS e cutover de banco simultâneos, com janela curta.
Tem caminho de migração assistida do HeroCtl?
Pra o HeroCtl, sim — temos um conversor experimental que lê app.json + Procfile e gera um manifesto de job equivalente. Funciona pra apps simples (web + worker + release), e tropeça em casos exóticos (multi-buildpack pesado, hooks customizados). Se quiser testar, manda mensagem.
Fechamento
Migrar do Heroku quatro anos depois é constrangedor — tinha que ter saído em 2022. Mas quatro anos virando cinco é pior. O custo composto de não migrar (R$25k a R$100k por ano em fatura Heroku acumulada, mais a fragilidade de depender de um produto que a Salesforce já mostrou que não tem afeto por usuários pequenos) é maior que o custo de uma semana de trabalho focado.
Se você decidir testar o HeroCtl, instala em qualquer servidor Linux:
curl -sSL https://get.heroctl.com/install.sh | sh
Funciona em 1 servidor (modo simples) ou em 3+ (modo HA real). O plano Community é gratuito sem limite de servidores e sem limite de jobs — você não precisa decidir nada comercial pra fazer a migração inteira.
Se decidir pelo Render, Railway ou Coolify, ótimo também. O ponto desse post não é capturar você como cliente — é tirar você do Heroku. Quatro anos depois, é hora.
Pra contexto adicional sobre auto-hospedagem em 2026, lê Heroku auto-hospedado: o estado da arte em 2026. Pra entender por que construímos um orquestrador novo em vez de adotar um existente, lê Por que criamos o HeroCtl.