PCSalt
YouTube GitHub
Back to Spring Boot
Spring Boot · 2 min read

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

  1. Push your code to GitHub
  2. Sign up at railway.app and connect your GitHub repo
  3. Railway detects the Dockerfile and builds automatically

Add a database

  1. In your Railway project, click “New” → “Database” → “PostgreSQL”
  2. Railway provides a DATABASE_URL environment variable automatically
  3. Reference it in your Spring config:
# application-prod.yml
spring:
  datasource:
    url: ${DATABASE_URL}

Environment variables

In Railway’s dashboard, add:

  • SPRING_PROFILES_ACTIVE=prod
  • JWT_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 stopping
  • open-in-view: false — prevents lazy loading issues in controllers
  • ddl-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 limits
  • MaxRAMPercentage=75.0 — use 75% of container memory for heap
  • UseZGC — 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:

  1. Dockerfile — Multi-stage build for small, secure images
  2. Docker Compose — Local development with database
  3. Railway/Fly.io — Deploy with Git push or CLI
  4. GitHub Actions — Automated build → test → deploy
  5. Production config — Graceful shutdown, connection pools, health checks
  6. JVM tuning — Container-aware memory settings

Push to main → tests run → deploy to production. No manual steps, no SSH, no FTP. Infrastructure as code.