Configuration Management in Go

Configuration Management in Go

Managing configuration in Go applications can be done through environment variables, command-line flags, configuration files, or a combination of these. The viper library provides a unified interface for configuration management.

Environment Variables

Using environment variables for configuration:

package main

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

type Config struct {
    DatabaseURL string
    Port        int
    Debug       bool
    Timeout     int
}

func loadConfig() (*Config, error) {
    config := &Config{}
    
    // Required string value
    config.DatabaseURL = os.Getenv("DATABASE_URL")
    if config.DatabaseURL == "" {
        return nil, fmt.Errorf("DATABASE_URL environment variable is required")
    }
    
    // Optional int value with default
    portStr := os.Getenv("PORT")
    if portStr == "" {
        config.Port = 8080 // default
    } else {
        port, err := strconv.Atoi(portStr)
        if err != nil {
            return nil, fmt.Errorf("invalid PORT: %w", err)
        }
        config.Port = port
    }
    
    // Optional bool value
    config.Debug = os.Getenv("DEBUG") == "true"
    
    // Optional duration with default
    timeoutStr := os.Getenv("TIMEOUT")
    if timeoutStr == "" {
        config.Timeout = 30 // default 30 seconds
    } else {
        timeout, err := strconv.Atoi(timeoutStr)
        if err != nil {
            return nil, fmt.Errorf("invalid TIMEOUT: %w", err)
        }
        config.Timeout = timeout
    }
    
    return config, nil
}

func main() {
    config, err := loadConfig()
    if err != nil {
        fmt.Printf("Error loading config: %v\n", err)
        os.Exit(1)
    }
    
    fmt.Printf("Config: %+v\n", config)
}

Command-Line Flags

Using the flag package:

package main

import (
    "flag"
    "fmt"
    "time"
)

type Config struct {
    Host     string
    Port     int
    Verbose  bool
    Timeout  time.Duration
    Database string
}

func main() {
    config := &Config{}
    
    // Define flags
    flag.StringVar(&config.Host, "host", "localhost", "Server host")
    flag.IntVar(&config.Port, "port", 8080, "Server port")
    flag.BoolVar(&config.Verbose, "verbose", false, "Enable verbose logging")
    flag.DurationVar(&config.Timeout, "timeout", 30*time.Second, "Request timeout")
    flag.StringVar(&config.Database, "database", "app.db", "Database file path")
    
    // Custom flag value
    var logLevel string
    flag.StringVar(&logLevel, "log-level", "info", "Log level (debug, info, warn, error)")
    
    // Parse flags
    flag.Parse()
    
    // Validate and process
    switch logLevel {
    case "debug", "info", "warn", "error":
        // valid
    default:
        fmt.Printf("Invalid log level: %s\n", logLevel)
        flag.Usage()
        return
    }
    
    fmt.Printf("Config: %+v\n", config)
    fmt.Printf("Log level: %s\n", logLevel)
    
    // Remaining arguments
    args := flag.Args()
    if len(args) > 0 {
        fmt.Printf("Additional arguments: %v\n", args)
    }
}

Configuration Files

JSON Configuration

package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type ServerConfig struct {
    Host    string `json:"host"`
    Port    int    `json:"port"`
    TLS     bool   `json:"tls"`
    CertFile string `json:"cert_file,omitempty"`
    KeyFile  string `json:"key_file,omitempty"`
}

type DatabaseConfig struct {
    Host     string `json:"host"`
    Port     int    `json:"port"`
    Username string `json:"username"`
    Password string `json:"password"`
    Database string `json:"database"`
}

type AppConfig struct {
    Server   ServerConfig   `json:"server"`
    Database DatabaseConfig `json:"database"`
    LogLevel string         `json:"log_level"`
}

func loadJSONConfig(filename string) (*AppConfig, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, fmt.Errorf("failed to open config file: %w", err)
    }
    defer file.Close()
    
    var config AppConfig
    decoder := json.NewDecoder(file)
    if err := decoder.Decode(&config); err != nil {
        return nil, fmt.Errorf("failed to decode config: %w", err)
    }
    
    return &config, nil
}

func main() {
    config, err := loadJSONConfig("config.json")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    
    fmt.Printf("Server Host: %s\n", config.Server.Host)
    fmt.Printf("Database: %s\n", config.Database.Database)
    fmt.Printf("Log Level: %s\n", config.LogLevel)
}

Example config.json:

{
  "server": {
    "host": "0.0.0.0",
    "port": 8080,
    "tls": true,
    "cert_file": "server.crt",
    "key_file": "server.key"
  },
  "database": {
    "host": "localhost",
    "port": 5432,
    "username": "app_user",
    "password": "secret_password",
    "database": "app_db"
  },
  "log_level": "info"
}

YAML Configuration

package main

import (
    "fmt"
    "os"
    
    "gopkg.in/yaml.v3"
)

type Config struct {
    App struct {
        Name    string `yaml:"name"`
        Version string `yaml:"version"`
        Debug   bool   `yaml:"debug"`
    } `yaml:"app"`
    
    Database struct {
        Host     string `yaml:"host"`
        Port     int    `yaml:"port"`
        Username string `yaml:"username"`
        Password string `yaml:"password"`
        Name     string `yaml:"name"`
    } `yaml:"database"`
    
    Features []string `yaml:"features"`
}

func loadYAMLConfig(filename string) (*Config, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, fmt.Errorf("failed to read config file: %w", err)
    }
    
    var config Config
    if err := yaml.Unmarshal(data, &config); err != nil {
        return nil, fmt.Errorf("failed to parse YAML: %w", err)
    }
    
    return &config, nil
}

func main() {
    config, err := loadYAMLConfig("config.yaml")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    
    fmt.Printf("App Name: %s\n", config.App.Name)
    fmt.Printf("Features: %v\n", config.Features)
}

Example config.yaml:

app:
  name: MyApp
  version: 1.0.0
  debug: false

database:
  host: localhost
  port: 5432
  username: app_user
  password: secret_password
  name: app_db

features:
  - authentication
  - logging
  - metrics

Using Viper for Configuration

Viper provides a unified configuration interface:

package main

import (
    "fmt"
    "log"
    
    "github.com/spf13/viper"
)

type Config struct {
    Server struct {
        Host string `mapstructure:"host"`
        Port int    `mapstructure:"port"`
    } `mapstructure:"server"`
    
    Database struct {
        URL string `mapstructure:"url"`
    } `mapstructure:"database"`
    
    Features []string `mapstructure:"features"`
}

func loadConfig() (*Config, error) {
    // Set config file name (without extension)
    viper.SetConfigName("config")
    
    // Add config paths
    viper.AddConfigPath(".")
    viper.AddConfigPath("./config")
    viper.AddConfigPath("/etc/myapp")
    
    // Enable reading from environment variables
    viper.SetEnvPrefix("MYAPP")
    viper.AutomaticEnv()
    
    // Set defaults
    viper.SetDefault("server.host", "localhost")
    viper.SetDefault("server.port", 8080)
    viper.SetDefault("database.url", "postgres://localhost/myapp")
    viper.SetDefault("features", []string{"basic"})
    
    // Read config file
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, fmt.Errorf("failed to read config file: %w", err)
        }
        // Config file not found, using defaults and env vars
        log.Println("Config file not found, using defaults and environment variables")
    }
    
    // Override with command line flags
    viper.BindPFlag("server.port", flag.Lookup("port"))
    
    var config Config
    if err := viper.Unmarshal(&config); err != nil {
        return nil, fmt.Errorf("failed to unmarshal config: %w", err)
    }
    
    return &config, nil
}

func main() {
    // Parse command line flags first
    port := flag.Int("port", 8080, "Server port")
    flag.Parse()
    
    config, err := loadConfig()
    if err != nil {
        log.Fatal("Failed to load config:", err)
    }
    
    fmt.Printf("Server: %s:%d\n", config.Server.Host, config.Server.Port)
    fmt.Printf("Database: %s\n", config.Database.URL)
    fmt.Printf("Features: %v\n", config.Features)
    
    // Watch for config changes
    viper.WatchConfig()
    viper.OnConfigChange(func(e fsnotify.Event) {
        fmt.Println("Config file changed:", e.Name)
        // Reload configuration
    })
}

Configuration Validation

Validating configuration values:

package main

import (
    "errors"
    "fmt"
    "net/url"
    "regexp"
)

type ValidationError struct {
    Field   string
    Message string
}

func (e ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

type ValidationErrors []ValidationError

func (e ValidationErrors) Error() string {
    if len(e) == 0 {
        return ""
    }
    
    msg := "validation errors:"
    for _, err := range e {
        msg += fmt.Sprintf("\n  %s", err.Error())
    }
    return msg
}

func validateConfig(config *Config) error {
    var errors ValidationErrors
    
    // Validate server host
    if config.Server.Host == "" {
        errors = append(errors, ValidationError{
            Field:   "server.host",
            Message: "cannot be empty",
        })
    }
    
    // Validate server port
    if config.Server.Port < 1 || config.Server.Port > 65535 {
        errors = append(errors, ValidationError{
            Field:   "server.port",
            Message: "must be between 1 and 65535",
        })
    }
    
    // Validate database URL
    if config.Database.URL != "" {
        if _, err := url.Parse(config.Database.URL); err != nil {
            errors = append(errors, ValidationError{
                Field:   "database.url",
                Message: "invalid URL format",
            })
        }
    }
    
    // Validate features
    validFeatures := map[string]bool{
        "authentication": true,
        "logging":        true,
        "metrics":        true,
        "caching":        true,
    }
    
    for _, feature := range config.Features {
        if !validFeatures[feature] {
            errors = append(errors, ValidationError{
                Field:   "features",
                Message: fmt.Sprintf("unknown feature: %s", feature),
            })
        }
    }
    
    if len(errors) > 0 {
        return errors
    }
    
    return nil
}

func main() {
    config := &Config{
        Server: struct {
            Host string `mapstructure:"host"`
            Port int    `mapstructure:"port"`
        }{
            Host: "",
            Port: 99999,
        },
        Database: struct {
            URL string `mapstructure:"url"`
        }{
            URL: "invalid-url",
        },
        Features: []string{"authentication", "invalid_feature"},
    }
    
    if err := validateConfig(config); err != nil {
        fmt.Printf("Configuration validation failed:\n%s\n", err)
    } else {
        fmt.Println("Configuration is valid")
    }
}

Best Practices

  1. Use multiple sources: Combine environment variables, config files, and flags
  2. Set sensible defaults: Don’t require every configuration option
  3. Validate configuration: Check values at startup
  4. Document configuration: Explain what each option does
  5. Use consistent naming: Follow conventions (snake_case for env vars, camelCase for struct fields)
  6. Handle missing config gracefully: Provide fallbacks
  7. Don’t log sensitive data: Be careful with passwords and secrets
  8. Support different environments: dev, staging, prod configs
  9. Version config files: Keep track of config changes
  10. Test configuration loading: Include config tests in your test suite

Effective configuration management makes your Go applications more flexible and easier to deploy across different environments.

For more on environment variables, check our environment variables tutorial. If you’re deploying applications, see the deployment tutorial.

Last updated on