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.