Skip to main content

Configuration Management in Go

Published: May 8, 2026 Updated: August 28, 2026 Larry Qu 7 min read

Configuration tells your application how to behave in a specific environment. Production configuration management in Go involves more than reading environment variables — it includes validation, secrets handling, feature flags, and safe reloading. This guide covers the full spectrum from simple env vars to production-grade config systems.

Starting Simple: os.Getenv with Defaults

For small services, direct env var reading is fine:

package config

import (
    "fmt"
    "os"
    "strconv"
    "strings"
    "time"
)

type Config struct {
    // Server
    Port        int
    Host        string
    Environment string

    // Database
    DatabaseURL     string
    DatabaseMaxConn int
    DatabaseTimeout time.Duration

    // Auth
    JWTSecret   string
    JWTExpiry   time.Duration

    // Features
    EnableMetrics bool
    LogLevel      string
}

func Load() (*Config, error) {
    cfg := &Config{
        Port:            getEnvInt("PORT", 8080),
        Host:            getEnv("HOST", "0.0.0.0"),
        Environment:     getEnv("ENVIRONMENT", "development"),
        DatabaseURL:     getEnv("DATABASE_URL", ""),
        DatabaseMaxConn: getEnvInt("DATABASE_MAX_CONNECTIONS", 25),
        DatabaseTimeout: getEnvDuration("DATABASE_TIMEOUT", 5*time.Second),
        JWTSecret:       getEnv("JWT_SECRET", ""),
        JWTExpiry:       getEnvDuration("JWT_EXPIRY", 24*time.Hour),
        EnableMetrics:   getEnvBool("ENABLE_METRICS", true),
        LogLevel:        getEnv("LOG_LEVEL", "info"),
    }
    return cfg, cfg.Validate()
}

func (c *Config) Validate() error {
    if c.DatabaseURL == "" {
        return fmt.Errorf("DATABASE_URL is required")
    }
    if c.Environment == "production" && c.JWTSecret == "" {
        return fmt.Errorf("JWT_SECRET is required in production")
    }
    if c.Port < 1 || c.Port > 65535 {
        return fmt.Errorf("PORT must be between 1 and 65535, got %d", c.Port)
    }
    validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
    if !validLevels[c.LogLevel] {
        return fmt.Errorf("LOG_LEVEL must be one of debug/info/warn/error, got %q", c.LogLevel)
    }
    return nil
}

func (c *Config) IsProduction() bool  { return c.Environment == "production" }
func (c *Config) IsDevelopment() bool { return c.Environment == "development" }

// Helpers
func getEnv(key, fallback string) string {
    if v := os.Getenv(key); v != "" { return v }
    return fallback
}

func getEnvInt(key string, fallback int) int {
    if v := os.Getenv(key); v != "" {
        if n, err := strconv.Atoi(v); err == nil { return n }
    }
    return fallback
}

func getEnvBool(key string, fallback bool) bool {
    if v := os.Getenv(key); v != "" {
        return strings.ToLower(v) == "true" || v == "1"
    }
    return fallback
}

func getEnvDuration(key string, fallback time.Duration) time.Duration {
    if v := os.Getenv(key); v != "" {
        if d, err := time.ParseDuration(v); err == nil { return d }
    }
    return fallback
}

Validate and crash early at startup:

func main() {
    cfg, err := config.Load()
    if err != nil {
        log.Fatalf("invalid configuration: %v", err)
    }
    // cfg is now guaranteed valid for the rest of the program
    startServer(cfg)
}

Viper: Multi-Source Configuration

Viper reads from env vars, config files, remote stores, and command-line flags — all in priority order:

go get github.com/spf13/viper
import (
    "github.com/spf13/viper"
    "strings"
)

