← Blogue

Déplacer une application Rails de Heroku vers Hetzner, étape par étape

Railyard

25 juillet 2026 · 9 min de lecture

Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte

Les dynos, config vars et heroku pg:backups de Heroku cachent une vraie quantité de travail d’infrastructure derrière une CLI, c’est exactement pourquoi partir est plus compliqué qu’un git push ailleurs. Une application Rails sur Heroku a besoin d’un serveur web, d’un worker de file d’attente si elle en a un, d’une base de données Postgres avec les vraies données dedans, de chaque config var mappée à un vrai environnement, et d’un domaine basculé sans écart où ni l’un ni l’autre serveur n’a les données actuelles. Ce guide fait tout ça contre un serveur Hetzner Cloud, dans Docker, et termine avec la même application qui tourne sur une infrastructure que vous contrôlez.

Ce qu’il vous faut

  • Un serveur Hetzner Cloud. Un CX23 (2 vCPU, 4 Go de mémoire, 5,99 € par mois avant taxes) correspond à ce sur quoi beaucoup de petites applications Rails tournent déjà avec un seul dyno Heroku plus un add-on Postgres hobby-dev ou standard-0 ; passez à un CX33 (4 vCPU, 8 Go, 8,99 € par mois) si l’application fait tourner un worker de file d’attente à côté du processus web.
  • La CLI Heroku, déjà authentifiée, pour rapatrier les config vars et un dump de base de données.
  • Un domaine actuellement pointé vers Heroku, avec accès pour changer son DNS.
  • Le code source de l’application dans un dépôt Git. Ce guide utilise Rails 8.1, qui tourne sur Ruby 4.0 ou plus récent ; le Dockerfile ci-dessous fixe Ruby 4.0.8.
  • Docker et le plugin Compose, soit localement pour tester le build d’abord, soit sur le serveur.

Étape 1 : faire l’inventaire de ce qui tourne réellement sur Heroku

L’API de Heroku n’expose pas votre dépôt directement, donc cette étape est manuelle, et la sauter est la façon la plus facile de manquer quelque chose sur le nouveau serveur. Rapatriez les trois choses dont vous avez besoin :

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

heroku ps montre votre formation actuelle (web, worker, tout autre type de processus et leurs comptes de dynos) ; heroku addons montre ce qui est réellement provisionné (plan Postgres, Redis, tout autre chose facturée séparément) qui a besoin d’un équivalent sur le nouveau serveur.

Étape 2 : créer le serveur

Dans la Hetzner Cloud Console, Add Server : Ubuntu 24.04, CX23, un emplacement, votre clé SSH.

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

Étape 3 : écrire le 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 pendant la précompilation des assets évite d’avoir besoin du vrai secret de l’application au moment du build, le même contournement que le Dockerfile par défaut de Rails lui-même (généré par rails new depuis la 7.1) utilise, puisque la compilation des assets n’a pas besoin du vrai secret mais échoue sans qu’il n’y en ait un présent.

Étape 4 : écrire le fichier 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:

Retirez complètement le service worker si la sortie de heroku ps de l’étape 1 ne montrait aucun dyno worker ; une application Rails sans traitement de tâches de fond n’a besoin ni de Sidekiq ni de Redis en marche.

Étape 5 : mapper les config vars Heroku vers .env

Ouvrez heroku-config.txt de l’étape 1 et traduisez-le, variable par variable, dans le .env du serveur :

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 est celle que Heroku avait presque certainement déjà comme RAILS_MASTER_KEY dans ses propres config vars (les credentials de Rails sont chiffrées avec elle, et les config vars de Heroku sont exactement là où cette clé vit habituellement en production) ; copiez-la exactement plutôt que de la régénérer, sinon chaque credential chiffrée dans config/credentials.yml.enc devient illisible. Chaque autre variable spécifique à l’application dans heroku-config.txt (clés API, drapeaux de fonctionnalité, tout ce que ENV.fetch lit dans votre propre code) appartient aussi à ce fichier ; DATABASE_URL et REDIS_URL reçoivent spécifiquement de nouvelles valeurs pointant vers les conteneurs de l’étape 4 plutôt que vers ceux de Heroku.

