Skip to content

How to Build Spring Cloud Config Server as GraalVM Native Image

The Problem: Spring Cloud Config Server Eats Too Much Memory

I was running Spring Cloud Config Server in Kubernetes with 512MB memory limits. It kept getting OOM killed during peak traffic. The JVM alone was consuming 200-350MB RSS, and when Git clone operations spiked, it would exceed the limit.

Someone on my team suggested rewriting it in Go. They showed me a simple Go config server that used only 10MB. But I didn’t want to abandon years of Spring ecosystem investment.

Then I found a Reddit thread where someone faced the exact same dilemma. The top comment changed everything:

“If you already were that committed to Spring, you could have just slapped Spring Native on it and you would get more or less the same result” - 16 votes

Another comment reinforced this:

“Keep Spring Config Server and build a native executable with GraalVM” - 12 votes

The consensus was clear: GraalVM native image could achieve similar results to Go without rewriting a single line of Spring code.

My First Attempt: Just Enable Native Compilation

I was using Spring Boot 2.7 with Spring Cloud Config Server. I added the native-maven-plugin and tried to build:

First native build attempt
./mvnw native:compile -Pnative

Result: Build failed with hundreds of errors about missing reflection metadata.

Error: Classes that should be initialized at run time got initialized during image building.
Error: No instances of org.springframework.cloud.config.server.environment.JGitEnvironmentRepository are allowed in the image heap.
Error: ClassNotFoundException: org.eclipse.jgit.transport.JschConfigSessionFactory

I realized Spring Cloud Config Server is more complex than a simple REST API. It uses dynamic class loading, reflection for property sources, and JGit for backend operations. All of these need explicit configuration for native images.

Step 1: Upgrade to Spring Boot 3.x

My first mistake was trying this with Spring Boot 2.7. Spring Boot 3.x has native compilation support built-in, no experimental flags needed.

I upgraded my pom.xml:

pom.xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2023.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

Then I configured the native build plugins:

pom.xml native build config
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-aot</id>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

Step 2: Install GraalVM

Before building, I needed GraalVM installed:

Check and install GraalVM
# Check current Java version
java -version
# Output: java version "17.0.x"
# Install GraalVM using SDKMAN (recommended)
sdk install java 21.0.1-graal
sdk use java 21.0.1-graal
# Verify GraalVM
java -version
# Output: openjdk version "21.0.1" 2023-10-17 LTS
# OpenJDK Runtime Environment GraalVM CE 21.0.1

Step 3: Handle Reflection for Config Server (The Critical Part)

This was where I spent most of my time. Spring Cloud Config Server uses reflection heavily for:

  1. Loading different property source types (Git, Vault, native filesystem)
  2. JGit SSH authentication
  3. Dynamic environment repository instantiation

I created src/main/resources/META-INF/native-image/reflect-config.json:

src/main/resources/META-INF/native-image/reflect-config.json
[
{
"name": "org.springframework.cloud.config.server.environment.JGitEnvironmentRepository",
"allDeclaredConstructors": true,
"allDeclaredMethods": true,
"allDeclaredFields": true
},
{
"name": "org.springframework.cloud.config.server.environment.NativeEnvironmentRepository",
"allDeclaredConstructors": true,
"allDeclaredMethods": true
},
{
"name": "org.springframework.cloud.config.server.environment.VaultEnvironmentRepository",
"allDeclaredConstructors": true,
"allDeclaredMethods": true
},
{
"name": "org.eclipse.jgit.transport.JschConfigSessionFactory",
"allDeclaredMethods": true,
"allDeclaredFields": true
},
{
"name": "org.eclipse.jgit.api.Git",
"allDeclaredConstructors": true,
"allDeclaredMethods": true
},
{
"name": "org.eclipse.jgit.internal.storage.file.FileRepository",
"allDeclaredConstructors": true
}
]

Why this matters: GraalVM performs ahead-of-time (AOT) compilation. Unlike the JVM, which can load classes dynamically at runtime, native images must know at build time which classes will be accessed via reflection. Spring Cloud Config Server uses EnvironmentRepository implementations loaded dynamically based on configuration.

Step 4: Handle Serialization

Config Server serializes Environment objects when caching or clustering. I needed serialization hints:

src/main/resources/META-INF/native-image/serialization-config.json
[
{
"name": "org.springframework.cloud.config.environment.Environment",
"allDeclaredConstructors": true,
"allDeclaredMethods": true,
"allDeclaredFields": true
},
{
"name": "org.springframework.cloud.config.environment.PropertySource",
"allDeclaredConstructors": true,
"allDeclaredMethods": true,
"allDeclaredFields": true
},
{
"name": "org.springframework.cloud.config.environment.PropertyValueDescriptor"
}
]

Step 5: Resource Configuration

Config Server loads application.yml, bootstrap.yml, and property files. These need explicit inclusion:

src/main/resources/META-INF/native-image/resource-config.json
{
"resources": {
"includes": [
{"pattern": "application.yml"},
{"pattern": "application.properties"},
{"pattern": "bootstrap.yml"},
{"pattern": "bootstrap.properties"},
{"pattern": ".*\\.properties$"},
{"pattern": ".*\\.yml$"},
{"pattern": "META-INF/spring.*"}
]
}
}

