GraalVM Native Image with Docker

GraalVM Native Image is a tool that takes your Java (or other JVM-based) application and compiles it ahead of time (AOT) into a standalone, platform-specific executable.

Instead of running on the JVM with JIT compilation at runtime, it produces a binary that:

  • Starts instantly (milliseconds instead of seconds)
  • Uses less memory
  • Doesn’t require a JDK installed on the target machine

In this post, I will cover using GraalVM Native Image with Docker.

GraalVM Native Image Plugins

Before using GraalVM Native Image, you need add the GraalVM Native Image plugin to your project.

Using Gradle

Add the GraalVM Native Image Plugin to build.gradle.kts.

build.gradle.kts

...
plugins {
...
// Gradle plugin for GraalVM Native Image
id("org.graalvm.buildtools.native") version "0.11.0"
...
}
...

Note: Don’t add the following configuration in build.gradle.kts if you want to build Spring Boot Application to a GraalVM Native Image:

tasks.jar { enabled = false }

Build and run native image on your host

# Build a GraalVM Native Image
$ ./gradlew nativeCompile -Dorg.gradle.configuration-cache=false
# Execute
$ ./build/native/nativeCompile/myapp

Spring Boot Application with GraalVM Native Image

1. Initialize a Spring Boot web application in Spring Initializr

2. Add Dockerfile

Dockerfile

FROM ghcr.io/graalvm/native-image-community:21-ol9 AS builder
# Install findutils to fix "xargs is not available" error when running ./gradlew commands
RUN microdnf install findutils
ARG WORKDIR=/app
WORKDIR /app
COPY gradle ./gradle
COPY settings.gradle.kts build.gradle.kts gradle.properties gradlew ./
RUN --mount=type=cache,target=${WORKDIR}/.gradle \
--mount=type=cache,target=/root/.gradle \
./gradlew help
COPY . .
RUN --mount=type=cache,target=${WORKDIR}/.gradle \
--mount=type=cache,target=/root/.gradle \
./gradlew -x test nativeCompile -Dorg.gradle.configuration-cache=false

FROM alpine:3 AS runtime
# Install the `gcompat` package in your Alpine Dockerfile to provide a compatibility layer for `glibc` binaries.
RUN apk add --no-cache gcompat
# Copy the native executable from the builder stage
COPY --from=builder /app/build/native/nativeCompile/a-simple-web-application /app/a-simple-web-application
# Set the timezone to Shanghai
ENV TZ=Asia/Shanghai
# Expose the port the app runs on
EXPOSE 8080
# Set the entrypoint to the native executable
ENTRYPOINT ["/app/a-simple-web-application"]
  • native-image-community:21-ol9
    • native-image-community: This is the GraalVM Community Edition container image that includes the Native Image tooling—used for ahead-of-time (AOT) compilation into native binaries—without bundling the full JDK runtime.
    • 21: Refers to Java (JDK) version 21.
    • ol9: Indicates that this build uses Oracle Linux 9 as its base OS. GraalVM container images are available on multiple distributions (Oracle Linux versions 7, 8, or 9), and that suffix specifies the platform.
  • RUN apk add --no-cache gcompat: GraalVM native images are typically built on systems using glibc, which is incompatible with musl libc without specific adjustments. 1. Adding gcompat: Install the gcompat package in your Alpine Dockerfile to provide a compatibility layer for glibc binaries. 2. Or Use a glibc compatible base image such as Ubuntu or Debian.

3. Build Docker Image

# Time cost: About 120s.
docker build -t myapp .

Docker Image size:

  • GraalVM native-image Docker images:
    • The image size using alpine:3 as runtime: 90MB.
    • The image size using oraclelinux:9-slim as runtime: 200MB.
    • The image size using Buildpacks to create a image (gradle bootBuildImage): 125MB.
  • Non GraalVM native-image Docker image: 230MB.

4. Running Docker container

docker run -p 8080:8080 myapp

5. Access the Spring Boot application

Visiting http://localhost:8080

6. Build and push to DockerHub

# Authenticate to the Registry
docker login

docker buildx create \
--name my-multi-platform-builder \
--driver docker-container \
--bootstrap --use

# Time cost: About 300~400 seconds (A simple Spring Boot web application)
docker build \
--builder my-multi-platform-builder \
--platform linux/amd64,linux/arm64 \
--tag IMAGE_NAME \
--push .
  • The format of IMAGE_NAME: <dockerhub_username>/<application_name>:<tag>

Docker Image size on DockerHub

  • The image compressed size on DockerHub using alpine:3 as runtime: 30MB.
  • The image compressed size on DockerHub using oraclelinux:9-slim as runtime: 75MB
  • The image compressed size on DockerHub using Buildpacks to create a image: 40MB.

A Simple echo CLI Application

1. Create a Java application with the gradle init command.

$ mkdir simple-echo-cli && cd simple-echo-cli
$ gradle --configuration-cache \
init --use-defaults \
--type java-application \
--package com.taogen.demo

2. Gradle configuration

app/build.gradle.kts

plugins {
java
application
id("org.graalvm.buildtools.native") version "0.11.0"
}

repositories {
// Use Maven Central for resolving dependencies.
mavenCentral()
}

dependencies {
// Use JUnit Jupiter for testing.
testImplementation(libs.junit.jupiter)
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

// Apply a specific Java toolchain to ease working on different environments.
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

application {
// Define the main class for the application.
mainClass = "com.taogen.demo.App"
}

tasks.named<Test>("test") {
// Use JUnit Platform for unit tests.
useJUnitPlatform()
}

tasks.withType<Jar> {
// Set a constant name for the JAR
archiveFileName.set("myapp.jar")
// Configure the JAR task to include the main class in the manifest.
// Adds metadata to the JAR manifest
manifest {
attributes["Main-Class"] = "com.taogen.demo.App" // Replace with your actual main class
}
}

3. The application

com/taogen/demo/App.java

package com.taogen.demo;

import java.util.Scanner;

public class App {
public static void main(String[] args) {
System.out.println("Echo application started. Type 'exit' to quit.");
String message = "";
while (!"exit".equals(message)) {
Scanner scanner = new Scanner(System.in);
System.out.print("Enter 'exit' to quit or enter your message: ");
message = scanner.nextLine();
System.out.println("You entered: " + message);
}
}
}

4. Build and run the application on your host

# Time cost: about 35 seconds
$ ./gradlew nativeCompile -Dorg.gradle.configuration-cache=false
$ ./app/build/native/nativeCompile/app

5. Running the application with Docker

Add Dockerfile to the root directory of the project

Dockerfile

FROM ghcr.io/graalvm/native-image-community:21-ol9 AS builder
# Install findutils to fix "xargs is not available" error when running ./gradlew commands
RUN microdnf install findutils
ARG WORKDIR=/app
WORKDIR /app
COPY gradle ./gradle
COPY settings.gradle.kts gradle.properties gradlew ./
COPY app/build.gradle.kts ./app/
RUN --mount=type=cache,target=${WORKDIR}/.gradle \
--mount=type=cache,target=/root/.gradle \
./gradlew help
COPY . .
RUN --mount=type=cache,target=${WORKDIR}/.gradle \
--mount=type=cache,target=/root/.gradle \
./gradlew -x test nativeCompile -Dorg.gradle.configuration-cache=false

FROM alpine:3 AS runtime
# Install the `gcompat` package in your Alpine Dockerfile to provide a compatibility layer for `glibc` binaries.
RUN apk add --no-cache gcompat
# Copy the native executable from the builder stage
COPY --from=builder /app/app/build/native/nativeCompile/app /app/app
# Set the timezone to Shanghai
ENV TZ=Asia/Shanghai
# Set the entrypoint to the native executable
ENTRYPOINT ["/app/app"]

Build Docker Image

# Time cost: About 40 seconds.
docker build -t simple-echo-cli .
  • The image size using oraclelinux:9-slim as runtime: 130MB.
  • The image size using alpine:3 as runtime: 22MB.

Running Docker container

docker run -it simple-echo-cli

Optimization for faster builds and smaller binaries

Add native-image.properties file to the root directory of your project

1. For dev builds

native-image.properties

Args = \
--no-fallback \
--initialize-at-build-time \
--report-unsupported-elements-at-runtime \
-H:+InlineBeforeAnalysis \
--gc=G1 \
--enable-http \
--enable-https \
--enable-url-protocols=http,https \
-H:IncludeResources=.*.properties|.*.yml|.*.yaml|.*.xml \
--verbose
Flag Effect in development
--no-fallback Avoids building a JVM fallback image, which saves a lot of time.
--initialize-at-build-time Broadly initializes classes at build time; reduces runtime CPU cost and usually speeds up builds slightly.
--report-unsupported-elements-at-runtime Lets the build succeed even if GraalVM finds features it can’t fully handle — errors happen at runtime instead of failing the build.
-H:+InlineBeforeAnalysis Improves analysis performance by inlining methods before heavy analysis — faster compilation.
--gc=G1 Faster compilation than serial for some builds; good balance for dev.
--enable-http / --enable-https Keeps network capabilities enabled for testing without extra setup.
--enable-url-protocols=http,https Ensures URL-based resource loading works without missing protocols.
-H:IncludeResources=.*.properties|.*.yml|.*.yaml|.*.xml GraalVM will include all files (from your JAR’s resources, or from dependencies) whose names match: *.properties, *.yml, *.yaml, *.xml
--verbose Prints detailed build logs to help debug class/resource inclusion.

2. For production builds

native-image.properties

Args = \
--no-fallback \
--initialize-at-build-time \
--enable-http \
--enable-https \
--enable-url-protocols=http,https \
--gc=serial \
-O2 \
-H:+ReportExceptionStackTraces \
-H:+AddAllCharsets \
-H:+StackTrace \
--strict-image-heap \
--install-exit-handlers \
-H:IncludeResources=.*.properties|.*.yml|.*.yaml|.*.xml
Flag Effect in production
--no-fallback Produces only the native binary (no embedded JVM fallback). Makes image smaller, startup faster.
--initialize-at-build-time Initializes most classes at build time, reducing startup time and runtime CPU cost.
--enable-http / --enable-https Ensures networking over HTTP/HTTPS works (disabled by default for security).
--enable-url-protocols=http,https Explicitly registers protocols — useful for libraries relying on URL.openStream().
--gc=serial Smaller memory footprint, predictable pauses; great for CLI tools or short-lived apps.
-O2 or -O3 Maximum optimization level; increases build time but can significantly improve runtime performance.
-H:+ReportExceptionStackTraces Improves debugging in production by including stack traces in error logs.
-H:+AddAllCharsets Ensures all Java character sets are included — avoids UnsupportedCharsetException.
-H:+StackTrace Keeps full stack traces for exceptions (slightly increases image size).
--strict-image-heap Validates heap during build to avoid runtime surprises; helps catch errors early.
--install-exit-handlers Ensures shutdown hooks run correctly on termination signals.
-H:IncludeResources=.*.properties|.*.yml|.*.yaml|.*.xml GraalVM will include all files (from your JAR’s resources, or from dependencies) whose names match: *.properties, *.yml, *.yaml, *.xml

Tuning tips for production:

  • If you run long-lived server apps, consider --gc=G1 instead of serial for better throughput.
  • If startup time is more critical than build size, keep --initialize-at-build-time broad.
  • If build time is becoming too long, you can reduce -O3 to -O2.

Common errors

Error 1: Executing the docker run command occurs an error “Fatal glibc error: CPU does not support x86-64-v3”

Solution:

Use oraclelinux:8-slim or oraclelinux:9-slim as the runtime base Docker image instead of oraclelinux:10-slim.

Error 2: Use alpine:3 as the base image to run GraalVM Docker image occurs an error: exec /app/xxx: no such file or directory

The error “no such file or directory” when running a GraalVM native executable in an Alpine-based Docker image often indicates a compatibility issue between the executable and Alpine’s musl libc library. GraalVM native images are typically built on systems using glibc, which is incompatible with musl libc without specific adjustments.

Solution 1:

Adding gcompat: Install the gcompat package in your Alpine Dockerfile to provide a compatibility layer for glibc binaries.

FROM alpine:3
# Install the `gcompat` package in your Alpine Dockerfile to provide a compatibility layer for `glibc` binaries.
RUN apk add --no-cache gcompat
# ...

Solution 2:

Use a glibc compatible base image: The most straightforward solution is to switch from Alpine to a base image that uses glibc, such as Ubuntu or Debian. This eliminates the musl/glibc incompatibility.