Intro

I run several backend services built on different frameworks and languages, One of them being Spring Boot and Spring Reactor on my own Linux server. Recently, I asked myself: why not host these services on Google Cloud Run? Cloud Run offers a free tier for the first 2 million requests per month, but if I leave an instance running (with a minimum instance count), it costs around ₹200–₹500 monthly—something I’ve paid before. The problem is cold-start latency: a traditional Spring Boot/Reactor application can take over 15 seconds to start. My users will never wait that long. Once a Cloud Run instance starts, it stays alive for about 15 minutes of inactivity before shutting down. We can’t force users to endure a 15-second delay on first request.

TL;DR — A Spring WebFlux app compiled with GraalVM native-image: • cold-starts in ≈ 100 ms
• uses ≈ 90 MB RSS
• when deployed on Cloud Run behind Firebase Hosting, serves up to 2 million requests/month for free.

Why Care About Cold-Starts?

“If my Spring service gets only 10 000 requests a month, do I really need to pay for a min-instance on Cloud Run?”

Answer: No. Even if each cold-start lasts 5 s, that’s only 125 vCPU-s and 250 GiB-s, which is well below the free monthly quotas of 180 k vCPU-s and 360 k GiB-s. But latency matters for user-facing APIs: a 3–5 s delay is acceptable for CRON jobs, not for UI interactions. Enter GraalVM Native Image.

Spring Boot 3 Native (the Easy Path)

With Spring Boot 3.4.x and the GraalVM build tools plugin, creating a native image is nearly zero-effort:

# build.gradle
plugins {
    id("org.springframework.boot") version "3.4.1"
    id("org.graalvm.buildtools.native") version "0.10.2"
}

# build and deploy
./gradlew clean bootBuildImage -PBP_NATIVE_IMAGE=true \
    --image-name=gcr.io/$PROJECT/webflux-native

gcloud run deploy webflux-native \
    --image gcr.io/$PROJECT/webflux-native \
    --region us-central1 \
    --platform managed \
    --memory 512Mi \
    --min-instances 0 \
    --allow-unauthenticated

• Cold-start: 40–120 ms (0.25 vCPU)
• Memory on Cloud Run: 70–90 MB
• Code changes: none—your usual @RestController, Mono, Flux, etc.

The Spring Boot plugin runs the Ahead-Of-Time (AOT) engine, generates reflection hints automatically, and hands them to GraalVM. No manual JSON is required.


Quarkus vs Micronaut vs Spring Boot Native

FrameworkNative Image SizeCold-start (0.25 vCPU)DI ModelReactive APIMigration Effort
Spring Boot 3 Native70–85 MB40–120 msSpring DIReactor (Mono/Flux)★☆☆☆☆
Quarkus 345–60 MB25–80 msCDIMutiny / Vert.x★★★★☆
Micronaut 445–55 MB20–60 msCompile-time DIReactor, RxJava★★★☆☆

Since my code already uses WebFlux, Spring Boot Native was a five-minute flip. The others are powerful but require annotation and API rewrites.


Cost Breakdown

• Requests: first 2 million/month free (then \$0.40/M)
• CPU: first 180 000 vCPU-s/month free
• Memory: first 360 000 GiB-s/month free
• Idle min-instance: \$0.0000025/vCPU-s + \$0.0000025/GiB-s
• Egress: first 1 GB free

Example:
10 000 requests × 300 ms active ≈ 3 000 s total CPU time → ~63 000 free units → cost = \$0.00.

Keeping one min-instance warm (0.25 vCPU / 512 MiB) costs ≈\$3.50/month—still trivial compared to user-facing latency improvements.


Custom Domain Binding

I bound my service to a custom domain in Cloud Run—a new feature available in the us-central1 region. DNS setup was straightforward, and Cloud Run automatically provisions an SSL certificate.


Using GraalVM for My Existing App

I already have a mature Spring WebFlux application, so the GraalVM native-image path is the simplest. I chose GraalVM Community Edition 21:

  1. Install GraalVM JDK 21 and the native-image component (or rely on Paketo buildpacks).
  2. Add the org.graalvm.buildtools.native plugin to build.gradle.
  3. Run:

    ./gradlew clean bootBuildImage \
     -Pprofile=cloudrun \
     --no-daemon
    
  4. Deploy with:

    gcloud run deploy my-service \
     --image gcr.io/$PROJECT/my-service:latest \
     --region us-central1 \
     --allow-unauthenticated \
     --min-instances=0
    

Hiccup: Runtime Hints for Reflection

GraalVM performs aggressive tree-shaking and needs explicit hints for any reflection usage. In my case, I use jjwt for JWT handling. Without hints, the native binary would fail at runtime, although development mode wouldn’t show errors. I provided a small registrar:

class JjwtRuntimeHints implements RuntimeHintsRegistrar {

    private static final Class<?>[] JJWT_TYPES = {
            DefaultJwtParser.class,
            DefaultClaimsBuilder.class,
            StandardSecureDigestAlgorithms.class,
            StandardKeyOperations.class,
            SignatureAlgorithm.class,
            StandardEncryptionAlgorithms.class,
            StandardKeyAlgorithms.class,
            StandardCompressionAlgorithms.class,
            KeysBridge.class
    };