Why not just include everything? Including too many resources bloats the native image. I only included patterns I knew Config Server needed.

Step 6: Build the Native Image

Finally, the moment of truth:

Build native image
# Clean build
./mvnw clean package -Pnative
# This takes 2-3 minutes on first run
# Subsequent builds are faster due to caching
# The native executable appears at:
ls -lh target/config-server
# -rwxr-xr-x 1 user user 48M Mar 30 10:42 target/config-server

The build output showed Spring AOT processing:

[INFO] --- spring-boot-maven-plugin:3.2.0:process-aot (default) @ config-server ---
[INFO] Processing AOT...
[INFO] Generating reflection hints...
[INFO] Generating resource hints...

Step 7: Run and Verify

Run native executable
# Run the native executable
./target/config-server
# Output:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, / / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.2.0)
2026-03-30 10:42:15.234 INFO 12345 --- [main] o.s.c.c.s.ConfigServerApplication : Started ConfigServerApplication in 0.089 seconds

0.089 seconds startup. Compare that to 3-5 seconds with JVM.

Now let’s check memory:

Check memory usage
# Get the PID
pgrep config-server
# 12345
# Check memory
ps -o pid,rss,command -p 12345
# PID RSS COMMAND
# 12345 45632 ./target/config-server

45632 KB = ~45MB RSS. My JVM-based Config Server was using 280MB.

The Results: Before vs After

I ran comparison tests over a week:

text title=“Memory and startup comparison”

MetricJVM ModeNative ImageGo Alternative
Startup Time3-5 seconds80-150ms5-20ms
Memory (RSS)200-350MB30-80MB10-30MB
Binary SizeN/A (needs JVM)40-60MB8-12MB
JVM RequiredYesNoNo
Code ChangesNoneConfig onlyFull rewrite
Spring FeaturesAllMostNone
Development Time0 days1 day2-3 weeks
The native image achieved 85-90% memory reduction without any code changes.
## Common Problems I Encountered
### Problem 1: ClassNotFoundException at Runtime
After successful build, I got runtime errors:

java.lang.ClassNotFoundException: com.example.config.CustomPropertySource

