How to Handle Configuration in Microservices with Go

Web
Configure Go microservices runtime behavior using the GODEBUG environment variable or go.mod directives to manage compatibility and security settings.

The configuration problem in microservices

You finish a Go service on your laptop. It connects to the local database, logs at debug level, and responds to HTTP requests. You push it to staging. The container starts, hits a missing environment variable, and crashes. Or worse, it starts silently, uses the wrong log level, and swallows errors until production catches fire. Configuration drift is the silent killer of microservices. Every environment needs different values for the same keys. You cannot bake those values into the binary. You need a reliable way to externalize settings, validate them early, and keep the code clean.

How Go approaches configuration

Go does not mandate a single configuration pattern. The language compiles to a single static binary, which means any value that changes between deployments must come from outside the executable. The community settled on a few conventions. Environment variables are the default for containers and cloud platforms. Configuration files work well for local development and on-premise deployments. Remote configuration servers handle dynamic updates without restarts. The standard library provides everything you need to read and parse these values. You combine os.LookupEnv, strconv, and struct tags to build a loader. The compiler enforces type safety at parse time, and the runtime respects whatever you pass in.

Configuration is just data that changes between environments. Think of it like a recipe. The code is the cooking technique. The configuration is the ingredient list and oven temperature. You do not bake a new cake to change the sugar amount. You adjust the parameters. Go treats configuration the same way. You define the shape in code, fill it from the outside, and validate it before the service starts serving traffic.

A minimal configuration loader

Here is the simplest way to load configuration from environment variables using only the standard library. The struct defines the shape. The loader fills it and returns an error if anything is missing or malformed.

package main

import (
	"fmt"
	"os"
	"strconv"
)

// Config holds the runtime settings for the service.
type Config struct {
	Port        int
	LogLevel    string
	DatabaseURL string
}

// LoadConfig reads environment variables and validates them.
func LoadConfig() (*Config, error) {
	// LookupEnv returns a bool so we can distinguish missing vs empty values.
	portStr, portExists := os.LookupEnv("SERVICE_PORT")
	if !portExists {
		return nil, fmt.Errorf("missing required env var SERVICE_PORT")
	}

	// Parse the port string into an integer.
	port, err := strconv.Atoi(portStr)
	if err != nil {
		return nil, fmt.Errorf("invalid port value: %w", err)
	}

	// Provide a sensible default for optional fields.
	logLevel := os.Getenv("LOG_LEVEL")
	if logLevel == "" {
		logLevel = "info"
	}

	// Return the fully populated struct.
	return &Config{
		Port:        port,
		LogLevel:    logLevel,
		DatabaseURL: os.Getenv("DATABASE_URL"),
	}, nil
}

The loader runs once during startup. It fails fast if required values are absent. The fmt.Errorf with %w wraps the original error, which preserves the error chain for debugging. Go's error wrapping convention makes the unhappy path visible. You do not hide failures behind silent defaults. You surface them immediately so the container orchestrator can restart or alert.

What happens at runtime

When the binary starts, the main function calls the loader before initializing any network listeners or database connections. The operating system passes environment variables to the process. Go's os package reads them from the process environment block. The loader parses strings into typed values. If parsing succeeds, the *Config pointer flows through the rest of the application. You pass it to handlers, workers, and middleware. You do not reach for global variables. You inject the configuration explicitly. This keeps tests deterministic and makes dependency graphs obvious.

The compiler does not check environment variable names. It only checks that your code compiles. If you typo a variable name, the program runs with an empty string or a default value. That is why the loader validates required fields upfront. You catch configuration errors at startup, not under load. The runtime then uses the values exactly as parsed. No magic happens behind the scenes. Go favors explicit behavior over implicit defaults.

Realistic microservice setup

A production service usually combines environment variables, a configuration file for local development, and runtime tuning flags. Here is how a realistic HTTP server initializes its configuration and starts listening.

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

// Server wraps the HTTP listener and configuration.
type Server struct {
	cfg *Config
	mux *http.ServeMux
}

