← Blog

Mueve una app Rails de Heroku a Hetzner, paso a paso

Railyard

25 de julio de 2026 · 8 min de lectura

Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta

Los dynos, config vars y heroku pg:backups de Heroku esconden una cantidad real de trabajo de infraestructura detrás de una CLI, por eso irse es más complicado que un git push a otro lado. Una app Rails en Heroku necesita un servidor web, un worker de cola si tiene uno, una base de datos Postgres con los datos reales adentro, cada config var mapeada a un entorno real, y un dominio cambiado sin un hueco donde ninguno de los dos servidores tenga los datos actuales. Esta guía hace todo eso contra un servidor de Hetzner Cloud, en Docker, y termina con la misma app corriendo en infraestructura que controlas.

Lo que necesitas

  • Un servidor de Hetzner Cloud. Un CX23 (2 vCPU, 4 GB de memoria, €5.99/mes sin IVA) se parece a lo que ya corren muchas apps Rails pequeñas en un solo dyno de Heroku más un add-on de Postgres hobby-dev o standard-0; sube a un CX33 (4 vCPU, 8 GB, €8.99/mes) si la app corre un worker de cola junto al proceso web.
  • La CLI de Heroku, ya autenticada, para traer config vars y un dump de base de datos.
  • Un dominio actualmente apuntando a Heroku, con acceso para cambiar su DNS.
  • El código fuente de la app en un repositorio Git. Esta guía usa Rails 8.1, que corre en Ruby 4.0 o más reciente; el Dockerfile de abajo fija Ruby 4.0.8.
  • Docker y el plugin de Compose, ya sea localmente para probar el build primero o en el servidor.

Paso 1: haz inventario de lo que de verdad corre en Heroku

La API de Heroku no expone tu repositorio directamente, así que este paso es manual, y saltárselo es la forma más fácil de que falte algo en el servidor nuevo. Trae las tres cosas que necesitas:

heroku config -a your-app-name > heroku-config.txt
heroku ps -a your-app-name
heroku addons -a your-app-name

heroku ps muestra tu formación actual (web, worker, cualquier otro tipo de proceso y sus cantidades de dynos); heroku addons muestra lo que está realmente aprovisionado (plan de Postgres, Redis, cualquier otra cosa facturada por separado) que necesita un equivalente en el servidor nuevo.

Paso 2: crea el servidor

En Hetzner Cloud Console, Add Server: Ubuntu 24.04, CX23, una ubicación, tu clave SSH.

ssh root@your-server-ip
curl -fsSL https://get.docker.com | sh

Paso 3: escribe el 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 la precompilación de assets evita necesitar el secreto real de la app en tiempo de build, el mismo workaround que usa el propio Dockerfile por defecto de Rails (generado por rails new desde la 7.1), ya que la compilación de assets no necesita el secreto real pero falla sin que haya uno presente.

Paso 4: escribe el archivo 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:

Quita el servicio worker por completo si la salida de heroku ps del paso 1 no mostró ningún dyno worker; una app Rails sin procesamiento de trabajos en segundo plano no necesita ni Sidekiq ni Redis corriendo.

Paso 5: mapea las config vars de Heroku a .env

Abre heroku-config.txt del paso 1 y tradúcelo, variable por variable, al .env del 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 es la que Heroku casi con certeza ya tenía como RAILS_MASTER_KEY en sus propias config vars (las credenciales de Rails se cifran con ella, y las config vars de Heroku son exactamente donde esa llave normalmente vive en producción); cópiala exacta en vez de regenerarla, o cada credencial cifrada en config/credentials.yml.enc se vuelve ilegible. Cualquier otra variable específica de la app en heroku-config.txt (llaves de API, feature flags, cualquier cosa que ENV.fetch lea en tu propio código) también pertenece a este archivo; DATABASE_URL y REDIS_URL específicamente reciben valores nuevos que apuntan a los contenedores del paso 4 en vez de a los de Heroku.

Paso 6: migra la base de datos

Con la app todavía sin correr en el servidor nuevo, trae un dump fresco desde Heroku:

heroku pg:backups:capture -a your-app-name
heroku pg:backups:download -a your-app-name -o latest.dump

Cópialo al servidor nuevo y restáuralo en el contenedor de Postgres recién creado (vacío):

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 específicamente aquí: el dump de Heroku pertenece a un nombre de rol que no existe en tu nuevo contenedor de Postgres, y sin --no-owner, la restauración falla a mitad de camino en cada objeto que no puede reasignar.

Paso 7: levanta el stack completo y verifica

docker compose up -d
docker compose exec app bin/rails db:migrate
docker compose ps

Deberías ver app, worker (si está presente), db, redis y caddy todos como running. Antes de cambiar el DNS, prueba contra la IP cruda del servidor con un encabezado Host para confirmar que la app de verdad sirve páginas reales con datos reales:

curl -H "Host: yourdomain.com" http://your-server-ip

Paso 8: escribe el Caddyfile y cambia el DNS

yourdomain.com {
    reverse_proxy app:3000
}

Baja el TTL del DNS de tu dominio uno o dos días antes del cambio si actualmente está alto, luego apunta el registro A a la IP del servidor nuevo. Heroku sigue sirviendo los datos viejos hasta que el DNS se propaga, que es también la verdadera ventana de riesgo: cualquier escritura que pase en Heroku después del dump del paso 6 pero antes de que el tráfico se mueva por completo al servidor nuevo es una fila que esta migración no tiene. Una ventana de mantenimiento corta, o un último dump incremental tomado justo antes del cambio de DNS, cierra ese hueco para cualquier cosa que no pueda tolerar perder unos minutos de escrituras.

El atajo: hazlo con Railyard

Pegar el nombre de una app de Heroku (o conectar Heroku directamente) en importar desde otra plataforma lee las mismas tres cosas que el paso 1 trajo a mano: config vars, formación (tipos de proceso web y worker con sus cantidades), y add-ons, mapeando heroku-postgresql y heroku-redis a Postgres y Redis aprovisionados automáticamente. Heroku no expone tu repositorio por su API de todas formas, así que igual pegas la URL de Git desde la que haces push, el único paso manual honesto que Railyard tampoco puede saltarse. La migración de base de datos de los pasos 6 y 7 se vuelve una sola importación: apúntala al DATABASE_URL en vivo o sube un dump, y respalda primero lo que haya, restaura, luego corre las migraciones automáticamente, el mismo comportamiento pg_restore --no-owner por debajo sin la bandera manual que recordar. Mira importaciones de base de datos para el flujo exacto.

Solución de problemas

  • pg_restore: error: could not execute query ... role "..." does not exist: --no-owner se omitió del comando de restauración en el paso 6; agrégalo y los mismos objetos se restauran con éxito bajo el rol de conexión en su lugar.
  • La app arranca pero cada página da 500 en credenciales cifradas: RAILS_MASTER_KEY no coincide con lo que cifró config/credentials.yml.enc, usualmente porque se regeneró en vez de copiarse de las propias config vars de Heroku en el paso 5.
  • Los trabajos en segundo plano nunca se procesan: confirma que el servicio worker de verdad está presente en docker compose ps y que REDIS_URL coincide entre app y worker; un trabajo encolado contra una instancia de Redis y un worker sondeando otra nunca se encuentran.
  • El HTTPS nunca se emite después del cambio de DNS: la propagación de DNS puede tardar más de lo esperado según el TTL anterior; dig +short yourdomain.com debería coincidir consistentemente con la IP del servidor nuevo desde varias ubicaciones antes de asumir que Caddy tiene la culpa.
  • Los datos se ven desactualizados después del cambio: este es el hueco de escritura del paso 8; si importa para esta app, rehaz el dump del paso 6 y la restauración del paso 7 justo antes de cambiar el DNS, con las escrituras pausadas en Heroku entre medio.

Preguntas frecuentes

¿Puedo hacer esto sin ningún tiempo de inactividad? No con un simple dump y restauración como lo hace esta guía; una configuración de replicación lógica desde Heroku Postgres hacia el servidor nuevo elimina el hueco de escritura por completo, pero es considerablemente más configuración de la que la mayoría de las apps pequeñas necesitan para los pocos minutos de riesgo que cubre una ventana de mantenimiento.

¿Y el sistema de archivos efímero de Heroku, depende algo de eso? Si la app escribe archivos subidos directamente al disco local esperando que persistan, eso ya estaba roto en Heroku entre reinicios de dyno; esta migración es de hecho un buen momento para mover el almacenamiento de archivos a un almacenamiento de objetos o a un volumen Docker si no se había hecho ya.

¿También tengo que migrar los trabajos programados? Las entradas de Heroku Scheduler no están cubiertas por heroku config ni heroku ps; revisa el panel del add-on de Heroku Scheduler por separado y recréalas como entradas cron o como un horario cron dedicado de Railyard, ya que nada en los pasos 1 a 8 de arriba las muestra automáticamente.

¿De verdad es un CX23 comparable a un dyno de Heroku? El CPU y memoria reales de un dyno standard-1x o standard-2x son modestos en comparación; los 4 GB de un CX23 generalmente superan a un dyno pequeño por el mismo costo mensual o menos, aunque la verdadera prueba es la huella de memoria real de tu propia app bajo tráfico real, no una ficha técnica.

¿Qué pasa si la migración sale mal después de que el DNS ya cambió? Mantén la app de Heroku corriendo (no escalada a cero) por al menos unos días después del cambio específicamente para que el DNS pueda apuntarse de vuelta a ella inmediatamente si algo sale mal en el servidor nuevo; ese es el verdadero plan de reversión, no un archivo de respaldo.

Una app Rails que costaba un dyno de Heroku y un add-on de Postgres cada mes ahora corre en un servidor de Hetzner a €5.99, con los mismos datos de Postgres, las mismas credenciales cifradas, y los mismos trabajos en segundo plano, solo que corriendo en un lugar que aprovisionaste y entiendes por completo.

Por qué eligen Railyard para esto

  • Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
  • Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
  • Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
  • Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
  • Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.

Sáltate la migración manual.

Pega el nombre de tu app de Heroku en Railyard y lee tus config vars, formación y add-ons, luego importa la base de datos directamente.

Plan gratis, sin tarjeta, y 14 días de Pro para cada equipo nuevo. Un precio fijo por equipo.