
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/eurekaService 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: trueOn 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