func LoadWithViper() (*Config, error) {
    v := viper.New()

    // 1. Config file (lowest priority)
    v.SetConfigName("config")           // config.yaml / config.json / config.toml
    v.SetConfigType("yaml")
    v.AddConfigPath(".")
    v.AddConfigPath("$HOME/.myapp")
    v.AddConfigPath("/etc/myapp/")

    if err := v.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, fmt.Errorf("reading config file: %w", err)
        }
        // Config file not found — fall through to env vars
    }

    // 2. Environment variables (higher priority)
    v.SetEnvPrefix("MYAPP")             // MYAPP_PORT, MYAPP_DATABASE_URL, etc.
    v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // server.port → SERVER_PORT
    v.AutomaticEnv()

    // 3. Defaults
    v.SetDefault("server.port", 8080)
    v.SetDefault("server.host", "0.0.0.0")
    v.SetDefault("log.level", "info")
    v.SetDefault("database.max_connections", 25)
    v.SetDefault("database.timeout", "5s")

    // Unmarshal into struct
    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("unmarshaling config: %w", err)
    }
    return &cfg, cfg.Validate()
}

Config File Example

# config.yaml
server:
  port: 8080
  host: "0.0.0.0"

database:
  url: "postgres://localhost/myapp"
  max_connections: 25
  timeout: "5s"

log:
  level: "info"

features:
  metrics: true
  new_ui: false

Secrets Management

Never put secrets in config files or environment variables directly in production. Use a secrets manager:

AWS Secrets Manager

go get github.com/aws/aws-sdk-go-v2/service/secretsmanager
import (
    "context"
    "encoding/json"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)

type Secrets struct {
    DatabasePassword string `json:"database_password"`
    JWTSecret        string `json:"jwt_secret"`
    APIKeys          map[string]string `json:"api_keys"`
}

func LoadSecrets(ctx context.Context, secretName string) (*Secrets, error) {
    awsCfg, err := config.LoadDefaultConfig(ctx)
    if err != nil {
        return nil, fmt.Errorf("loading AWS config: %w", err)
    }

    client := secretsmanager.NewFromConfig(awsCfg)
    output, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
        SecretId: &secretName,
    })
    if err != nil {
        return nil, fmt.Errorf("getting secret %s: %w", secretName, err)
    }

    var secrets Secrets
    if err := json.Unmarshal([]byte(*output.SecretString), &secrets); err != nil {
        return nil, fmt.Errorf("parsing secrets: %w", err)
    }
    return &secrets, nil
}

HashiCorp Vault

import vault "github.com/hashicorp/vault/api"

func LoadFromVault(addr, token, path string) (map[string]string, error) {
    client, err := vault.NewClient(&vault.Config{Address: addr})
    if err != nil {
        return nil, err
    }
    client.SetToken(token)

    secret, err := client.Logical().Read(path)
    if err != nil || secret == nil {
        return nil, fmt.Errorf("reading vault path %s: %w", path, err)
    }

    result := make(map[string]string)
    for k, v := range secret.Data {
        if str, ok := v.(string); ok {
            result[k] = str
        }
    }
    return result, nil
}

.env Files (Development Only)

go get github.com/joho/godotenv
// Load .env only in development — never in production containers
func init() {
    if os.Getenv("ENVIRONMENT") != "production" {
        if err := godotenv.Load(); err != nil {
            // .env not found is fine — env vars may be set directly
        }
    }
}

.env file (never commit to git):

DATABASE_URL=postgres://localhost/myapp_dev
JWT_SECRET=dev-secret-not-for-production
LOG_LEVEL=debug

Feature Flags

Feature flags let you deploy code before it’s ready to enable:

type FeatureFlags struct {
    mu    sync.RWMutex
    flags map[string]bool
}

func NewFeatureFlags() *FeatureFlags {
    return &FeatureFlags{
        flags: map[string]bool{
            "new_ui":       getEnvBool("FEATURE_NEW_UI", false),
            "beta_api":     getEnvBool("FEATURE_BETA_API", false),
            "dark_mode":    getEnvBool("FEATURE_DARK_MODE", true),
            "maintenance":  getEnvBool("MAINTENANCE_MODE", false),
        },
    }
}

func (f *FeatureFlags) IsEnabled(flag string) bool {
    f.mu.RLock()
    defer f.mu.RUnlock()
    return f.flags[flag]
}

func (f *FeatureFlags) Set(flag string, enabled bool) {
    f.mu.Lock()
    defer f.mu.Unlock()
    f.flags[flag] = enabled
}

