How to Handle Configuration in Go

Configuration management is where many Go applications fall apart in production. I've seen too many codebases where database credentials are scattered across multiple files, feature flags are...

Key Insights

  • Environment variables work for simple cases, but structured configuration with validation prevents runtime errors and makes your application more maintainable
  • Use a layered approach where defaults are overridden by config files, then environment variables—this gives you flexibility across development, staging, and production
  • Never hardcode secrets or environment-specific values; separate configuration from code and validate all inputs at startup to fail fast

Introduction to Configuration Management

Configuration management is where many Go applications fall apart in production. I’ve seen too many codebases where database credentials are scattered across multiple files, feature flags are hardcoded, and changing a timeout requires recompiling the entire application.

The problem isn’t that Go lacks configuration tools—it’s that developers often take shortcuts. They start with a quick os.Getenv() call here, a hardcoded constant there, and before long, configuration logic is everywhere. When you need to add staging environments or run integration tests with different settings, you’re stuck refactoring half your codebase.

Here’s what bad configuration looks like:

func main() {
    db, err := sql.Open("postgres", "postgres://user:password@localhost:5432/mydb")
    client := &http.Client{Timeout: 30 * time.Second}
    // Configuration scattered throughout the code
}

And here’s the better approach:

type Config struct {
    DatabaseURL    string
    RequestTimeout time.Duration
}

func main() {
    cfg := loadConfig()
    db, err := sql.Open("postgres", cfg.DatabaseURL)
    client := &http.Client{Timeout: cfg.RequestTimeout}
}

Centralized configuration gives you a single source of truth, makes testing easier, and allows you to change behavior without touching application logic.

Environment Variables with os.Getenv

For simple applications, especially those running in containers, environment variables are often sufficient. The standard library’s os package provides everything you need.

The basic os.Getenv() returns an empty string if the variable doesn’t exist, which can hide configuration errors. Use os.LookupEnv() when you need to distinguish between an unset variable and an empty value:

package main

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

type Config struct {
    Port           int
    DatabaseURL    string
    RequestTimeout time.Duration
    Debug          bool
}

func loadFromEnv() (*Config, error) {
    cfg := &Config{
        Port:           8080, // defaults
        RequestTimeout: 30 * time.Second,
        Debug:          false,
    }

    if portStr, exists := os.LookupEnv("PORT"); exists {
        port, err := strconv.Atoi(portStr)
        if err != nil {
            return nil, fmt.Errorf("invalid PORT: %w", err)
        }
        cfg.Port = port
    }

    dbURL, exists := os.LookupEnv("DATABASE_URL")
    if !exists {
        return nil, fmt.Errorf("DATABASE_URL is required")
    }
    cfg.DatabaseURL = dbURL

    if timeoutStr, exists := os.LookupEnv("REQUEST_TIMEOUT"); exists {
        timeout, err := time.ParseDuration(timeoutStr)
        if err != nil {
            return nil, fmt.Errorf("invalid REQUEST_TIMEOUT: %w", err)
        }
        cfg.RequestTimeout = timeout
    }

    if debugStr, exists := os.LookupEnv("DEBUG"); exists {
        cfg.Debug = debugStr == "true" || debugStr == "1"
    }

    return cfg, nil
}

This approach works well for 12-factor apps and containerized deployments. However, manually parsing every variable gets tedious quickly, and you’re duplicating validation logic. That’s where structured configuration helps.

Structured Configuration with Structs and Tags

Rather than manually parsing environment variables, use struct tags to declaratively define your configuration. The envconfig library is lightweight and does exactly this:

package config

import (
    "fmt"
    "time"

    "github.com/kelseyhightower/envconfig"
)

type Config struct {
    Port           int           `envconfig:"PORT" default:"8080"`
    DatabaseURL    string        `envconfig:"DATABASE_URL" required:"true"`
    RequestTimeout time.Duration `envconfig:"REQUEST_TIMEOUT" default:"30s"`
    Debug          bool          `envconfig:"DEBUG" default:"false"`
    
    Redis RedisConfig
}

type RedisConfig struct {
    Host     string `envconfig:"REDIS_HOST" default:"localhost"`
    Port     int    `envconfig:"REDIS_PORT" default:"6379"`
    Password string `envconfig:"REDIS_PASSWORD"`
}

func Load() (*Config, error) {
    var cfg Config
    if err := envconfig.Process("", &cfg); err != nil {
        return nil, fmt.Errorf("failed to process config: %w", err)
    }
    
    if err := cfg.Validate(); err != nil {
        return nil, fmt.Errorf("invalid configuration: %w", err)
    }
    
    return &cfg, nil
}

func (c *Config) Validate() error {
    if c.Port < 1 || c.Port > 65535 {
        return fmt.Errorf("port must be between 1 and 65535")
    }
    if c.RequestTimeout < time.Second {
        return fmt.Errorf("request timeout must be at least 1 second")
    }
    return nil
}

This approach gives you type safety, automatic parsing, and clear documentation of what configuration your application expects. The struct tags make it obvious what environment variables are needed and their defaults.

Configuration Files (JSON, YAML, TOML)

Environment variables work great for deployment-specific settings, but configuration files are better for complex, hierarchical configuration that doesn’t change between environments. YAML is my preference for readability, though TOML and JSON work just as well.

Here’s a configuration file approach:

# config.yaml
server:
  port: 8080
  read_timeout: 30s
  write_timeout: 30s

database:
  url: postgres://localhost:5432/mydb
  max_connections: 25
  max_idle: 5

redis:
  host: localhost
  port: 6379

features:
  enable_caching: true
  enable_metrics: true

And the Go code to load it:

package config

import (
    "fmt"
    "os"
    "time"

    "gopkg.in/yaml.v3"
)

type FileConfig struct {
    Server   ServerConfig   `yaml:"server"`
    Database DatabaseConfig `yaml:"database"`
    Redis    RedisConfig    `yaml:"redis"`
    Features FeatureFlags   `yaml:"features"`
}

type ServerConfig struct {
    Port         int           `yaml:"port"`
    ReadTimeout  time.Duration `yaml:"read_timeout"`
    WriteTimeout time.Duration `yaml:"write_timeout"`
}

type DatabaseConfig struct {
    URL            string `yaml:"url"`
    MaxConnections int    `yaml:"max_connections"`
    MaxIdle        int    `yaml:"max_idle"`
}

type RedisConfig struct {
    Host     string `yaml:"host"`
    Port     int    `yaml:"port"`
    Password string `yaml:"password"`
}

type FeatureFlags struct {
    EnableCaching bool `yaml:"enable_caching"`
    EnableMetrics bool `yaml:"enable_metrics"`
}

func LoadFromFile(path string) (*FileConfig, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("failed to read config file: %w", err)
    }

    var cfg FileConfig
    if err := yaml.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("failed to parse config: %w", err)
    }

    return &cfg, nil
}

The key is handling file paths gracefully. Don’t assume the config file is always in the same location—allow it to be specified via environment variable or command-line flag.

Best Practices and Patterns

Never put secrets in configuration files checked into version control. Use environment variables for secrets, or better yet, integrate with secret management systems like HashiCorp Vault or cloud provider secret managers.

Always validate configuration at startup. Fail fast if something is misconfigured rather than discovering the problem when you try to connect to the database:

func (c *Config) Validate() error {
    var errs []error

    if c.Server.Port < 1 || c.Server.Port > 65535 {
        errs = append(errs, fmt.Errorf("invalid port: %d", c.Server.Port))
    }

    if c.Database.MaxConnections < 1 {
        errs = append(errs, fmt.Errorf("max_connections must be positive"))
    }

    if c.Database.MaxIdle > c.Database.MaxConnections {
        errs = append(errs, fmt.Errorf("max_idle cannot exceed max_connections"))
    }

    if len(errs) > 0 {
        return fmt.Errorf("configuration validation failed: %v", errs)
    }

    return nil
}

For testing, inject configuration rather than using a global singleton:

type Server struct {
    cfg *Config
    db  *sql.DB
}

func NewServer(cfg *Config) (*Server, error) {
    db, err := sql.Open("postgres", cfg.Database.URL)
    if err != nil {
        return nil, err
    }
    
    return &Server{cfg: cfg, db: db}, nil
}

// In tests:
func TestServer(t *testing.T) {
    testCfg := &Config{
        Database: DatabaseConfig{URL: "postgres://test"},
    }
    srv, err := NewServer(testCfg)
    // ...
}

Real-World Example: Multi-Environment Setup

Here’s a complete example that combines everything: defaults, file-based config, and environment variable overrides.

package config

import (
    "fmt"
    "os"
    "time"

    "github.com/kelseyhightower/envconfig"
    "gopkg.in/yaml.v3"
)

type Config struct {
    Environment string        `yaml:"environment" envconfig:"ENVIRONMENT" default:"development"`
    Server      ServerConfig  `yaml:"server"`
    Database    DatabaseConfig `yaml:"database"`
}

type ServerConfig struct {
    Port         int           `yaml:"port" envconfig:"PORT" default:"8080"`
    ReadTimeout  time.Duration `yaml:"read_timeout" envconfig:"READ_TIMEOUT" default:"30s"`
    WriteTimeout time.Duration `yaml:"write_timeout" envconfig:"WRITE_TIMEOUT" default:"30s"`
}

type DatabaseConfig struct {
    URL            string `yaml:"url" envconfig:"DATABASE_URL"`
    MaxConnections int    `yaml:"max_connections" envconfig:"DB_MAX_CONNECTIONS" default:"25"`
}

func Load() (*Config, error) {
    cfg := &Config{}

    // 1. Load defaults (handled by struct tags)
    
    // 2. Load from file if specified
    if configFile := os.Getenv("CONFIG_FILE"); configFile != "" {
        data, err := os.ReadFile(configFile)
        if err != nil {
            return nil, fmt.Errorf("failed to read config file: %w", err)
        }
        if err := yaml.Unmarshal(data, cfg); err != nil {
            return nil, fmt.Errorf("failed to parse config: %w", err)
        }
    }

    // 3. Override with environment variables
    if err := envconfig.Process("", cfg); err != nil {
        return nil, fmt.Errorf("failed to process environment: %w", err)
    }

    // 4. Validate
    if err := cfg.Validate(); err != nil {
        return nil, err
    }

    return cfg, nil
}

func (c *Config) Validate() error {
    if c.Database.URL == "" {
        return fmt.Errorf("DATABASE_URL is required")
    }
    if c.Server.Port < 1 || c.Server.Port > 65535 {
        return fmt.Errorf("invalid port: %d", c.Server.Port)
    }
    return nil
}

This layered approach gives you maximum flexibility. Developers can use a config.yaml file locally, staging can override specific values with environment variables, and production can use entirely environment-based configuration for security.

The key is making configuration explicit, validated, and easy to test. Your future self—and your team—will thank you when you need to debug why staging behaves differently than production.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.