From Heroku to Railway: Migrating Spring Boot Microservices

From Heroku to Railway: Migrating Spring Boot Microservices
by Zelkulon15 March 20261 min read

Step by step from Heroku to Railway – including all the pitfalls we encountered along the way. From the pom.xml to Eureka Service Discovery.

Starting point: Why we left Heroku

Heroku served us well for years. Fast deployments, simple configuration – perfect for getting started. But as the number of services grew, costs became a problem. The worst part was the spin-up delay: Basic Dynos sleep after 30 minutes of inactivity and need up to 30 seconds to wake up on the next request. That's unacceptable for a professional application.

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

After a brief evaluation, we chose Railway. The Pro plan at $20/month including $20 in credits sounds more expensive than a single Dyno at first – but Railway never sleeps. And when you're running multiple services, the math adds up quickly.

Railway setup in 5 minutes

Getting started with Railway is straightforward. After signing up, choose the Pro plan and create a new project. Create a separate Railway service for each backend service.

  • Create a Railway account at railway.app
  • Choose the Pro plan ($20/month including $20 credits)
  • Create a new project and add PostgreSQL as a service
  • Add each microservice as its own Railway service and connect to GitHub

The killer feature: Railway automatically deploys on every push to the configured branch. No manual deployments, no CI/CD setup required.

Railway Projekt
─────────────────────────────────────────────────────
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ auth-serviceβ”‚  β”‚blog-service β”‚  β”‚  PostgreSQL β”‚
  β”‚  (GitHub ↻) β”‚  β”‚  (GitHub ↻) β”‚  β”‚  (managed)  β”‚
  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                   Private Network
              (.railway.internal - kostenlos)

Configuring environment variables

Railway uses a special double-curly-brace syntax for service-to-service variable references. This lets you use values from one service directly as variables in another – no manual copying of credentials. Some services need both JDBC_DATABASE_URL and SPRING_DATASOURCE_URL – both can point to the same Railway reference.

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

The most common mistake in a first Railway migration: Spring Boot cannot read the standard JDBC URL format from a DATABASE_URL variable when it's in postgresql:// format. Spring strictly requires the JDBC format – whether via JDBC_DATABASE_URL or 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: Solving the Parent POM problem

If your microservices are organized in a monorepo, you'll hit an unexpected error on your first Railway build: Railway builds each service in a separate context – the parent POM from the parent directory is not available. The solution is simple: give each service its own 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>

If Spring Cloud dependencies are involved, you also need to add a dependencyManagement block to the pom.xml – since the parent POM no longer provides it.

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

More common errors and their fixes

Beyond the parent POM, there are other errors that came up during migration. Here are the three most important ones:

⚠ Dockerfile: wrong JAR path

Problem: The Dockerfile references a subdirectory in the build context that no longer exists in a single-service build.

Fix: Use the path without subdirectory – the service is now directly at the 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 is incompatible with Spring Boot 3.4.x and throws NoClassDefFoundError: HttpRedirects.

Fix: Either downgrade to Spring Boot 3.3.8 + Spring Cloud 2023.0.5, or switch to the reactive spring-cloud-starter-gateway.

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

⚠ Wrong Eureka private domain

Problem: Railway private domains do NOT have a "-production" suffix – eurekaservice-production.railway.internal does not work.

Fix: Use the private domain without suffix: 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 with Eureka on Railway

If you carried Eureka over from the Heroku era, you probably have eureka.client.enabled=false in your application-prod.yml. On Railway, service discovery should be active again – this must be explicitly enabled.

# 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

On Railway, all services communicate over the private network. Eureka runs on a Railway service and is reachable via its private domain. Important: the domain always follows the pattern servicename.railway.internal – without any 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 and private networking

Railway distinguishes between two domain types you need to know about:

  • Public domain: servicename-production.up.railway.app – HTTPS, externally accessible, for API calls from the frontend
  • Private domain: servicename.railway.internal – internal only, free (no egress traffic)
  • Variable references: Railway double-curly-brace syntax for service-to-service configuration
  • Port configuration: Settings β†’ Networking β†’ Generate Domain β†’ enter 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}}"

Cost comparison

After one week in production, here's the picture:

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

The biggest advantage isn't just price: Railway services run continuously – no sleeping, no spin-up delay. Response times are consistent and deployment via GitHub push works reliably.

Conclusion

Railway has won us over. The migration wasn't trivial, but the effort was worth it. Anyone who knows the pitfalls described here can migrate their Spring Boot services in a matter of hours. Key takeaways:

  • Rotate secrets: After migration, renew DB password and JWT secret
  • Remove DATABASE_URL: Spring Boot needs jdbc://, not postgresql://
  • Replace parent POM: Switch each service to spring-boot-starter-parent
  • Use private domains: Internal communication over .railway.internal is free
  • Re-enable Eureka: Don't forget eureka.client.enabled=true if service discovery is needed
From Heroku to Railway: Migrating Spring Boot Microservices