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:
./mvnw native:compile -PnativeResult: 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.JschConfigSessionFactoryI 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:
<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:
<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 current Java versionjava -version# Output: java version "17.0.x"
# Install GraalVM using SDKMAN (recommended)sdk install java 21.0.1-graalsdk use java 21.0.1-graal
# Verify GraalVMjava -version# Output: openjdk version "21.0.1" 2023-10-17 LTS# OpenJDK Runtime Environment GraalVM CE 21.0.1Step 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:
- Loading different property source types (Git, Vault, native filesystem)
- JGit SSH authentication
- Dynamic environment repository instantiation
I created 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:
[ { "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:
{ "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:
# 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-serverThe 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 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 seconds0.089 seconds startup. Compare that to 3-5 seconds with JVM.
Now let’s check memory:
# Get the PIDpgrep config-server# 12345
# Check memoryps -o pid,rss,command -p 12345# PID RSS COMMAND# 12345 45632 ./target/config-server45632 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”
| Metric | JVM Mode | Native Image | Go Alternative |
|---|---|---|---|
| Startup Time | 3-5 seconds | 80-150ms | 5-20ms |
| Memory (RSS) | 200-350MB | 30-80MB | 10-30MB |
| Binary Size | N/A (needs JVM) | 40-60MB | 8-12MB |
| JVM Required | Yes | No | No |
| Code Changes | None | Config only | Full rewrite |
| Spring Features | All | Most | None |
| Development Time | 0 days | 1 day | 2-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 existRoot cause: The resource wasn’t included in the native image.
Solution: Add resource hints programmatically:
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 failRoot cause: JSch (used by JGit for SSH) uses reflection internally.
Solution: Register JSch classes:
[ { "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.JschConfigSessionFactoryRoot cause: Some classes initialize static state during image build that should happen at runtime.
Solution: Create a native-image.properties file:
Args = --initialize-at-run-time=org.eclipse.jgit.transport.JschConfigSessionFactory \ --initialize-at-run-time=org.eclipse.jgit.internal.transport.ssh.jsch.JschConfigSessionFactoryProblem 5: Dynamic Property Loading
My Config Server loads repositories dynamically based on configuration labels:
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:
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)
spring: profiles: active: native cloud: config: server: native: search-locations: classpath:/config,file:/configThis is the simplest setup. No Git, no SSH issues. Just load config files from the classpath or filesystem.
Git Backend (Most Common)
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: 1Tip: clone-on-start: true is important for native images. Clone during startup, not on first request, to catch issues early.
HashiCorp Vault Backend
spring: cloud: config: server: vault: host: vault.example.com port: 8200 scheme: https authentication: TOKENFor Vault, ensure the Vault client library is registered for reflection:
[ { "name": "org.springframework.vault.core.VaultTemplate", "allDeclaredConstructors": true, "allDeclaredMethods": true }]Building Container Images
Instead of building locally, I built container images directly:
./mvnw spring-boot:build-image -Pnative
# Output image: docker.io/library/config-server:0.0.1-SNAPSHOTThe result is a minimal container with just the native executable, no JVM layer needed.
Kubernetes Deployment
apiVersion: apps/v1kind: Deploymentmetadata: name: config-serverspec: 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: 8888I 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”
| Metric | Before (JVM) | After (Native) |
|---|---|---|
| Average Memory | 245MB | 52MB |
| Peak Memory | 340MB | 78MB |
| Startup Time | 4.2 seconds | 0.12 seconds |
| Cold Start (K8s) | 45 seconds | 8 seconds |
| Container Image Size | 280MB | 85MB |
| 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 AOT2. **JRuby/Groovy scripts**: Dynamic languages won't work without extensive configuration3. **Quick iteration**: Native builds take 2-3 minutes vs seconds for JVM builds4. **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