// HTTP endpoint to toggle flags at runtime (admin only)
func (f *FeatureFlags) Handler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        f.mu.RLock()
        defer f.mu.RUnlock()
        json.NewEncoder(w).Encode(f.flags)
    }
}

Usage in handlers:

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if h.features.IsEnabled("maintenance") {
        http.Error(w, "Service under maintenance", http.StatusServiceUnavailable)
        return
    }

    if h.features.IsEnabled("new_ui") {
        serveNewUI(w, r)
        return
    }
    serveOldUI(w, r)
}

Hot Reload with fsnotify

Reload config file changes without restarting the service:

go get github.com/fsnotify/fsnotify
import "github.com/fsnotify/fsnotify"

func WatchConfig(configFile string, onReload func(*Config)) {
    watcher, err := fsnotify.NewWatcher()
    if err != nil {
        log.Printf("config watcher error: %v", err)
        return
    }

    go func() {
        defer watcher.Close()
        for {
            select {
            case event, ok := <-watcher.Events:
                if !ok { return }
                if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
                    // Debounce — wait 100ms for write to complete
                    time.Sleep(100 * time.Millisecond)
                    cfg, err := LoadFromFile(configFile)
                    if err != nil {
                        log.Printf("config reload error: %v", err)
                        continue
                    }
                    log.Printf("Config reloaded from %s", configFile)
                    onReload(cfg)
                }
            case err, ok := <-watcher.Errors:
                if !ok { return }
                log.Printf("config watcher error: %v", err)
            }
        }
    }()

    watcher.Add(configFile)
}

Viper also supports built-in watch:

v.WatchConfig()
v.OnConfigChange(func(e fsnotify.Event) {
    log.Printf("Config file changed: %s", e.Name)
    // re-read and update app config
})

Environment-Specific Config Files

A common pattern for managing dev/staging/prod configs:

config/
  base.yaml          # shared defaults
  development.yaml   # dev overrides
  production.yaml    # prod overrides
  test.yaml          # test overrides
func LoadForEnvironment(env string) (*Config, error) {
    v := viper.New()

    // Load base config
    v.SetConfigFile("config/base.yaml")
    if err := v.ReadInConfig(); err != nil {
        return nil, err
    }

    // Merge environment-specific overrides
    override := viper.New()
    override.SetConfigFile(fmt.Sprintf("config/%s.yaml", env))
    if err := override.ReadInConfig(); err == nil {
        v.MergeConfigMap(override.AllSettings())
    }

    // Env vars override everything
    v.AutomaticEnv()

    var cfg Config
    return &cfg, v.Unmarshal(&cfg)
}

Production Checklist

Before deploying:

func (c *Config) ProductionChecks() []string {
    var warnings []string

    if c.JWTSecret == "change-me" || len(c.JWTSecret) < 32 {
        warnings = append(warnings, "JWT_SECRET is weak or default")
    }
    if c.DatabaseURL == "" {
        warnings = append(warnings, "DATABASE_URL not set")
    }
    if c.LogLevel == "debug" {
        warnings = append(warnings, "LOG_LEVEL=debug in production is noisy")
    }
    if !c.EnableMetrics {
        warnings = append(warnings, "Metrics disabled — consider enabling for observability")
    }
    return warnings
}

// In main()
cfg, err := config.Load()
if err != nil { log.Fatalf("config error: %v", err) }

if cfg.IsProduction() {
    for _, w := range cfg.ProductionChecks() {
        log.Printf("CONFIG WARNING: %s", w)
    }
}

Summary

Approach Use when
os.Getenv + validation struct Simple services, 12-factor apps
Viper Multiple config sources, YAML/TOML files
AWS Secrets Manager / Vault Production secrets — never in env vars or files
godotenv Development only — .env files
Feature flags struct Runtime behavior toggles without deployment
fsnotify / Viper watch Hot reload non-secret config

The golden rules: validate at startup and crash if invalid, never log secrets, separate secrets from config, use a secrets manager in production.

Resources

Comments

👍 Was this article helpful?