Skip to content

How to Build a Go Config Server Compatible with Spring Cloud Config Clients

Purpose

I wanted a lightweight config server for my microservices. Spring Cloud Config Server works fine, but it needs a JVM runtime - that’s ~200MB+ memory and a ~300MB+ Docker image. For simple file serving, this felt heavy.

I decided to build a Go-based config server that serves the exact same JSON format as Spring Cloud Config Server. Spring Boot clients should work without any changes to their bootstrap.yml.

Environment

  • Go 1.21+
  • Spring Boot 2.x/3.x clients
  • Docker for deployment
  • fsnotify for hot-reload

The Problem with Spring Cloud Config Server

Spring Cloud Config Server has these resource requirements:

Resource comparison
JVM runtime: ~200MB+ base memory
Spring Boot image: ~300MB+ Docker image
Startup time: 5-15 seconds

For teams wanting lighter infrastructure, the challenge is replicating Spring’s API format without the JVM overhead.

What Spring Clients Expect

Spring Cloud Config clients call /{application}/{profile} and expect this JSON format:

Expected response format
{
"name": "myapp",
"profiles": ["production"],
"propertySources": [
{
"name": "file:/config/myapp-production.yml",
"source": {
"spring.datasource.url": "jdbc:postgresql://prod-db:5432/myapp",
"spring.datasource.username": "app_user",
"server.port": 8080
}
}
]
}

The key detail: nested YAML must be flattened to dot-notation properties. For example:

Input YAML
spring:
datasource:
url: jdbc:postgresql://localhost:5432/db
username: user

becomes:

Flattened output
spring.datasource.url: jdbc:postgresql://localhost:5432/db
spring.datasource.username: user

How to Build It

Step 1: YAML Flattening Logic

I wrote a recursive function to flatten nested YAML structures:

flatten.go
package config
import (
"fmt"
)
// FlattenYAML converts nested YAML to Spring's dot-notation properties
func FlattenYAML(data map[string]interface{}, prefix string) map[string]interface{} {
result := make(map[string]interface{})
for key, value := range data {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
switch v := value.(type) {
case map[string]interface{}:
// Recursively flatten nested objects
nested := FlattenYAML(v, fullKey)
for nk, nv := range nested {
result[nk] = nv
}
case []interface{}:
// Handle arrays: spring.servers[0]=server1
for i, item := range v {
arrayKey := fmt.Sprintf("%s[%d]", fullKey, i)
if nested, ok := item.(map[string]interface{}); ok {
nestedFlat := FlattenYAML(nested, arrayKey)
for nk, nv := range nestedFlat {
result[nk] = nv
}
} else {
result[arrayKey] = item
}
}
default:
result[fullKey] = value
}
}
return result
}

Step 2: Config Server Structure

I defined the response structure to match Spring’s format exactly:

server.go
package main
type PropertySource struct {
Name string `json:"name"`
Source map[string]interface{} `json:"source"`
}
type ConfigResponse struct {
Name string `json:"name"`
Profiles []string `json:"profiles"`
Label string `json:"label"`
Version string `json:"version"`
State string `json:"state"`
PropertySources []PropertySource `json:"propertySources"`
}

Step 3: Hot-Reload with fsnotify

I used fsnotify to watch config files without restarting:

watcher.go
func (cs *ConfigServer) watchConfigs() {
for {
select {
case event, ok := <-cs.watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Write == fsnotify.Write ||
event.Op&fsnotify.Create == fsnotify.Create {
log.Printf("Config changed: %s", event.Name)
cs.mu.Lock()
cs.loadConfig(event.Name)
cs.mu.Unlock()
}
case err, ok := <-cs.watcher.Errors:
if !ok {
return
}
log.Printf("Watcher error: %v", err)
}
}
}

When I edit a YAML file, the server picks up changes instantly:

Hot-reload demo
# Start the server
./config-server
# Initial config
curl http://localhost:8888/myapp/production
# Update config file
echo 'app.feature.new-flag: true' >> config/myapp-production.yml
# Immediately visible (no restart)
curl http://localhost:8888/myapp/production
# Returns updated config

Step 4: The Main Server

Here’s the full server implementation:

main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v3"
)
type ConfigServer struct {
configDir string
watcher *fsnotify.Watcher
configs map[string]map[string]interface{}
mu sync.RWMutex
}
func NewConfigServer(configDir string) (*ConfigServer, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
cs := &ConfigServer{
configDir: configDir,
watcher: watcher,
configs: make(map[string]map[string]interface{}),
}
if err := cs.loadAllConfigs(); err != nil {
return nil, err
}
go cs.watchConfigs()
if err := watcher.Add(configDir); err != nil {
return nil, err
}
return cs, nil
}
func (cs *ConfigServer) GetConfig(appName, profile string) *ConfigResponse {
cs.mu.RLock()
defer cs.mu.RUnlock()
var sources []PropertySource
// Load order: app-profile.yml, app.yml, application.yml
patterns := []string{
fmt.Sprintf("%s-%s.yml", appName, profile),
fmt.Sprintf("%s.yml", appName),
"application.yml",
}
for _, pattern := range patterns {
if config, exists := cs.configs[pattern]; exists {
sources = append(sources, PropertySource{
Name: fmt.Sprintf("file:/%s", filepath.Join(cs.configDir, pattern)),
Source: FlattenYAML(config, ""),
})
}
}
return &ConfigResponse{
Name: appName,
Profiles: []string{profile},
PropertySources: sources,
}
}
func (cs *ConfigServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 2 {
http.Error(w, "Invalid path. Use /{application}/{profile}", http.StatusBadRequest)
return
}
appName := parts[0]
profile := parts[1]
config := cs.GetConfig(appName, profile)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}
func (cs *ConfigServer) HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "UP"})
}
func main() {
configDir := os.Getenv("CONFIG_DIR")
if configDir == "" {
configDir = "/config"
}
server, err := NewConfigServer(configDir)
if err != nil {
log.Fatalf("Failed to create config server: %v", err)
}
defer server.watcher.Close()
http.HandleFunc("/health", server.HealthHandler)
http.HandleFunc("/", server.ServeHTTP)
port := os.Getenv("PORT")
if port == "" {
port = "8888"
}
log.Printf("Config server starting on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, server))
}

Step 5: Docker Deployment

I built a minimal Alpine image:

Dockerfile
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o config-server .
# Runtime stage
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /build/config-server .
RUN mkdir -p /config
EXPOSE 8888
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:8888/health || exit 1
ENTRYPOINT ["./config-server"]

Spring Clients: Zero Changes Required

My Spring Boot applications don’t need any code changes. They just point to the Go server:

bootstrap.yml
spring:
application:
name: myapp
cloud:
config:
uri: http://config-server:8888
profile: production

The Go server responds identically to GET http://config-server:8888/myapp/production.

The Results

Here’s what I achieved:

Size comparison
Go binary: ~10MB
Go Docker image: ~15MB (Alpine)
Runtime memory: ~15-30MB
Startup time: milliseconds
vs Spring Boot:
Spring image: ~300MB+
Runtime memory: ~200MB+
Startup time: 5-15 seconds

Summary

In this post, I showed how to build a Go config server that’s compatible with Spring Cloud Config clients. The key points are:

  1. YAML flattening - Convert nested YAML to dot-notation properties
  2. Match propertySources format - Return the exact JSON structure Spring expects
  3. Hot-reload - Use fsnotify to watch files without restart
  4. Health checks - Expose /health for Kubernetes/load balancers

Spring Boot clients work without any changes - they just point to the Go server URI.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments