Von Heroku zu Railway: Migration von Spring Boot Microservices

Von Heroku zu Railway: Migration von Spring Boot Microservices
von Zelkulon15. März 20261 min Lesezeit

Schritt für Schritt von Heroku zu Railway migrieren – mit allen Fallstricken, die uns auf dem Weg begegnet sind. Von der pom.xml bis zur Eureka Service Discovery.

Ausgangssituation: Warum wir Heroku verlassen haben

Heroku hat uns jahrelang gute Dienste geleistet. Schnelles Deployment, einfache Konfiguration – perfekt für den Start. Doch mit wachsender Anzahl an Services wurden die Kosten zum Problem. Das Schlimmste war der Spin-up Delay: Basic Dynos schlafen nach 30 Minuten Inaktivität ein und benötigen beim nächsten Request bis zu 30 Sekunden zum Aufwachen. Für eine professionelle Anwendung ist das inakzeptabel.

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

Nach einer kurzen Evaluierung fiel die Wahl auf Railway. Der Pro Plan mit $20/Monat inkl. $20 Credits klingt zunächst teurer als ein einzelner Dyno – aber Railway schläft nicht. Und wenn man mehrere Services betreibt, rechnet sich das schnell.

Railway in 5 Minuten einrichten

Der Einstieg in Railway ist unkompliziert. Nach der Registrierung wählt man den Pro Plan und legt ein neues Projekt an. Für jeden Backend-Service wird ein eigener Railway-Service erstellt.

  • Railway Account erstellen unter railway.app
  • Pro Plan wählen ($20/Monat inkl. $20 Credits)
  • Neues Projekt anlegen und PostgreSQL als Service hinzufügen
  • Jeden Microservice als eigenen Service anlegen und mit GitHub verbinden

Das Killer-Feature: Bei jedem Push auf den konfigurierten Branch deployed Railway automatisch. Kein manuelles Deployment, kein CI/CD Setup nötig.

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

Umgebungsvariablen konfigurieren

Railway verwendet für Service-zu-Service Referenzen eine eigene Syntax mit doppelten geschweiften Klammern. Das ermöglicht es, Werte eines Services direkt als Variable in einem anderen zu verwenden – ohne Credentials manuell zu kopieren. Manche Services benötigen sowohl JDBC_DATABASE_URL als auch SPRING_DATASOURCE_URL – beide können auf dieselbe Railway-Referenz zeigen.

# ── 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"

Der häufigste Fehler bei der ersten Railway-Migration: Spring Boot kann das Standard-JDBC-URL-Format nicht aus einer DATABASE_URL Variable lesen, wenn diese im postgresql://-Format vorliegt. Spring erwartet zwingend das JDBC-Format – egal ob über JDBC_DATABASE_URL oder 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: Das Parent-POM Problem lösen

Wer seine Microservices in einem Monorepo organisiert hat, stößt beim ersten Railway-Build auf einen unerwarteten Fehler: Railway baut jeden Service in einem separaten Kontext – das Parent-POM aus dem übergeordneten Verzeichnis ist nicht verfügbar. Die Lösung ist einfach: Jeder Service bekommt seinen eigenen 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>

Falls Spring Cloud Dependencies im Spiel sind, muss zusätzlich ein dependencyManagement-Block in die pom.xml – da das Parent-POM diesen nicht mehr mitbringt.

<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>

Weitere häufige Fehler und ihre Fixes

Neben dem Parent-POM gibt es weitere Fehler, die bei der Migration aufgetaucht sind. Hier die wichtigsten drei:

⚠ Dockerfile: JAR-Pfad falsch

Problem: Das Dockerfile verweist auf einen Unterordner im Build-Kontext, der beim Einzelservice-Build nicht mehr existiert.

Fix: Pfad ohne Unterordner verwenden – der Service liegt jetzt direkt im Build-Root.

# 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 ist inkompatibel mit Spring Boot 3.4.x und wirft NoClassDefFoundError: HttpRedirects.

Fix: Entweder auf Spring Boot 3.3.8 + Spring Cloud 2023.0.5 downgraden, oder auf das reaktive spring-cloud-starter-gateway wechseln.

<!-- 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>

⚠ Eureka Private Domain falsch

Problem: Railway Private Domains haben KEIN "-production" Suffix – eurekaservice-production.railway.internal funktioniert nicht.

Fix: Private Domain ohne Suffix verwenden: 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

Service Discovery mit Eureka auf Railway

Wer Eureka aus einer Heroku-Ära mit sich trägt, hat wahrscheinlich eureka.client.enabled=false in seiner application-prod.yml. Auf Railway soll Service Discovery wieder aktiv sein – das muss explizit aktiviert werden.

# 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

Auf Railway kommunizieren alle Services über das private Netzwerk. Eureka läuft auf einem Railway-Service und ist unter seiner Private Domain erreichbar. Wichtig: Die Domain folgt immer dem Muster servicename.railway.internal – ohne Environment-Suffix.

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)

Domains und privates Netzwerk

Railway unterscheidet zwischen zwei Domain-Typen, die man kennen muss:

  • Public Domain: servicename-production.up.railway.app – HTTPS, extern erreichbar, für API-Calls vom Frontend
  • Private Domain: servicename.railway.internal – nur intern, kostenlos (kein Egress-Traffic)
  • Variable References: Railway-Syntax mit doppelten geschweiften Klammern für Service-zu-Service Konfiguration
  • Port-Konfiguration: Settings → Networking → Generate Domain → Port 8080 eingeben
# 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}}"

Kosten-Vergleich

Nach einer Woche im produktiven Betrieb zeichnet sich folgendes Bild ab:

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

Der größte Vorteil ist dabei nicht nur der Preis: Railway-Services laufen kontinuierlich – kein Einschlafen, kein Spin-up Delay. Die Antwortzeiten sind konstant und das Deployment via GitHub Push funktioniert zuverlässig.

Fazit

Railway hat uns überzeugt. Die Migration war nicht trivial, aber der Aufwand hat sich gelohnt. Wer die genannten Fallstricke kennt, kann seine Spring Boot Services in wenigen Stunden migrieren. Wichtigste Hinweise zum Abschluss:

  • Secrets rotieren: Nach der Migration DB-Passwort und JWT-Secret erneuern
  • DATABASE_URL entfernen: Spring Boot braucht jdbc://, nicht postgresql://
  • Parent-POM ersetzen: Jeden Service auf spring-boot-starter-parent umstellen
  • Private Domains nutzen: Interne Kommunikation über .railway.internal ist kostenlos
  • Eureka reaktivieren: eureka.client.enabled=true nicht vergessen, wenn Service Discovery benötigt wird
Von Heroku zu Railway: Migration von Spring Boot Microservices