De Heroku à Railway : Migrer des microservices Spring Boot

De Heroku à Railway : Migrer des microservices Spring Boot
par Zelkulon15 mars 20261 min de lecture

De Heroku à Railway étape par étape – avec tous les pièges rencontrés en chemin. Du pom.xml à la découverte de services Eureka.

Point de départ : pourquoi nous avons quitté Heroku

Heroku nous a bien servi pendant des années. Déploiements rapides, configuration simple – parfait pour démarrer. Mais avec un nombre croissant de services, les coûts sont devenus un problème. Le pire était le délai de démarrage à froid : les Dynos Basic s’endorment après 30 minutes d’inactivité et peuvent prendre jusqu’à 30 secondes pour se réveiller. C’est inacceptable pour une application professionnelle.

Heroku (vorher)
─────────────────────────────────────────────────────
  auth-service    ($7/Monat)   → Basic Dyno
  blog-service    ($7/Monat)   → Basic Dyno
  PostgreSQL      ($5/Monat)   → Mini Plan
  [weitere Services ...]
─────────────────────────────────────────────────────
  Gesamt: ~$46/Monat + Spin-up Delay bei jedem Kaltstart

Après une brève évaluation, nous avons choisi Railway. Le plan Pro à 20 $/mois incluant 20 $ de crédits peut sembler plus cher qu’un seul Dyno au premier abord – mais Railway ne dort jamais. Et lorsqu’on fait tourner plusieurs services, le calcul devient vite favorable.

Configurer Railway en 5 minutes

Commencer avec Railway est simple. Après l’inscription, choisissez le plan Pro et créez un nouveau projet. Créez un service Railway distinct pour chaque service backend.

  • Créer un compte Railway sur railway.app
  • Choisir le plan Pro (20 $/mois incluant 20 $ de crédits)
  • Créer un nouveau projet et ajouter PostgreSQL comme service
  • Ajouter chaque microservice comme service Railway distinct et connecter à GitHub

La fonctionnalité clé : Railway déploie automatiquement à chaque push sur la branche configurée. Pas de déploiement manuel, pas de configuration CI/CD nécessaire.

Railway Projekt
─────────────────────────────────────────────────────
  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
  │ auth-service│  │blog-service │  │  PostgreSQL │
  │  (GitHub ↻) │  │  (GitHub ↻) │  │  (managed)  │
  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘
         └────────────────┴─────────────────┘
                   Private Network
              (.railway.internal - kostenlos)

Configurer les variables d’environnement

Railway utilise une syntaxe spéciale à doubles accolades pour les références de variables service-à-service. Cela permet d’utiliser les valeurs d’un service directement comme variables dans un autre – sans copier manuellement les identifiants. Certains services nécessitent à la fois JDBC_DATABASE_URL et SPRING_DATASOURCE_URL – les deux peuvent pointer vers la même référence Railway.

# ── Auth-Service ────────────────────────────────────────────────
AUTH_BASE_URL="https://your-auth-service-production.up.railway.app"
AUTH_COOKIE_NAME="your_jwt_cookie"
AUTH_COOKIE_SECURE="true"
CORS_ORIGINS="https://www.yourdomain.com,https://*.vercel.app"

JDBC_DATABASE_URL="jdbc:postgresql://${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:5432/${{Postgres.PGDATABASE}}?sslmode=disable"
JDBC_DATABASE_USERNAME="${{Postgres.PGUSER}}"
JDBC_DATABASE_PASSWORD="${{Postgres.POSTGRES_PASSWORD}}"
SPRING_PROFILES_ACTIVE="prod"
SWAGGER_ENABLED="false"
# ── Blog-Service (mit Liquibase) ────────────────────────────────
AUTH_JWT_SECRET="your-secret-min-256-bit"   # NIEMALS committen!
AUTH_JWT_TTL="3600"

# Railway nutzt JDBC_DATABASE_URL – manche Services brauchen zusätzlich
# SPRING_DATASOURCE_URL (Spring Boot liest beide, je nach Konfiguration)
JDBC_DATABASE_URL="jdbc:postgresql://${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:5432/${{Postgres.PGDATABASE}}?sslmode=disable"
SPRING_DATASOURCE_URL="jdbc:postgresql://${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:5432/${{Postgres.PGDATABASE}}?sslmode=disable"
JDBC_DATABASE_USERNAME="${{Postgres.PGUSER}}"
SPRING_DATASOURCE_USERNAME="${{Postgres.PGUSER}}"
JDBC_DATABASE_PASSWORD="${{Postgres.POSTGRES_PASSWORD}}"
SPRING_DATASOURCE_PASSWORD="${{Postgres.POSTGRES_PASSWORD}}"

