Migrar de Heroku a cluster propio: guía técnica en 5 pasos
El fin del plan gratuito de Heroku en noviembre/2022 transformó migración en prioridad para cientos de equipos. Plan detallado con checklist, tiempo estimado, y trampas comunes.
El 28 de noviembre de 2022, Salesforce desconectó el plan gratuito de Heroku. Cientos de miles de hobby projects fueron borrados de una sola vez, y el ciclo de noticias duró unos dos meses — gente migrando a Render, a Fly.io, a Railway, a un VPS cualquiera. Lo que nadie previó en aquel momento es lo que ocurrió después: cuatro años pasaron, estamos en 2026, y todavía existen miles de SaaS brasileños en producción pagando entre US$25 y US$100 al mes por dyno solo porque "migrar" es el decimotercero ítem del backlog. Siempre hay una feature más urgente. Siempre hay un cliente preguntando cuándo sale el módulo X. Migrar da cero ingreso nuevo — entonces queda.
Este post es el plan para hacer que esa migración quepa en una semana de trabajo de un dev part-time, y el resto de un mes para estabilizar. No es un manifiesto, no es una comparación de proveedores, no es "vente al HeroCtl". Es un runbook. Al final hay una sección sobre opciones de destino, incluyendo nuestro producto, pero si terminas de leer y vas a Render o a Coolify o a Fly.io, el post hizo el trabajo.
Por qué todavía duele migrar (la verdad no dicha)
La primera cosa que necesita quedar clara: no es el Dockerfile. Escribir Dockerfile para una app Rails o Node es media tarde — hay template listo para cada framework, hay cinco posts en DEV explicándolo, hay Copilot escribiéndolo. Si tu resistencia es "todavía no dockerizamos", esa parte es la menos importante.
El dolor está en el ecosistema:
- Postgres con extensions específicas que olvidaste que activaste en 2019.
pg_stat_statements,pgcrypto,hstore,postgis— cada una es un motivo para que la migración se rompa silenciosamente. - Redis Premium con persistencia que usas para cola de Sidekiq Y para cache Y para rate limit. Para cache puede reiniciar de cero. Para cola no puede.
- Sidekiq workers stateful con jobs agendados meses adelante. Migrar mientras corren es correr atrás del tren en movimiento.
- Heroku Scheduler con aquel cron que nadie mira desde 2020 pero que hace el reporte mensual del CEO.
- Papertrail integrado, NewRelic instrumentado, Bugsnag en todos los errores — tres SaaS extras de los que ni sabes si van a tener sentido en la nueva arquitectura.
- Buildpack que corrió seis años sin nadie saber bien qué hace. Tiene un
bin/post_compileque minifica algo, tiene variable de ambiente que define qué versión de Ruby — en algún lugar, tu aplicación depende de seis comportamientos del buildpack que nunca fueron documentados.
Y está la parte humana: tú y tu equipo internalizaron primitivas Heroku a lo largo de años. Procfile, slug compilation, dynos, release phase, config vars. Todo eso se volvió intuición. Cuando vamos a rehacer fuera de Heroku, rehacemos inconscientemente — y en general mal, porque Heroku tenía defaults que esconden decisiones importantes que ahora son tuyas.
La migración técnica lleva una semana. La migración mental lleva un mes. Este post intenta acortar las dos.
Pre-flight check — una a dos horas, antes de cualquier commit
Antes de abrir el editor, necesitas el inventario. La mayor parte de las migraciones que salen mal es por una sorpresa que podría haber sido descubierta en la primera hora.
Inventario de apps:
heroku apps
¿Cuántas apps existen en la cuenta? ¿Cuáles todavía están en uso de verdad? ¿Cuáles pueden volverse cron-job y morir? ¿Cuáles fueron creadas para un cliente que se fue en 2021? Marca cada una en una hoja de cálculo con tres columnas: nombre, status (vivo/zombi/cron), prioridad de migración (alta/media/baja).
La mayoría de las cuentas tiene 30% de apps zombis. Migrar zombi no tiene ROI — destruir tiene.
Inventario de addons por app:
heroku addons -a mi-app
Cada línea es una decisión futura. ¿Postgres? ¿Redis? ¿Papertrail? ¿Heroku Scheduler? ¿SendGrid? ¿Mailgun? Para cada uno, escribe en la hoja: va a migrar a equivalente self-hosted, va a volverse SaaS externo, o va a descartar. Si no sabes para qué sirve, investiga antes — no en el momento del cutover.
Inventario de buildpacks:
heroku buildpacks -a mi-app
¿Multi-buildpack? ¿Custom buildpack? Si la salida tiene más de una línea, lee cada uno. Buildpack customizado suele tener hooks (bin/release, bin/compile, bin/post_compile) que ejecutan cosas específicas. Vas a necesitar replicar esos pasos en el Dockerfile o en un release container.
Inventario de env vars:
heroku config -a mi-app
Exporta todo a archivo seguro. NO commits. NO mandes por Slack. NO pegues en ChatGPT. Ese archivo tiene DATABASE_URL, SECRET_KEY_BASE, clave de API de pago. Trátalo como contraseña, porque es exactamente eso.
Atención a dos trampas:
- Variables con carácter
:en el nombre (algunas libs antiguas usan) escapan diferente en contenedores. BUNDLE_WITHOUT=development:testgrabado en producción es bomba reloj después de la migración.
Inventario de Procfile:
Cada línea del Procfile es un servicio:
webse vuelve el contenedor principal.workerse vuelve segundo contenedor o job separado.releasese vuelve step pre-deploy (típicamente migrations).clockoschedulerse vuelve cron job.
Si tu Procfile tiene cinco líneas, vas a tener cinco servicios en el destino. No son detalles — son el diseño de la topología.
Métricas actuales:
heroku ps -a mi-app
heroku logs --tail -a mi-app
¿Cuántos dynos corriendo? ¿Cuál el tipo (Standard-1X, Performance-M)? ¿Volumen de logs por minuto? ¿Latencia media en NewRelic? ¿Pico de CPU/memoria del mes pasado?
Esos números sirven para dimensionar el destino. Migrar y descubrir después que la memoria es la mitad de lo necesario es la forma más rápida de romper la confianza en el proyecto entero.
Al final del pre-flight tienes una hoja de cálculo con todo. Ese archivo es el corazón de la migración. Toda decisión vuelve a él.
Paso 1 — Elección de stack destino (decisión arquitectural, 30 minutos)
Tres caminos posibles. Voy a ser honesto sobre cada uno.
Opción A — VPS único con panel self-hosted. Un servidor en DigitalOcean o Hetzner, instala Coolify o Dokploy, despliega tu app por el panel. Costo: R$30 a R$50 al mes para empezar, escala bien hasta unas 10 apps en servidor mediano. Sin alta disponibilidad — si el servidor cae, todo cae. SLA que logras prometer: best-effort.
Ideal para: indie hacker, proyecto personal, MVP, SaaS sin cliente que exige SLA por escrito.
Opción B — Cluster con alta disponibilidad. Tres o más servidores, orquestador que coordina entre ellos, sobrevive a la caída de un servidor sin afectar tráfico. Costo: R$150 a R$300 al mes para un cluster de tres nodos modestos. SLA posible: 99,9% sin desesperación.
Ideal para: SaaS B2B con clientes pagantes, cualquier aplicación donde media hora de downtime genera ticket de soporte.
Opción C — Plataforma gestionada externa. Render, Railway, Fly.io. Pagas más, pero cero ops. Costo: R$200 a R$500 al mes para workload comparable a 2-3 dynos Heroku, escala lineal de ahí en adelante.
Ideal para: equipo que no tiene absolutamente a nadie para cuidar de servidor y prefiere transferir el problema a otra empresa.
Decisión honesta, en una pregunta: ¿hay cliente exigiendo SLA? Si no, opción A. Si sí, B. Si el equipo no tiene a nadie dispuesto a aprender mínima ops, C. No existe respuesta correcta universal — existe respuesta correcta para tu contexto. Mezclar las tres también es válido: app principal en B, herramienta interna en A, scheduler aislado en C.
Paso 2 — Dockerización (medio día a dos días por app)
Aquí el trabajo técnico empieza. La lógica general es la misma para cualquier 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 queda en una etapa que es descartada. Imagen final tiene solo lo necesario para correr.
Por lenguaje:
- Ruby/Rails:
ruby:3.x-slimcomo base, multi-stage para reducir tamaño. El slug compilation de Heroku se volvió tus propias líneas en el Dockerfile —bundle install,assets:precompile, copiar artefactos. - Node:
node:20-alpineresuelve la mayoría de los casos. Atención a deps con binarios nativos (sharp, bcrypt, sqlite3, canvas) — Alpine usa musl, y algunas libs necesitan glibc. Si se rompe, cambia anode:20-slim. - Python/Django:
python:3.x-slim, gunicorn o uvicorn como server.requirements.txtopyproject.tomlen la etapa de build. - Elixir/Phoenix:
elixir:1.x-alpine, release como artefacto (mix release), runtime image solo con erlang.
Mapeo Procfile → Docker:
| Procfile | Equivalente en destino |
|---|---|
web: bundle exec puma | CMD del contenedor principal |
worker: bundle exec sidekiq | Contenedor separado, misma imagen, comando diferente |
release: bundle exec rake db:migrate | Job de release, ejecuta antes del rolling update |
clock: bundle exec clockwork | Cron job, o contenedor singleton |
La mayor parte de los orquestadores modernos (HeroCtl, Render, Railway, Coolify) entiende esos cuatro formatos directamente.
Assets:
Slug compilation de Heroku hace precompile automático. En Docker necesitas pensar:
- Rails:
RUN bundle exec rake assets:precompileen la etapa de build. - Node:
RUN npm run builden la etapa de build. - Asset host (CDN): si usas CloudFront o S3 para servir static, configurar
RAILS_SERVE_STATIC_FILESyASSET_HOSTcorrectamente.
Tiempo medio realista:
- App Rails media (CRUD con Sidekiq): 1 a 2 días.
- App Node simple (API, sin build pesado de frontend): 4 horas.
- App con 5+ workers stateful y procesamiento de medios: 3 a 5 días.
La primera app demora más. La segunda demora la mitad. De la tercera en adelante, es mecánico.
Paso 3 — Migración de base (la parte más arriesgada, 2 a 8 horas)
Aquí mora el miedo. Base es el único lugar donde "volver atrás" es caro. Todo lo demás es redeploy.
Postgres:
Heroku Postgres expone acceso directo vía pg_dump si tienes las credenciales (están en DATABASE_URL). Antes de cualquier cosa, descubre tus extensions:
SELECT extname, extversion FROM pg_extension;
Comunes: pgcrypto, hstore, postgis, pg_stat_statements, uuid-ossp, unaccent. Si el destino no tiene todas, o tiene en versión diferente, lo descubres antes — no en medio del restore a las 3 de la mañana.
Destino posible para Postgres:
- Postgres corriendo como job en el propio cluster (RPO/RTO menor, control total, tú cuidas del backup).
- Postgres gestionado regional — RDS São Paulo, Neon, Supabase, Aiven. Más caro, menos ops.
Migración con mínimo downtime — opción A (con ventana):
# Drena tráfico: pone app en mantenimiento, espera Sidekiq vaciar
heroku maintenance:on -a mi-app
# Dump
pg_dump $HEROKU_DATABASE_URL --no-owner --no-privileges --format=custom --file=dump.sql
# Restore en destino
pg_restore --no-owner --no-privileges --dbname=$DEST_DATABASE_URL dump.sql
# Smoke test en destino
psql $DEST_DATABASE_URL -c 'SELECT count(*) FROM users;'
# Cutover de DNS, app en destino apunta a la nueva base
heroku maintenance:off -a mi-app # opcional, solo para que Heroku siga atendiendo /healthz
Ventana típica: 30 minutos a 2 horas, dependiendo del tamaño de la base. Para base por debajo de 5GB, 30 min es holgado.
Migración con mínimo downtime — opción B (logical replication):
Replicación lógica de Postgres permite que inicies la copia mientras la app sigue escribiendo en Heroku. Cuando la réplica llega al estado actual, haces el cutover de DNS y el destino se vuelve el nuevo primario.
Funciona si el destino logra alcanzar Heroku vía red. Para Heroku Postgres necesita abrir whitelist de la IP del destino (Heroku tiene mecanismo para eso en planes pagados). Setup lleva una tarde, cutover dura segundos.
Redis:
Dos naturalezas distintas — trata diferente:
- Redis como cache: simplemente reinicia de cero en el destino. El cache se recalienta solo. No hay nada que migrar.
- Redis como cola Sidekiq/Resque con persistencia: aquí duele. Snapshot vía
BGSAVE, transfiere el RDB, restore en el destino. O: pausa los workers en Heroku, procesa la cola hasta el final, haz cutover con cola vacía.
Redis Premium de Heroku tiene persistencia activada por default; Redis simple en el destino puede no tener — verifica antes.
Paso 4 — DNS, SSL y cutover (1 a 3 horas)
El cutover es la hora de la verdad. Todo lo que vino antes era preparación.
24 horas antes:
Reduce el TTL del registro DNS a 60 segundos. Eso garantiza que, cuando apuntes al destino, propaga rápido. TTL alto es lo que hace cutover volverse pesadilla de 6 horas con la mitad de los clientes todavía hitting el servidor antiguo.
Setup paralelo:
App corriendo en paralelo en los dos destinos. Heroku sigue respondiendo en el dominio antiguo. Destino responde en un dominio temporal (ej: app-nuevo.heroctl.com).
Smoke test en el destino:
curl https://app-nuevo.heroctl.com/healthz
curl https://app-nuevo.heroctl.com/api/v1/usuarios -H "Authorization: Bearer $TOKEN"
# Hit endpoints críticos manualmente, con ojos humanos
Si algo está mal, descúbrelo ahora. Después del cutover vas a estar lidiando con tickets de soporte simultáneamente.
Cutover:
Cambia el CNAME (o A record) del dominio de producción al destino. En hasta 60 segundos, nuevas requests van al destino nuevo. Heroku sigue respondiendo en el dominio antiguo (la URL *.herokuapp.com) por 30 días — eso es cinturón de seguridad importante.
SSL/TLS:
Heroku tenía certificado automático embebido. En el destino, dependiendo de la elección:
- HeroCtl, Coolify, Render, Railway, Fly.io: certificado automático vía Let's Encrypt, sin pensar.
- VPS único desnudo: configuras cert-manager-equivalente, o Caddy con ACME, o nginx + certbot.
Antes del cutover de DNS, valida que el destino emitió el certificado para el dominio. Let's Encrypt valida vía HTTP-01 o DNS-01 — el desafío HTTP-01 solo funciona después de que el DNS apunte, entonces tienes huevo-gallina. Solución: emite vía DNS-01 antes (no necesita que el DNS apunte al destino), o acepta 30 segundos de error de TLS en el momento del cutover.
Sticky sessions:
Si tu app usa WebSocket, o tiene sesión en memoria (en lugar de Redis o base), necesitas sticky session en el balanceador. Heroku no hacía eso por default, pero algunas apps terminan dependiendo de ruteo estable sin percibir. En el destino, configura cookie-based session affinity si es necesario.
Paso 5 — Decommission Heroku (1 hora, 30 días después)
Treinta días es el cinturón de seguridad. Mantén la app en Heroku encendida, sin tráfico (al final el DNS ya apuntó a otro lugar), solo por si acaso. Costo: lo que ya estabas pagando, dividido proporcionalmente hasta la fecha de cancelación.
Treinta días después, si nada se rompió:
heroku addons:destroy heroku-postgresql -a mi-app
heroku addons:destroy heroku-redis -a mi-app
heroku addons:destroy papertrail -a mi-app
heroku apps:destroy mi-app
Cada addon tiene que ser cancelado separadamente — algunos tienen billing propio que sigue incluso con app destruido. Verifica la factura del mes siguiente con lupa.
Heroku hace reembolso pro-rata hasta el día de la cancelación. No olvides cancelar la cuenta entera si es la última app — sino pagas tarifa de plataforma todos los meses por nada.
Trampas comunes
La mayor parte de las migraciones se traba en esas ocho cosas. Lee todo antes de empezar.
Slug compilation hooks invisibles. Apps antiguas tienen bin/release, bin/post_compile, bin/pre_compile. Esos scripts corren dentro del buildpack y hacen cosas como minificar JS, generar archivos derivados, o correr una migración que nadie recuerda. Antes de Dockerizar, abre cada uno y replica en un step del Dockerfile o en release container.
Config vars con formato roto. Heroku acepta MY:VAR como nombre de variable (con :). Contenedores en general también, pero algunas herramientas de orquestación escapan diferente. Renombra a MY_VAR antes de migrar.
Redis URL con formato variante. Heroku usa redis://h:senha@host:port. Algunos clients (gems Ruby antiguas, principalmente) esperan redis://:senha@host:port. Si ves Redis::CommandError: WRONGPASS, es probablemente eso.
BUNDLE_WITHOUT=development:test grabado en el env. Cuando corres ese mismo contenedor fuera de Heroku, sigue sin instalar gems de desarrollo. En producción, ok. En staging donde necesitas correr pruebas, se rompe. Limpia esa variable antes de usar el config dump en otro ambiente.
Gems específicas de Heroku. rails_12factor (deprecado pero aún en apps de 2014), heroku_san, taps. Removiste, fin. Si algo depende, cambia por equivalente estándar.
DNS con Heroku-DNS-Target. Heroku recomienda usar ALIAS o ANAME para apuntar a la app, en lugar de CNAME, para raíces de dominio. Cuando migres, cambia a A record directo a la IP del destino. ALIAS apuntando a Heroku es lo que va a fastidiarte en dominios apex.
Papertrail / NewRelic / Bugsnag desconectados sin sustituto. Logs y observabilidad son fáciles de dejar para después y romperse en la primera hora post-migración. Antes del cutover, tiene que tener: logs centralizados (HeroCtl tiene escritor único embebido; Render expone vía UI; Coolify tiene Loki opcional), métricas básicas (CPU, memoria, requests), y alguna herramienta de errores (Sentry self-hosted o SaaS).
Sidekiq/Resque con jobs en vuelo durante cutover. Durante el momento del cutover, algunos jobs van a la cola del destino sin haber sido procesados en el origen. Si tu job no es idempotente (puede correr dos veces sin efecto colateral), eso es problema. Solución: pausa los workers en Heroku 5 minutos antes del cutover, espera que la cola vacíe, haz cutover con cola vacía.
Cronograma realista para startup media (5 a 10 apps Heroku)
Equipo pequeño, un dev part-time:
- Semana 1: pre-flight completo + elección de stack + setup del destino (cluster vacío corriendo, panel accesible).
- Semana 2: Dockerización del primer app de bajo riesgo + migración de base en ambiente de staging.
- Semana 3: cutover del primer app en producción + validación de 7 días.
- Semanas 4 a 6: migración de los demás apps en paralelo, ritmo de 1 a 2 por semana.
- Total: 4 a 6 semanas de elapsed time, tal vez 80 horas de trabajo efectivo distribuidas.
Equipo medio (3 devs, 20 apps): 8 semanas, 200 horas de trabajo efectivo.
Equipo grande (cluster de 50+ apps): trátalo como proyecto formal, con gerente de proyecto, y calcula trimestre.
La regla de bolsillo: nunca migres más de 2 apps en paralelo si es el mismo dev haciéndolo. El costo de contexto-switching engulle la ganancia de paralelismo.
FAQ
¿Cuánto cuesta la migración en horas-hombre? Para un SaaS de 5 apps, dev part-time: ~80 horas. A R$200/h, R$16k. Comparado a R$2k/mes de factura Heroku que ahorras, payback en 8 meses. En los 4 años siguientes, es solo economía.
¿Y si no tengo Docker setup? No necesitas pre-instalar nada — las plataformas de destino construyen la imagen por ti (Render, Railway, Fly.io aceptan Dockerfile directo del git). HeroCtl exige imagen en registry, entonces subes a ECR, GCR, Docker Hub o GHCR. Para uso local, instalas Docker Desktop y listo.
¿Heroku Postgres tiene export limit?
Tiene límite de IOPS durante pg_dump en planes bajos. Bases por encima de 5GB en plan Hobby pueden necesitar pg_dump en modo paralelo (-j) o usar logical replication para evitar carga grande. Para Standard o superior, sin problema relevante.
¿Sidekiq scheduled jobs sobreviven? Sobreviven si migras el Redis con snapshot (BGSAVE → restore). Si reinicias Redis de cero en el destino, pierdes scheduled jobs. Considera eso en el cutover: o haces la transferencia de Redis junto, o aceptas reagendar manualmente algunos jobs.
¿Puedo probar con 1 app antes? Ese es el camino recomendado. Toma el app menos crítico (interno, o de bajísimo tráfico), haz la migración entera en él primero. Aprende con los tropiezos ahí. Después migras los de producción con confianza. La primera migración enseña más que leer 10 posts como este.
¿Y si la migración falla? Los 30 días de Heroku corriendo en paralelo son tu red. Si el destino se rompe de forma irreversible en la primera hora, vuelve el DNS a Heroku, lleva 60 segundos, vida normal. El único caso donde rollback es caro es si hiciste cutover de base con escrituras en el destino — ahí necesitas replicar de vuelta. Por eso la recomendación es cutover de DNS y cutover de base simultáneos, con ventana corta.
¿Hay camino de migración asistida del HeroCtl?
Para HeroCtl, sí — tenemos un conversor experimental que lee app.json + Procfile y genera un manifiesto de job equivalente. Funciona para apps simples (web + worker + release), y tropieza en casos exóticos (multi-buildpack pesado, hooks customizados). Si quieres probar, manda mensaje.
Cierre
Migrar de Heroku cuatro años después es vergonzoso — tenía que haber salido en 2022. Pero cuatro años volviéndose cinco es peor. El costo compuesto de no migrar (R$25k a R$100k por año en factura Heroku acumulada, más la fragilidad de depender de un producto que Salesforce ya mostró que no tiene cariño por usuarios pequeños) es mayor que el costo de una semana de trabajo enfocado.
Si decides probar HeroCtl, instala en cualquier servidor Linux:
curl -sSL https://get.heroctl.com/install.sh | sh
Funciona en 1 servidor (modo simple) o en 3+ (modo HA real). El plan Community es gratuito sin límite de servidores y sin límite de jobs — no necesitas decidir nada comercial para hacer la migración entera.
Si decides por Render, Railway o Coolify, genial también. El punto de este post no es capturarte como cliente — es sacarte de Heroku. Cuatro años después, es hora.
Para contexto adicional sobre auto-hospedaje en 2026, lee Heroku auto-hospedado: el estado del arte en 2026. Para entender por qué construimos un orquestador nuevo en lugar de adoptar uno existente, lee Por qué creamos HeroCtl.