Deploying Spring Boot to Railway/Fly.io with Docker & GitHub Actions
Deploy a Spring Boot application with Docker — multi-stage builds, Railway and Fly.io deployment, GitHub Actions CI/CD pipeline, and production configuration.
You have a working Spring Boot API. Now it needs to run somewhere other than your laptop. This post covers the full deployment pipeline: Docker image, CI/CD with GitHub Actions, and deployment to Railway or Fly.io.
Dockerfile — Multi-stage build
# Stage 1: Build
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY gradle gradle
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY gradle/libs.versions.toml gradle/
RUN ./gradlew dependencies --no-daemon
COPY src src
RUN ./gradlew bootJar --no-daemon -x test
# Stage 2: Run
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Why multi-stage?
- Stage 1 has the full JDK and Gradle — large (800MB+)
- Stage 2 has only the JRE and your jar — small (~200MB)
- The final image doesn’t contain source code, build tools, or dependencies
Security: Non-root user
The adduser/USER appuser lines ensure the application doesn’t run as root inside the container. If the app is compromised, the attacker has limited privileges.
Build and test locally
# Build the image
docker build -t myapp:latest .
# Run it
docker run -p 8080:8080 \
-e DATABASE_URL=jdbc:postgresql://host.docker.internal:5432/mydb \
-e DATABASE_USER=admin \
-e DATABASE_PASSWORD=secret \
myapp:latest
Docker Compose for local development
# docker-compose.yml
services:
app:
build: .
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=dev
- DATABASE_URL=jdbc:postgresql://db:5432/mydb
- DATABASE_USER=admin
- DATABASE_PASSWORD=secret
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_DB: mydb
POSTGRES_USER: admin
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d mydb"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
docker compose up
Deploy to Railway
Railway detects Dockerfiles automatically.
Setup
- Push your code to GitHub
- Sign up at railway.app and connect your GitHub repo
- Railway detects the Dockerfile and builds automatically
Add a database
- In your Railway project, click “New” → “Database” → “PostgreSQL”
- Railway provides a
DATABASE_URLenvironment variable automatically - Reference it in your Spring config:
# application-prod.yml
spring:
datasource:
url: ${DATABASE_URL}
Environment variables
In Railway’s dashboard, add:
SPRING_PROFILES_ACTIVE=prodJWT_SECRET=your-production-secret- Any other secrets
Custom start command (optional)
java -Xmx256m -jar app.jar
Railway’s free tier has memory limits. Set -Xmx appropriately.
Deploy to Fly.io
Fly.io uses a fly.toml configuration file.
Setup
# Install flyctl
brew install flyctl
# Login
fly auth login
# Create app (in your project directory)
fly launch
fly.toml
app = "my-spring-boot-app"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[env]
SPRING_PROFILES_ACTIVE = "prod"
PORT = "8080"
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
[[vm]]
memory = "512mb"
cpu_kind = "shared"
cpus = 1
Add PostgreSQL
fly postgres create --name my-app-db
fly postgres attach my-app-db
Fly.io sets DATABASE_URL automatically.
Set secrets
fly secrets set JWT_SECRET=your-production-secret
fly secrets set DATABASE_PASSWORD=your-db-password
Deploy
fly deploy
GitHub Actions CI/CD
Build and test on every push
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build and test
env:
DATABASE_URL: jdbc:postgresql://localhost:5432/testdb
DATABASE_USER: test
DATABASE_PASSWORD: test
run: ./gradlew build
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/build/reports/tests/'
Deploy on tag
# .github/workflows/deploy.yml
name: Deploy
on:
push:
tags:
- 'v*'
jobs:
deploy-railway:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Railway
uses: bervProject/railway-deploy@main
with:
railway_token: ${{ secrets.RAILWAY_TOKEN }}
service: my-service
# OR deploy to Fly.io
deploy-fly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy to Fly.io
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
run: flyctl deploy --remote-only
Full pipeline: Build → Test → Deploy
name: CI/CD
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- uses: gradle/actions/setup-gradle@v4
- name: Test
env:
DATABASE_URL: jdbc:postgresql://localhost:5432/testdb
DATABASE_USER: test
DATABASE_PASSWORD: test
run: ./gradlew test
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- name: Deploy
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
run: flyctl deploy --remote-only
Tests must pass before deploy runs (needs: test).
Production configuration
application-prod.yml
server:
port: ${PORT:8080}
shutdown: graceful
spring:
datasource:
url: ${DATABASE_URL}
hikari:
maximum-pool-size: 5
minimum-idle: 2
connection-timeout: 10000
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
flyway:
enabled: true
lifecycle:
timeout-per-shutdown-phase: 30s
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: never
logging:
level:
root: WARN
com.example: INFO
Key production settings:
shutdown: graceful— finish in-flight requests before stoppingopen-in-view: false— prevents lazy loading issues in controllersddl-auto: validate— Flyway handles migrations, Hibernate only validates- Health endpoint exposed but details hidden
JVM flags for containers
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+UseZGC", \
"-jar", "app.jar"]
UseContainerSupport— JVM respects container memory limitsMaxRAMPercentage=75.0— use 75% of container memory for heapUseZGC— low-latency garbage collector
Health checks
@Component
class DatabaseHealthIndicator(
private val dataSource: DataSource
) : HealthIndicator {
override fun health(): Health {
return try {
dataSource.connection.use { conn ->
conn.createStatement().execute("SELECT 1")
}
Health.up().build()
} catch (e: Exception) {
Health.down(e).build()
}
}
}
Railway and Fly.io use /actuator/health to check if your app is running. If it returns non-200, the platform restarts the container.
Summary
Deployment pipeline:
- Dockerfile — Multi-stage build for small, secure images
- Docker Compose — Local development with database
- Railway/Fly.io — Deploy with Git push or CLI
- GitHub Actions — Automated build → test → deploy
- Production config — Graceful shutdown, connection pools, health checks
- JVM tuning — Container-aware memory settings
Push to main → tests run → deploy to production. No manual steps, no SSH, no FTP. Infrastructure as code.