**Root cause**: I had a custom property source class used via reflection.
**Solution**: Register it in `RuntimeHints`:
```java title="src/main/java/com/example/config/NativeHints.java"
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportRuntimeHints;
@Configuration
@ImportRuntimeHints(NativeHints.CustomPropertySourceHints.class)
public class NativeHints {
static class CustomPropertySourceHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.reflection().registerType(
com.example.config.CustomPropertySource.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_DECLARED_METHODS
);
}
}
}

Problem 2: Missing Resources at Runtime

java.io.FileNotFoundException: class path resource [application.yml] cannot be opened because it does not exist

Root cause: The resource wasn’t included in the native image.

Solution: Add resource hints programmatically:

Resource hints registration
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
public class ResourceHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources().registerPattern("*.yml");
hints.resources().registerPattern("*.properties");
hints.resources().registerPattern("config/*.yml");
}
}

Problem 3: SSH/Git Authentication Failures

My Config Server uses SSH keys for Git authentication:

com.jcraft.jsch.JSchException: Auth fail

Root cause: JSch (used by JGit for SSH) uses reflection internally.

Solution: Register JSch classes:

src/main/resources/META-INF/native-image/ssh-reflect-config.json
[
{
"name": "com.jcraft.jsch.Session",
"allDeclaredMethods": true,
"allDeclaredFields": true
},
{
"name": "com.jcraft.jsch.Channel",
"allDeclaredMethods": true
},
{
"name": "com.jcraft.jsch.ChannelSftp",
"allDeclaredConstructors": true,
"allDeclaredMethods": true
}
]

Problem 4: Build Fails with “initialized during image building”

Error: Classes that should be initialized at run time got initialized during image building.
This was caused by: org.eclipse.jgit.transport.JschConfigSessionFactory

Root cause: Some classes initialize static state during image build that should happen at runtime.

Solution: Create a native-image.properties file:

src/main/resources/META-INF/native-image/native-image.properties
Args = --initialize-at-run-time=org.eclipse.jgit.transport.JschConfigSessionFactory \
--initialize-at-run-time=org.eclipse.jgit.internal.transport.ssh.jsch.JschConfigSessionFactory

Problem 5: Dynamic Property Loading

My Config Server loads repositories dynamically based on configuration labels:

application.yml dynamic repos
spring:
cloud:
config:
server:
git:
repos:
team-a:
uri: https://github.com/org/team-a-config
pattern: team-a-*
team-b:
uri: https://github.com/org/team-b-config
pattern: team-b-*

Root cause: Spring creates these repository beans dynamically.

Solution: Use AOT hints to pre-declare all possible repository types:

Dynamic repository hints
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import java.util.stream.Stream;
@Configuration
@ImportRuntimeHints(DynamicRepoHints.class)
public class ConfigServerNativeConfig {
static class DynamicRepoHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
// Register all repository types that might be used
Stream.of(
"org.springframework.cloud.config.server.environment.JGitEnvironmentRepository",
"org.springframework.cloud.config.server.environment.NativeEnvironmentRepository",
"org.springframework.cloud.config.server.environment.SvnEnvironmentRepository",
"org.springframework.cloud.config.server.environment.VaultEnvironmentRepository"
).forEach(type -> {
try {
hints.reflection().registerType(
Class.forName(type),
MemberCategory.values()
);
} catch (ClassNotFoundException e) {
// Type not in classpath, skip
}
});
}
}
}

Backend Configuration Examples

Native Filesystem Backend (Simplest for Testing)

application.yml (native filesystem backend)
spring:
profiles:
active: native
cloud:
config:
server:
native:
search-locations: classpath:/config,file:/config

This is the simplest setup. No Git, no SSH issues. Just load config files from the classpath or filesystem.

Git Backend (Most Common)

application.yml (git backend)
spring:
cloud:
config:
server:
git:
uri: https://github.com/org/config-repo
clone-on-start: true
default-label: main
# Use shallow clone for faster startup
clone-depth: 1

Tip: clone-on-start: true is important for native images. Clone during startup, not on first request, to catch issues early.

HashiCorp Vault Backend

application.yml (vault backend)
spring:
cloud:
config:
server:
vault:
host: vault.example.com
port: 8200
scheme: https
authentication: TOKEN

For Vault, ensure the Vault client library is registered for reflection:

vault-reflect-config.json
[
{
"name": "org.springframework.vault.core.VaultTemplate",
"allDeclaredConstructors": true,
"allDeclaredMethods": true
}
]

Building Container Images

Instead of building locally, I built container images directly:

Build native container image
./mvnw spring-boot:build-image -Pnative
# Output image: docker.io/library/config-server:0.0.1-SNAPSHOT

The result is a minimal container with just the native executable, no JVM layer needed.

Kubernetes Deployment

k8s-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: config-server
spec:
replicas: 2
template:
spec:
containers:
- name: config-server
image: config-server:0.0.1-SNAPSHOT
resources:
requests:
memory: "64Mi" # Down from 256Mi
cpu: "100m"
limits:
memory: "128Mi" # Down from 512Mi
cpu: "500m"
ports:
- containerPort: 8888

I reduced memory limits from 512Mi to 128Mi. The native image never exceeded 80MB even under load.

Production Metrics After One Month

Running in production for a month:

text title=“Production metrics”

MetricBefore (JVM)After (Native)
Average Memory245MB52MB
Peak Memory340MB78MB
Startup Time4.2 seconds0.12 seconds
Cold Start (K8s)45 seconds8 seconds
Container Image Size280MB85MB
CPU Usage (avg)12%8%
Cold start time dropped from 45 seconds to 8 seconds. This matters for HPA (Horizontal Pod Autoscaler) scaling events.
## When NOT to Use Native Images
Native images aren't always the answer:
1. **Heavy reflection frameworks**: If you use frameworks that heavily rely on dynamic proxies and reflection not supported by Spring AOT
2. **JRuby/Groovy scripts**: Dynamic languages won't work without extensive configuration
3. **Quick iteration**: Native builds take 2-3 minutes vs seconds for JVM builds
4. **Missing libraries**: Some libraries just don't support native compilation
For Config Server specifically, the main limitations are:
- **Native backend**: File-based config loading works but requires explicit resource configuration
- **JGit complexity**: SSH authentication works but needs JSch reflection hints
- **Custom repositories**: Any custom `EnvironmentRepository` needs manual registration
## Lessons Learned
1. **Spring Boot 3.x is required**: Don't try this with Spring Boot 2.x. The native support is much better in 3.x.
2. **Reflection hints are everything**: Spend time understanding what classes need reflection access. The error messages tell you what's missing.
3. **Test thoroughly**: Native images behave differently. Some runtime behaviors won't surface until the image runs.
4. **Start simple**: Get a native filesystem backend working first, then add Git/Vault complexity.
5. **Container builds are easier**: Use `spring-boot:build-image` instead of local native compilation to avoid environment issues.
## Conclusion
Building Spring Cloud Config Server as a GraalVM native image achieved 90% memory reduction (from ~280MB to ~45MB) and 50x faster startup (from 4 seconds to 80ms) without rewriting any Java code. The total effort was about one day, compared to weeks for a Go rewrite.
The key insight is that Spring Boot 3.x makes native compilation mostly a configuration problem rather than a code problem. Once I understood how to properly configure reflection hints for Config Server's dynamic class loading, everything worked.
For teams invested in the Spring ecosystem, GraalVM native images are a practical alternative to rewriting services in Go or Rust. You keep your Spring knowledge, your existing code, and get most of the operational benefits.
import FinalWords from '../../../components/FinalWords.astro';
<FinalWords
reflinks={frontmatter.reflinks}
currentPostId={frontmatter.title}
currentPostTags={frontmatter.tags}
currentPostSeries={frontmatter.series}
manualRelations={frontmatter.related_posts?.manual || []}
excludeList={frontmatter.related_posts?.exclude || []}
maxRelated={frontmatter.related_posts?.max_related || 5}
/>

Comments