SPRING_PROFILES_ACTIVE="prod"
LIQUIBASE_ENABLED="true"
SWAGGER_ENABLED="false"

L’erreur la plus courante lors d’une première migration Railway : Spring Boot ne peut pas lire le format d’URL JDBC standard depuis une variable DATABASE_URL au format postgresql://. Spring exige strictement le format JDBC – que ce soit via JDBC_DATABASE_URL ou SPRING_DATASOURCE_URL.

# FALSCH – Spring Boot kann dieses Format nicht verarbeiten:
DATABASE_URL="postgresql://user:pass@host:5432/db"

# RICHTIG – JDBC-Format ist Pflicht (mit oder ohne SPRING_-Präfix):
JDBC_DATABASE_URL="jdbc:postgresql://${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:5432/${{Postgres.PGDATABASE}}?sslmode=disable"
SPRING_DATASOURCE_URL="jdbc:postgresql://${{Postgres.RAILWAY_PRIVATE_DOMAIN}}:5432/${{Postgres.PGDATABASE}}?sslmode=disable"

Microservices : résoudre le problème du POM parent

Si vos microservices sont organisés dans un monorepo, vous rencontrerez une erreur inattendue lors du premier build Railway : Railway construit chaque service dans un contexte séparé – le POM parent du répertoire supérieur n’est pas disponible. La solution est simple : donnez à chaque service son propre Spring Boot parent.

<!-- ALT: Parent-POM aus dem Monorepo (Railway kennt diesen Pfad nicht) -->
<parent>
  <groupId>com.example</groupId>
  <artifactId>parent-backend</artifactId>
  <version>1.0.0</version>
  <relativePath>../pom.xml</relativePath>
</parent>

<!-- NEU: Direkt auf Spring Boot Parent verweisen -->
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.4.3</version>
  <relativePath/>
</parent>

Si des dépendances Spring Cloud sont impliquées, il faut également ajouter un bloc dependencyManagement au pom.xml – puisque le POM parent ne le fournit plus.

<properties>
  <spring-cloud.version>2024.0.1</spring-cloud.version>