Étape 6 : migrer la base de données

Avec l’application pas encore en marche sur le nouveau serveur, rapatriez un dump frais depuis Heroku :

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

Copiez-le vers le nouveau serveur et restaurez-le dans le conteneur Postgres fraîchement créé (vide) :

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 compte particulièrement ici : le dump de Heroku appartient à un nom de rôle qui n’existe pas sur votre nouveau conteneur Postgres, et sans --no-owner, la restauration échoue en cours de route sur chaque objet qu’elle ne peut pas réassigner.

Étape 7 : démarrer la pile complète et vérifier

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

Vous devriez voir app, worker (s’il est présent), db, redis et caddy tous running. Avant de basculer le DNS, testez contre l’IP brute du serveur avec un en-tête Host pour confirmer que l’application sert réellement de vraies pages avec de vraies données :

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

Étape 8 : écrire le Caddyfile et basculer le DNS

yourdomain.com {
    reverse_proxy app:3000
}

Baissez le TTL DNS de votre domaine un jour ou deux avant le basculement s’il est actuellement élevé, puis pointez l’enregistrement A vers l’IP du nouveau serveur. Heroku continue de servir les anciennes données jusqu’à ce que le DNS se propage, ce qui est aussi la vraie fenêtre de risque : toute écriture qui se produit sur Heroku après le dump de l’étape 6 mais avant que le trafic ne se déplace entièrement vers le nouveau serveur est une ligne que cette migration n’a pas. Une courte fenêtre de maintenance, ou un dernier dump incrémental pris juste avant le changement de DNS, referme cet écart pour tout ce qui ne peut pas tolérer de perdre quelques minutes d’écritures.

Le raccourci : faites-le avec Railyard

Coller le nom d’une application Heroku (ou connecter Heroku directement) dans importer depuis une autre plateforme lit les trois mêmes choses que l’étape 1 a rapatriées à la main : config vars, formation (types de processus web et worker avec leurs comptes), et add-ons, mappant heroku-postgresql et heroku-redis vers Postgres et Redis provisionnés automatiquement. Heroku n’expose pas votre dépôt via son API de toute façon, donc vous collez quand même l’URL Git depuis laquelle vous poussez, la seule étape manuelle honnête que Railyard ne peut pas non plus sauter. La migration de base de données des étapes 6 et 7 devient un import unique : pointez-le vers le DATABASE_URL en direct ou téléversez un dump, et il sauvegarde d’abord ce qui est là, restaure, puis exécute les migrations automatiquement, le même comportement pg_restore --no-owner sous le capot sans le drapeau manuel à retenir. Voyez imports de base de données pour le flux exact.

Dépannage

  • pg_restore: error: could not execute query ... role "..." does not exist : --no-owner a été omis de la commande de restauration à l’étape 6 ; ajoutez-le et les mêmes objets se restaurent avec succès sous le rôle de connexion à la place.
  • L’application démarre mais chaque page fait une 500 sur des credentials chiffrées : RAILS_MASTER_KEY ne correspond pas à ce qui a chiffré config/credentials.yml.enc, habituellement parce qu’elle a été régénérée plutôt que copiée depuis les propres config vars de Heroku à l’étape 5.
  • Les tâches de fond ne traitent jamais rien : confirmez que le service worker est bien présent dans docker compose ps et que REDIS_URL correspond entre app et worker ; une tâche mise en file contre une instance Redis et un worker qui sonde une autre ne se rencontrent jamais.
  • Le HTTPS ne s’émet jamais après le basculement DNS : la propagation DNS peut prendre plus longtemps que prévu selon le TTL précédent ; dig +short yourdomain.com devrait correspondre de façon constante à l’IP du nouveau serveur depuis plusieurs emplacements avant d’assumer que Caddy est en cause.
  • Les données semblent périmées après le basculement : c’est l’écart d’écriture de l’étape 8 ; si ça compte pour cette application, refaites le dump de l’étape 6 et la restauration de l’étape 7 juste avant de basculer le DNS, avec les écritures mises en pause sur Heroku entre les deux.