    @Override
    public void registerHints(RuntimeHints hints, ClassLoader cl) {
        for (Class<?> type : JJWT_TYPES) {
            hints.reflection().registerType(
                    type,
                    MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
                    MemberCategory.INVOKE_PUBLIC_METHODS,
                    MemberCategory.DECLARED_FIELDS
            );
        }
    }
}

Just enough to satisfy io.jsonwebtoken internals—they get scanned at native-image build time.


Final Artifact Sizes and Cold-Start

• Docker image: 109 MB raw
• Compressed in registry: 76 MB
• Cold-start observed: 0.603 ms (including initial PostgreSQL connection to a remote host)



CI/CD: GitHub Actions Workflow

I automated build & deploy with a GitHub workflow on every push to main branch, it will also work on pull request.

name: Build and Deploy Backend to Cloud Run

on:
  push:
    branches: [ main ]
    paths:
      - 'backend/**'
      - '.github/workflows/backend.yml'

      ....

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}

      - name: Activate Service Account
        run: gcloud auth activate-service-account \
               --key-file=${{ secrets.GCP_SA_KEY }}

      ....

      - name: Set up GraalVM 21
        uses: graalvm/setup-graalvm@v1
        with:
          java-version: '21'
          distribution: 'graalvm-community'

      ....

      - name: Build Native Image Locally
        working-directory: backend/
        run: |
          ./gradlew clean bootBuildImage \
            -Pspring.profiles.active=cloudrun \
            --imageName "${{ steps.image_details.outputs.path }}:${{ steps.image_details.outputs.version }}"

      - name: Push Versioned Image
        run: docker push "${{ steps.image_details.outputs.path }}:${{ steps.image_details.outputs.version }}"

      - name: Tag & Push Latest
        run: |
          docker tag "${{ steps.image_details.outputs.path }}:${{ steps.image_details.outputs.version }}" \
                     "${{ steps.image_details.outputs.path }}:latest"
          docker push "${{ steps.image_details.outputs.path }}:latest"

      - name: Deploy to Cloud Run
        uses: google-github-actions/deploy-cloudrun@v2
        with:
          service: opentasks-service
          region: us-central1
          ...
          project_id: ${{ secrets.GCP_PROJECT_ID }}
          env_vars: |
            ...
            SPRING_R2DBC_URL=${{ secrets.R2DBC_URL }}
            SPRING_R2DBC_USERNAME=${{ secrets.R2DBC_USERNAME }}
          secrets: |
            ...
            SPRING_R2DBC_PASSWORD=db-pass:latest
          flags: |
            --memory=512Mi
            --min-instances=0
            --concurrency=250
            ...

Explanation of key parts:
• Triggers on pushes to main or manual dispatch.
• Authenticates with a GCP service account.
• Sets up GraalVM 21 community edition.
• Build the native image locally via bootBuildImage.
• Pushes both versioned and latest tags to Artifact Registry.
• Deploys to Cloud Run with zero minimum instances, custom environment variables, and resource flags.


Conclusion

By combining Spring Boot 3’s native-image support with GraalVM, I reduced my service’s cold-start time from 15 seconds to under 1 second, fitting in under 100 MB of memory. This lets me use Cloud Run’s free tier without sacrificing user experience.


References

  1. Spring Boot Native Reference Documentation
  2. GraalVM Native Image Guide
  3. Google Cloud Run Free Tier & Pricing

Personal update

I’m cutting down things over the past 15 days, focusing on items that I want to pursue. Thinking a lot… almost every day, making it a habit. My mind is too cluttered, and for reasons I’ll share in future posts. Future me… how is it going? Did everything go well? Did you make peace with your choice? When I was in hometown, a friend told his dad what’s going on with me since I seemed off when I visited him. I had a good conversation with him… maybe that is what I needed? He said something that made me think: explore your thoughts with friends/others—don’t keep it a secret. That’s how you get an idea/improve the idea; you will get the push you need. I feel this is true to some extent, but the other is also true. Again, future me, what do you think?

In some of my free time, I’ve been playing Ghost of Tsushima. It helps me take my mind off things. The game is beautiful; I’m taking it slow. In more ways, it’s helping me relax and gives me a boost when I want to focus. I’m keeping it under control, though, not treating it like a time sink. When I was younger, I remember I used to lock in and play for days. Heck—the last game I got addicted to was Division 2. I used to plan and play raids with people, but I eventually lost touch with most of the guild members. Thinking back, my first game guild was from a game called Elsword. As everyone was getting busy with life and not logging into the game, we planned to disband the guild and had a last-day party with everyone. I have a video of that on my old hard drive. I was back from class and late to the party, and “Pandemonium,” the guild master, was waiting on me. We had a blast, and it was a nice sunset. Hopefully I can recover it and post it here… I had fond memories of that guild. Coming back to Division 2, I constructed a huge-ass essay on how it’s affecting my life, posted it to the guild and Discord servers, and left the game.