GraalVM Native Image for Spring Boot / Quarkus: Step-by-step production example
If you’ve heard that Java can start in milliseconds and sip memory like an espresso shot—yep, that’s GraalVM Native Image. It compiles your app ahead-of-time into a tiny, self-contained binary that’s perfect for microservices, serverless, and CLI tools. Below are pragmatic, production-ready walkthroughs for both Spring Boot and Quarkus, sprinkled with gotchas, DX tips, and ops checklists.
What you’ll need (and a few realities)
- A recent JDK (Java 21+ is ideal), Docker/Podman for containerized builds, and enough horsepower: native builds are heavier than JVM jars. Quarkus recommends at least 4 CPUs and 4GB RAM when building in containers.
- You can build with framework tooling (Spring Boot AOT/native) or directly with the GraalVM Native Build Tools. Frameworks layer smart defaults on top of GraalVM to smooth over reflection, resources, and proxies.
Path A: Spring Boot → Native image (production example)
We’ll package a simple HTTP service with Actuator, ready for containers and K8s.
1) Generate the project
Start from Spring Initializr with Spring Web, Actuator, and (optionally) Spring Data JPA if you’re hitting a database. Spring Boot includes native support with AOT processing and build plugins.
2) Add a tiny endpoint
@RestController
class HelloController {
@GetMapping("/hello")
String hello() { return "Hi from native 👋"; }
}
3) Build a native container image (recommended for prod)
Boot’s plugin integrates with Buildpacks so you don’t have to handcraft a Dockerfile:
./mvnw -Pnative spring-boot:build-image # or Gradle: ./gradlew bootBuildImage --imageName=ghcr.io/acme/hello-native -Pnative
3) Build a native container image (recommended for prod)
Boot’s plugin integrates with Buildpacks so you don’t have to handcraft a Dockerfile:
./mvnw -Pnative spring-boot:build-image # or Gradle: ./gradlew bootBuildImage --imageName=ghcr.io/acme/hello-native -Pnative
This produces a Linux container that already contains a GraalVM-compiled binary. It’s the simplest way to get a production image that works consistently across environments.
4) Or: build a local native binary
./mvnw -Pnative -DskipTests native:compile # binary ends up in target/ (e.g., target/your-app)
Use this when you want to craft your own Dockerfile or run bare-metal.
5) Minimal Dockerfile (if you built the binary yourself)
FROM scratch COPY target/your-app /app EXPOSE 8080 ENTRYPOINT ["/app"]
docker run --rm -p 8080:8080 ghcr.io/acme/hello-native curl :8080/hello
7) Health, metrics, readiness
Because you added Actuator, expose only the endpoints you need and wire liveness/readiness for K8s. The native binary doesn’t change your Actuator semantics—just your startup time.
Debug tip: If you hit reflection/resource errors during native build, add Spring Native hints or supply metadata—see the Reachability Metadata primer and Spring’s native docs.
Path B: Quarkus → Native image (production example)
Quarkus leans hard into GraalVM: fast dev mode, thoughtful defaults, and great native ergonomics.
1) Create & code
Use the Quarkus CLI or Maven to scaffold with REST:
quarkus create app com.acme:native-demo --extension='rest,container-image-docker' cd native-demo
Add a resource:
@Path("/hello")
public class HelloResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() { return "Hello from Quarkus native"; }
}
2) Build a native executable (local)
./mvnw package -DskipTests -Dnative # runner at target/*-runner
3) Build in a container (consistent & portable)
./mvnw package -DskipTests -Dnative -Dquarkus.native.container-build=true
To pin the builder, add in application.properties:
quarkus.native.container-build=true quarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:jdk-21 quarkus.container-image.build=true quarkus.container-image.group=ghcr.io/acme
This uses the Mandrel/GraalVM builder inside UBI and produces a container image you can push to your registry. Quarkus recommends provisioning ≥4 CPUs / 4GB RAM to keep native builds reliable. Quarkus
4) Run the image
docker run --rm -p 8080:8080 ghcr.io/acme/native-demo:1.0.0 curl :8080/hello
5) Production flags you’ll actually use
-Dquarkus.native.additional-build-args=--native-image-infoto surface build details.-Dquarkus.native.native-image-xmx=5gif build memory is tight (slower but steadier).- Quarkus’ Native Reference Guide has a goldmine of troubleshooting advice for OOMs, link-at-build-time issues, and GC/runtime trade-offs.
CI/CD: fast, hermetic pipelines
Why containers for builds? Consistency. Both frameworks can produce native images entirely inside Docker/Podman, which means no GraalVM installation on runners.
GitHub Actions sketch (Spring Boot, buildpacks):
name: native-image
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Build native container
run: ./mvnw -Pnative spring-boot:build-image -DskipTests
- name: Push
run: docker tag docker.io/library/your-app:latest ghcr.io/acme/your-app:$(git rev-parse --short HEAD) &&
echo "$CR_PAT" | docker login ghcr.io -u USERNAME --password-stdin &&
docker push ghcr.io/acme/your-app:$(git rev-parse --short HEAD)
GitHub Actions sketch (Quarkus, container build):
- name: Build native in container run: ./mvnw package -DskipTests -Dnative -Dquarkus.native.container-build=true
Observability & ops notes
- Images are tiny and start fast, but JVM mode may still win on some long-running throughput benchmarks due to JIT optimizations. Pick native for cold-start/scale-to-zero and tight resource envelopes; use JVM for hot, CPU-bound workloads when peak throughput wins.
- Actuator (Boot) and SmallRye/Micrometer (Quarkus) still work—just ensure any exporters you use (e.g., Prometheus) are included at build time.
- Memory tuning differs from the JVM. Review Quarkus’ native memory guidance and GraalVM docs before setting hard limits in containers.
Troubleshooting checklist (works for both)
- Reflection & resources: Supply Reachability Metadata or use framework hints. The GraalVM build tools can also auto-collect metadata with the tracing agent.
- Third-party libs: Prefer libraries with native support; otherwise add
reflect-config.json/resource entries or code-level hints. - Build OOMs/timeouts: Reduce parallelism, set a build Xmx (Quarkus:
-Dquarkus.native.native-image-xmx=...), or scale your CI runner. - Different base images: If you need musl/static executables for Alpine or scratch, you’ll pass extra native-image flags; validate with your distro first. (Quarkus and GraalVM docs outline libc choices and constraints.)
Copy-paste “first deploy” recipes
Spring Boot (Buildpacks):
./mvnw -Pnative spring-boot:build-image \ -Dspring-boot.build-image.imageName=ghcr.io/acme/hello-native:$(git rev-parse --short HEAD) docker run --rm -p 8080:8080 ghcr.io/acme/hello-native:$(git rev-parse --short HEAD)
Quarkus (Container native build):
./mvnw package -DskipTests -Dnative -Dquarkus.native.container-build=true docker run --rm -p 8080:8080 ghcr.io/acme/native-demo:1.0.0
Useful links
- Spring Boot: Native Image – official reference, buildpacks &
-Pnativeprofile. - Quarkus: Building Native Executables – CLI, Maven/Gradle, container build switches.
- Quarkus: Native Reference Guide – requirements, tuning, troubleshooting (a must-read).
- GraalVM Native Image – concepts, build tools, and options.
- GraalVM Reachability Metadata – reflection/resources/proxies and the tracing agent.
Final nudge
Start with containerized native builds (they’re reproducible), bake in health checks early, and keep an eye on memory during builds. Once you see a cold-start in milliseconds for real, it’s hard to go back.