FAQ

Puis-je faire ça sans aucun temps d’arrêt ? Pas avec un simple dump-et-restauration comme ce guide le fait ; une configuration de réplication logique depuis Heroku Postgres vers le nouveau serveur élimine entièrement l’écart d’écriture, mais c’est sensiblement plus de configuration que ce dont la plupart des petites applications ont besoin pour les quelques minutes de risque qu’une fenêtre de maintenance couvre.

Et le système de fichiers éphémère de Heroku, est-ce que quelque chose en dépend ? Si l’application écrit des téléversements directement sur le disque local en s’attendant à ce qu’ils persistent, c’était déjà cassé sur Heroku entre les redémarrages de dyno ; cette migration est en fait un bon moment pour déplacer le stockage de fichiers vers un stockage objet ou un volume Docker si ce n’était pas déjà fait.

Dois-je aussi migrer les tâches planifiées ? Les entrées de Heroku Scheduler ne sont pas couvertes par heroku config ni heroku ps ; vérifiez le tableau de bord de l’add-on Heroku Scheduler séparément et recréez-les comme entrées cron ou comme horaire cron dédié de Railyard, puisque rien dans les étapes 1 à 8 ci-dessus ne les fait apparaître automatiquement.

Un CX23 est-il vraiment comparable à un dyno Heroku ? Le CPU et la mémoire réels d’un dyno standard-1x ou standard-2x sont modestes en comparaison ; les 4 Go d’un CX23 surpassent généralement un petit dyno pour un coût mensuel égal ou inférieur, même si le vrai test est l’empreinte mémoire réelle de votre propre application sous du vrai trafic, pas une fiche technique.

Et si la migration tourne mal après que le DNS a déjà basculé ? Gardez l’application Heroku en marche (pas réduite à zéro) pendant au moins quelques jours après le basculement, spécifiquement pour que le DNS puisse être repointé vers elle immédiatement si quelque chose ne va pas sur le nouveau serveur ; c’est le vrai plan de repli, pas un fichier de sauvegarde.

Une application Rails qui coûtait un dyno Heroku et un add-on Postgres chaque mois tourne maintenant sur un serveur Hetzner à 5,99 €, avec les mêmes données Postgres, les mêmes credentials chiffrées, et les mêmes tâches de fond, simplement en marche quelque part que vous avez provisionné et que vous comprenez entièrement.

Pourquoi les gens choisissent Railyard pour ça

  • Votre propre compte infonuagique. Déployez sur DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou n'importe quel serveur Linux par SSH, facturé directement par votre fournisseur, sans majoration.
  • Un prix fixe par équipe. L'offre gratuite est gratuite pour toujours : un serveur plus un serveur de base de données, trois applications, sans carte. Pro coûte 19 $ par mois pour jusqu'à cinq serveurs et un nombre illimité d'applications. Team coûte 49 $ par mois avec des serveurs illimités. Aucuns frais par application, par déploiement, par siège ou par gigaoctet.
  • Des gabarits en un clic. Déployez des applications open source, comme des bases de données, des outils d'analyse et des outils de développement, sur votre propre serveur en un clic.
  • Des bases de données gérées. Postgres, MySQL et Redis, avec des sauvegardes planifiées, vérifiées par restauration, et la récupération à un point dans le temps.
  • Déploiement par push. Déployez depuis GitHub, GitLab ou la CLI, avec HTTPS automatique et des mises en production sans interruption.

Sautez la migration manuelle.

Collez le nom de votre application Heroku dans Railyard et il lit vos config vars, votre formation et vos add-ons, puis importe la base de données directement.

Offre gratuite, sans carte, et 14 jours de Pro pour chaque nouvelle équipe. Prix fixe par équipe.