</properties>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>${spring-cloud.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Autres erreurs fréquentes et leurs correctifs

Au-delà du POM parent, d’autres erreurs sont apparues lors de la migration. Voici les trois plus importantes :

⚠ Dockerfile : mauvais chemin JAR

Problem: Le Dockerfile référence un sous-répertoire dans le contexte de build qui n’existe plus dans un build service unique.

Fix: Utiliser le chemin sans sous-répertoire – le service est maintenant directement à la racine du build.

# Falsch: Unterordner existiert nicht wenn Service eigenes Root hat
COPY --from=builder /build/service-name/target/app.jar app.jar

# Richtig:
COPY --from=builder /build/target/app.jar app.jar

⚠ Spring Cloud Gateway : NoClassDefFoundError

Problem: spring-cloud-starter-gateway-server-webmvc est incompatible avec Spring Boot 3.4.x et lève NoClassDefFoundError: HttpRedirects.

Fix: Rétrograder vers Spring Boot 3.3.8 + Spring Cloud 2023.0.5, ou passer au spring-cloud-starter-gateway réactif.

<!-- Option A: Spring Boot + Spring Cloud downgraden (stabil) -->
<parent>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.3.8</version>
</parent>
<properties>
  <spring-cloud.version>2023.0.5</spring-cloud.version>
</properties>

<!-- Option B: Reaktives Gateway verwenden -->
<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

⚠ Mauvais domaine privé Eureka

Problem: Les domaines privés Railway n’ont PAS de suffixe "-production" – eurekaservice-production.railway.internal ne fonctionne pas.

Fix: Utiliser le domaine privé sans suffixe : eurekaservice.railway.internal

# Falsch – "-production" Suffix existiert nicht:
EUREKA_SERVER=http://eurekaservice-production.railway.internal:8761/eureka

# Richtig – Private Domain ohne Suffix:
EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eurekaservice.railway.internal:8761/eureka

Découverte de services avec Eureka sur Railway

Si vous avez conservé Eureka de l’ère Heroku, vous avez probablement eureka.client.enabled=false dans votre application-prod.yml. Sur Railway, la découverte de services doit être à nouveau active – cela doit être explicitement activé.

# application-prod.yml
eureka:
  client:
    enabled: true   # War auf Heroku deaktiviert – nicht vergessen!
    service-url:
      defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://localhost:8761/eureka}
  instance:
    prefer-ip-address: true

Sur Railway, tous les services communiquent via le réseau privé. Eureka tourne sur un service Railway et est accessible via son domaine privé. Important : le domaine suit toujours le schéma servicename.railway.internal – sans suffixe d’environnement.

Service Discovery Flow (Railway Private Network)
─────────────────────────────────────────────────────────────
  auth-service ──register──▶ eurekaservice.railway.internal
  blog-service ──register──▶        :8761/eureka
                                         │
  gateway  ──fetch registry──────────────┘
      │
      └──route /api/auth/**──▶ auth-service (via Eureka)
      └──route /api/blog/**──▶ blog-service (via Eureka)

Domaines et réseau privé

Railway distingue deux types de domaines à connaître :

  • Domaine public : servicename-production.up.railway.app – HTTPS, accessible de l’extérieur, pour les appels API depuis le frontend
  • Domaine privé : servicename.railway.internal – interne uniquement, gratuit (pas de trafic egress)
  • Références de variables : syntaxe Railway à doubles accolades pour la configuration service-à-service
  • Configuration du port : Settings → Networking → Generate Domain → entrer le port 8080
# Domains in Railway generieren:
# Settings → Networking → Generate Domain → Port 8080

# Ergebnis (Beispiel):
# Public:  your-auth-service-production.up.railway.app   (HTTPS, extern)
# Private: your-auth-service.railway.internal             (intern, kostenlos)

# PostgreSQL bietet zusätzlich eine TCP-Proxy-URL für externe Tools (z.B. DBeaver):
DATABASE_PUBLIC_URL="postgresql://${{PGUSER}}:${{POSTGRES_PASSWORD}}@${{RAILWAY_TCP_PROXY_DOMAIN}}:${{RAILWAY_TCP_PROXY_PORT}}/${{PGDATABASE}}"

# Intern nutzen Services dagegen immer die Private Domain:
DATABASE_URL="postgresql://${{PGUSER}}:${{POSTGRES_PASSWORD}}@${{RAILWAY_PRIVATE_DOMAIN}}:5432/${{PGDATABASE}}"

Comparaison des coûts

Après une semaine en production, voici le tableau :

Kostenvergleich (monatlich)
─────────────────────────────────────────────────────
  Heroku                     Railway
  ──────────────────         ──────────────────
  8× Basic Dyno  $56         Pro Plan     $20
  2× PostgreSQL  $10         (inkl. $20 Credits)
  ──────────────────         ──────────────────
  Gesamt:      ~$47          Gesamt:     ~$15 *

  * geschätzt nach erster Woche, abhängig vom Traffic

  Ersparnis: ~$32/Monat = ~$384/Jahr

Le plus grand avantage n’est pas seulement le prix : les services Railway tournent en continu – pas de mise en veille, pas de délai de démarrage à froid. Les temps de réponse sont constants et le déploiement via push GitHub fonctionne de manière fiable.

Conclusion

Railway nous a convaincus. La migration n’était pas triviale, mais l’effort en valait la peine. Quiconque connaît les pièges décrits peut migrer ses services Spring Boot en quelques heures. Points clés à retenir :

  • Faire tourner les secrets : après la migration, renouveler le mot de passe DB et le JWT secret
  • Supprimer DATABASE_URL : Spring Boot a besoin de jdbc://, pas de postgresql://
  • Remplacer le POM parent : passer chaque service à spring-boot-starter-parent
  • Utiliser les domaines privés : la communication interne via .railway.internal est gratuite
  • Réactiver Eureka : ne pas oublier eureka.client.enabled=true si la découverte de services est nécessaire
De Heroku à Railway : Migrer des microservices Spring Boot