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:
JVM runtime: ~200MB+ base memorySpring Boot image: ~300MB+ Docker imageStartup time: 5-15 secondsFor 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:
{ "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:
spring: datasource: url: jdbc:postgresql://localhost:5432/db username: userbecomes:
spring.datasource.url: jdbc:postgresql://localhost:5432/dbspring.datasource.username: userHow to Build It
Step 1: YAML Flattening Logic
I wrote a recursive function to flatten nested YAML structures:
package config
import ( "fmt")
// FlattenYAML converts nested YAML to Spring's dot-notation propertiesfunc 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:
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:
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:
# Start the server./config-server
# Initial configcurl http://localhost:8888/myapp/production
# Update config fileecho 'app.feature.new-flag: true' >> config/myapp-production.yml
# Immediately visible (no restart)curl http://localhost:8888/myapp/production# Returns updated configStep 4: The Main Server
Here’s the full server implementation:
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:
# Build stageFROM golang:1.21-alpine AS builder
WORKDIR /buildCOPY go.mod go.sum ./RUN go mod download
COPY . .RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o config-server .
# Runtime stageFROM alpine:3.19
RUN apk --no-cache add ca-certificates
WORKDIR /appCOPY --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:
spring: application: name: myapp cloud: config: uri: http://config-server:8888 profile: productionThe Go server responds identically to GET http://config-server:8888/myapp/production.
The Results
Here’s what I achieved:
Go binary: ~10MBGo Docker image: ~15MB (Alpine)Runtime memory: ~15-30MBStartup time: milliseconds
vs Spring Boot:Spring image: ~300MB+Runtime memory: ~200MB+Startup time: 5-15 secondsSummary
In this post, I showed how to build a Go config server that’s compatible with Spring Cloud Config clients. The key points are:
- YAML flattening - Convert nested YAML to dot-notation properties
- Match propertySources format - Return the exact JSON structure Spring expects
- Hot-reload - Use fsnotify to watch files without restart
- Health checks - Expose
/healthfor 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:
- 👨💻 config-server-go GitHub Repository
- 👨💻 Spring Cloud Config Documentation
- 👨💻 fsnotify Package
- 👨💻 Reddit Discussion: Replaced Spring Cloud Config Server with Go
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments