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
Framework | Native Image Size | Cold-start (0.25 vCPU) | DI Model | Reactive API | Migration Effort |
---|---|---|---|---|---|
Spring Boot 3 Native | 70–85 MB | 40–120 ms | Spring DI | Reactor (Mono/Flux) | ★☆☆☆☆ |
Quarkus 3 | 45–60 MB | 25–80 ms | CDI | Mutiny / Vert.x | ★★★★☆ |
Micronaut 4 | 45–55 MB | 20–60 ms | Compile-time DI | Reactor, 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:
- Install GraalVM JDK 21 and the
native-image
component (or rely on Paketo buildpacks). - Add the
org.graalvm.buildtools.native
plugin tobuild.gradle
. Run:
./gradlew clean bootBuildImage \ -Pprofile=cloudrun \ --no-daemon
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
- Spring Boot Native Reference Documentation
- GraalVM Native Image Guide
- 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.