// NewServer creates a configured HTTP server.
func NewServer(cfg *Config) *Server {
	// Initialize the router and attach handlers.
	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	return &Server{cfg: cfg, mux: mux}
}

// Run starts the server and handles graceful shutdown.
func (s *Server) Run(ctx context.Context) error {
	// Create the listener on the configured port.
	addr := fmt.Sprintf(":%d", s.cfg.Port)
	srv := &http.Server{Addr: addr, Handler: s.mux}

	// Start listening in the background.
	go func() {
		log.Printf("listening on %s", addr)
		if err := srv.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatalf("server failed: %v", err)
		}
	}()

	// Wait for interrupt signal to trigger shutdown.
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	// Create a timeout context for graceful shutdown.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	return srv.Shutdown(shutdownCtx)
}

The server respects context.Context as the first parameter in long-lived calls. That is a community convention. Functions that take a context should respect cancellation and deadlines. The Run method blocks until a signal arrives, then shuts down gracefully. You can extend this pattern to reload configuration on SIGHUP if your deployment strategy requires it.

Go also provides built-in runtime tuning through the GODEBUG environment variable and //go:debug directives. You set specific key-value pairs to override defaults for internal runtime features. For example, GODEBUG=http2client=0,tarinsecurepath=0 disables HTTP/2 for outgoing requests and enforces strict path validation for tar archives. You do not need to recompile to test these toggles. The runtime reads them at startup and applies them to the relevant packages.

Starting with Go 1.23, you can define defaults directly in your go.mod file using a godebug block. This removes the need to set environment variables in every deployment pipeline for internal tuning flags.

godebug (
    default=go1.21
    panicnil=1
)

The default key sets the minimum Go version behavior for certain runtime features. The panicnil=1 key forces a panic when a nil pointer is dereferenced, which helps catch bugs early in development. These directives live in the module file because they affect compilation and runtime behavior globally for that module. You treat them like build flags, not application configuration.

Configuration is plumbing. Run it through every long-lived call site.

Pitfalls and compiler behavior

Missing environment variables cause silent failures if you use os.Getenv without checking. os.Getenv returns an empty string when a key is absent. os.LookupEnv returns a boolean that tells you whether the key exists. Use LookupEnv for required values. Use Getenv for optional values with defaults.

Parsing errors surface as runtime errors if you ignore them. strconv.Atoi returns an error when the string contains non-numeric characters. The compiler does not catch this. It only checks that you handle the return values. If you write port := strconv.Atoi(os.Getenv("PORT")) without checking the error, the compiler rejects the program with assignment mismatch: 2 variables but strconv.Atoi returns 1 value (or a similar unused-result error depending on Go version). You must capture both the result and the error.

The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You do not wrap errors in custom types unless you need to add context. You chain them with %w so the original cause survives through the call stack.

Another common mistake is passing configuration by pointer everywhere. Configuration structs are usually small. You can pass them by value if they are read-only. If you need to update values at runtime, use a pointer or a configuration manager with atomic swaps. Do not mutate shared configuration without synchronization. Goroutine leaks happen when a background goroutine waits on a channel that never gets closed. Always have a cancellation path. Configuration reloaders must respect context cancellation.

Convention aside: public names start with a capital letter. Private names start lowercase. Configuration structs usually live in a config package with a Load function. The receiver name is usually one or two letters matching the type: (c *Config) Validate(), not (this *Config). Trust the conventions. They reduce cognitive load across codebases.

The worst configuration bug is the one that never logs.

When to use which configuration strategy

Use environment variables when you deploy to containers, Kubernetes, or cloud platforms that inject secrets and settings automatically. Use a configuration file when developers need local overrides without exporting variables in their terminal. Use a remote configuration service when you need zero-downtime updates for feature flags or rate limits. Use GODEBUG or //go:debug when you need to tune Go runtime behavior, disable specific standard library features, or enforce stricter safety checks during development. Use plain sequential code when you do not need dynamic configuration: hardcode the value or bake it into the build artifact.

Where to go next