Baking a Version String into a GraalVM Native Image
How I Got Here
Baking a Version String into a GraalVM Native Image

How I Got Here
I was building [jbeats](https://github.com/somak2kai/jbeats) — a Java CLI tool that parses Java source files and emits per-method metadata as JSON. I distribute jbeats as a GraalVM native binary via Homebrew.
I tagged a release, the CI built the native images, Homebrew picked them up. Everything looked fine:
$ brew upgrade jbeats
==> Upgrading somak2kai/tap/jbeats
0.1.2 -> 0.1.4
🍺 /opt/homebrew/Cellar/jbeats/0.1.4: 3 files, 20.5MB, built in 3 seconds
Then I ran:
$ jbeats --version
jbeats 0.1.0
No no no not 0.1.0 :O :(
The version I had hardcoded in Main.java on day one. Homebrew correctly installed 0.1.4. The binary reported 0.1.0. Something was clearly wrong.
The first fix seemed obvious — pass the version from the git tag at build time:
mvn -Pnative package -DskipTests -Djbeats.version=v0.1.4
And read it back:
$ jbeats --version
jbeats dev
The fallback dev was being printed .. me sad again :( The property had vanished entirely.
That’s when I understood what GraalVM native-image actually does — and why -D properties don't behave the way you expect.
The Problem
You’re building a CLI tool in Java. You compile it to a native binary with GraalVM’s native-image. You want mytool --version to print the version from your release tag — say, v1.2.0.
The obvious thing to try:
native-image -Dapp.version=1.2.0 -jar myapp.jar
Then in code:
System.out.println(System.getProperty("app.version", "dev"));
You build. You run. It prints dev.
The property evaporated. Here’s why.
GraalVM native-image is not a JIT compiler. It performs Ahead-of-Time (AOT) compilation — it runs a static analysis of your entire program, determines what code paths are reachable, and compiles everything down to a standalone binary. No JVM at runtime.
System properties passed with -D during the native-image build are build-time properties. They influence the analysis phase — things like logging configuration, feature flags, and framework bootstrapping during compilation. They are not automatically carried into the final binary as runtime state.
The answer is --initialize-at-build-time.
How Class Initialization Works in Native Image
In a standard JVM, classes are initialized lazily — the first time they are accessed at runtime. Their static initializers (static {} blocks and static field assignments) run then.
GraalVM native-image gives you control over this. You can tell it to initialize specific classes during the build instead. When it does, static fields get their values computed and baked into the binary as constants. At runtime, those classes are already initialized — no work to do, no properties to look up.
This is the lever we need.
The Fix
Create a dedicated class whose sole job is to hold the version string:
package com.example;
/**
* Initialized at native-image build time.
* The VERSION field is resolved during compilation and baked
* into the binary — no runtime property lookup needed.
*/
public final class Version {
public static final String VALUE =
System.getProperty("app.version", "dev");
private Version() {}
}
Tell GraalVM to initialize it at build time. In your native-image arguments (or Maven pom.xml):
--initialize-at-build-time=com.example.Version
Now the flow is:
native-imagestarts, initializesVersionduring buildSystem.getProperty("app.version", "dev")runs at compile time — readsv1.2.0from the build environment"v1.2.0"is stored in the static field and baked into the binary- At runtime,
Version.VALUEis just a constant — no JVM, no property lookup, no fallback
Wiring It Up with Maven
In your pom.xml, define a default property:
<properties>
<app.version>dev</app.version>
</properties>
Pass it to native-image in the GraalVM plugin configuration:
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>0.10.4</version>
<configuration>
<mainClass>com.example.Main</mainClass>
<imageName>mytool</imageName>
<buildArgs>
<arg>--no-fallback</arg>
<arg>-Dapp.version=${app.version}</arg>
<arg>--initialize-at-build-time=com.example.Version</arg>
</buildArgs>
</configuration>
</plugin>
And Et voilà …
An Alternative I Considered — And Why I Dropped It
Before landing on the --initialize-at-build-time approach, I looked at Maven resource filtering. The idea is straightforward: create a version.properties file in src/main/resources/, put a placeholder in it, and let Maven replace it before compilation.
# src/main/resources/version.properties
version=${app.version}
Enable filtering in pom.xml:
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
Then read it at runtime:
Properties props = new Properties();
props.load(Main.class.getResourceAsStream("/version.properties"));
String version = props.getProperty("version", "dev");
System.out.println("jbeats " + version);
On paper this works. In practice with GraalVM native-image, there are two problems.
Problem 1: GraalVM doesn’t bundle resources by default.
native-image does not include classpath resources in the binary unless you explicitly register them. So getResourceAsStream("/version.properties") returns null at runtime — the file simply isn't there. To fix it you need a resource-config.json in src/main/resources/META-INF/native-image/:
json
{
"resources": {
"includes": [
{ "pattern": "version.properties" }
]
}
}
That’s an extra config file to maintain. Forget it, or get the pattern wrong, and you get a silent null — same symptom as before, no build error to catch it.
Problem 2: Silent failure if filtering doesn’t run.
If Maven resource filtering is skipped or misconfigured, the placeholder never gets replaced. Your binary ships with the literal string ${app.version} as its version. Again, no build error — it compiles and links just fine.
Two extra failure modes, both silent, both producing wrong output rather than a build break. That’s worse than the problem I was trying to solve.
The --initialize-at-build-time approach fails loudly if something goes wrong — if the property isn't set, you get dev, which is visibly wrong and easy to catch in CI. It also requires zero extra config files. That's why I went with it.
This is the first time I came across --initialize-at-build-time approach and I have to say — i kinda like it.

메타데이터
- post_id
- 40b1535c57d6
- slug
- baking-a-version-string-into-a-graalvm-native-image-40b1535c57d6
- url
- https://medium.com/@somaktukai/baking-a-version-string-into-a-graalvm-native-image-40b1535c57d6
- canonical_url
- https://medium.com/@somaktukai/baking-a-version-string-into-a-graalvm-native-image-40b1535c57d6
- author_url
- https://medium.com/@somaktukai
- status
- ok
- fetched_at
- 2026-06-25 16:53:31