Manage Configuration
Problem
Hardcoded configuration makes applications inflexible across environments. Changing database URLs or API keys requires recompilation. Different environments (development, staging, production) need different values without code changes. Managing configuration securely while avoiding secrets in version control is challenging.
This guide shows effective configuration management in Go.
Environment Variables
Reading Environment Variables
import "os"
// ✅ Read environment variable
func GetDatabaseURL() string {
return os.Getenv("DATABASE_URL")
}
// ✅ With default value
func GetPort() string {
port := os.Getenv("PORT")
if port == "" {
return "8080"
}
return port
}
// ✅ Required environment variable
func GetRequiredEnv(key string) (string, error) {
value := os.Getenv(key)
if value == "" {
return "", fmt.Errorf("required environment variable not set: %s", key)
}
return value, nil
}
// ✅ Parse as integer
func GetMaxConnections() (int, error) {
value := os.Getenv("MAX_CONNECTIONS")
if value == "" {
return 10, nil // Default
}
n, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("invalid MAX_CONNECTIONS: %w", err)
}
return n, nil
}Environment Variable Patterns
// ✅ Configuration struct
type Config struct {
DatabaseURL string
Port int
Debug bool
MaxConnections int
APIKey string
}
func LoadConfig() (*Config, error) {
config := &Config{}
// Required fields
var err error
config.DatabaseURL, err = GetRequiredEnv("DATABASE_URL")
if err != nil {
return nil, err
}
config.APIKey, err = GetRequiredEnv("API_KEY")
if err != nil {
return nil, err
}
// Optional with defaults
config.Port = getEnvAsInt("PORT", 8080)
config.MaxConnections = getEnvAsInt("MAX_CONNECTIONS", 10)
config.Debug = getEnvAsBool("DEBUG", false)
return config, nil
}
func getEnvAsInt(key string, defaultVal int) int {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultVal
}
value, err := strconv.Atoi(valueStr)
if err != nil {
return defaultVal
}
return value
}
func getEnvAsBool(key string, defaultVal bool) bool {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultVal
}
value, err := strconv.ParseBool(valueStr)
if err != nil {
return defaultVal
}
return value
}.env Files with godotenv
Loading .env Files
import "github.com/joho/godotenv"
// ✅ Load .env file
func main() {
// Load .env file in development
if err := godotenv.Load(); err != nil {
log.Println("No .env file found") // Not fatal in production
}
config, err := LoadConfig()
if err != nil {
log.Fatal(err)
}
runApp(config)
}
// ✅ Load specific file
func loadDevConfig() error {
return godotenv.Load(".env.development")
}
// ✅ Load multiple files (first found wins)
func loadEnvFiles() error {
return godotenv.Load(".env.local", ".env")
}.env file format:
DATABASE_URL=postgresql://localhost:5432/mydb
DB_MAX_CONNECTIONS=20
API_KEY=sk_test_abc123
API_BASE_URL=https://api.example.com
PORT=8080
DEBUG=true
LOG_LEVEL=info.gitignore:
.env
.env.local
.env.*.local
!.env.example.env.example (committed to repo):
DATABASE_URL=
DB_MAX_CONNECTIONS=20
API_KEY=
PORT=8080
DEBUG=falseFlag Package for Command-Line Args
Basic Flags
import "flag"
func main() {
// ✅ Define flags
port := flag.Int("port", 8080, "Server port")
host := flag.String("host", "localhost", "Server host")
debug := flag.Bool("debug", false, "Enable debug mode")
config := flag.String("config", "", "Config file path")
// Parse flags
flag.Parse()
fmt.Printf("Starting server on %s:%d\n", *host, *port)
fmt.Printf("Debug mode: %v\n", *debug)
if *config != "" {
fmt.Printf("Using config file: %s\n", *config)
}
}Usage:
./app
./app -port 3000 -debug
./app --port=3000 --debug=true --config=/etc/app/config.yamlFlag Variables
func main() {
var config struct {
Port int
Host string
Debug bool
}
// ✅ Bind flags to struct fields
flag.IntVar(&config.Port, "port", 8080, "Server port")
flag.StringVar(&config.Host, "host", "localhost", "Server host")
flag.BoolVar(&config.Debug, "debug", false, "Enable debug mode")
flag.Parse()
// Use config directly
fmt.Printf("Server: %s:%d\n", config.Host, config.Port)
}Viper for Advanced Configuration
Setting Up Viper
import "github.com/spf13/viper"
// ✅ Configure viper
func InitConfig() error {
// Set config file name and paths
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("$HOME/.myapp")
viper.AddConfigPath("/etc/myapp")
// Environment variables
viper.AutomaticEnv()
viper.SetEnvPrefix("MYAPP") // MYAPP_DATABASE_URL
// Set defaults
viper.SetDefault("port", 8080)
viper.SetDefault("database.max_connections", 10)
// Read config file
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// Config file not found; using defaults and env vars
log.Println("No config file found, using defaults")
} else {
return fmt.Errorf("reading config: %w", err)
}
}
return nil
}
func main() {
if err := InitConfig(); err != nil {
log.Fatal(err)
}
// Access values
port := viper.GetInt("port")
dbURL := viper.GetString("database.url")
debug := viper.GetBool("debug")
fmt.Printf("Port: %d\n", port)
fmt.Printf("DB URL: %s\n", dbURL)
}config.yaml Format
port: 8080
debug: false
log_level: info
database:
url: postgresql://localhost:5432/mydb
max_connections: 20
timeout: 30s
api:
base_url: https://api.example.com
key: ${API_KEY} # From environment variable
timeout: 10s
features:
new_ui: true
beta_features: falseUnmarshal to Struct
type Config struct {
Port int `mapstructure:"port"`
Debug bool `mapstructure:"debug"`
LogLevel string `mapstructure:"log_level"`
Database DatabaseConfig `mapstructure:"database"`
API APIConfig `mapstructure:"api"`
}
type DatabaseConfig struct {
URL string `mapstructure:"url"`
MaxConnections int `mapstructure:"max_connections"`
Timeout string `mapstructure:"timeout"`
}
type APIConfig struct {
BaseURL string `mapstructure:"base_url"`
Key string `mapstructure:"key"`
Timeout string `mapstructure:"timeout"`
}
// ✅ Unmarshal config to struct
func LoadConfigStruct() (*Config, error) {
if err := InitConfig(); err != nil {
return nil, err
}
var config Config
if err := viper.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("unmarshaling config: %w", err)
}
return &config, nil
}
func main() {
config, err := LoadConfigStruct()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Port: %d\n", config.Port)
fmt.Printf("DB URL: %s\n", config.Database.URL)
}Configuration Priority
Layered Configuration
// ✅ Priority order (highest to lowest):
// 1. Command-line flags
// 2. Environment variables
// 3. Config file
// 4. Default values
func SetupConfig() (*Config, error) {
// 1. Set defaults
viper.SetDefault("port", 8080)
viper.SetDefault("host", "localhost")
// 2. Read config file
viper.SetConfigName("config")
viper.AddConfigPath(".")
viper.ReadInConfig() // Ignore error if not found
// 3. Bind environment variables
viper.AutomaticEnv()
// 4. Bind command-line flags
flag.Int("port", 8080, "Server port")
flag.String("host", "localhost", "Server host")
flag.Parse()
viper.BindPFlags(flag.CommandLine)
var config Config
if err := viper.Unmarshal(&config); err != nil {
return nil, err
}
return &config, nil
}Validation
Config Validation
// ✅ Validate configuration
func (c *Config) Validate() error {
if c.Port < 1024 || c.Port > 65535 {
return fmt.Errorf("port must be between 1024 and 65535, got %d", c.Port)
}
if c.Database.URL == "" {
return fmt.Errorf("database URL is required")
}
if c.Database.MaxConnections < 1 {
return fmt.Errorf("max connections must be at least 1")
}
if c.API.Key == "" {
return fmt.Errorf("API key is required")
}
return nil
}
func main() {
config, err := LoadConfigStruct()
if err != nil {
log.Fatal(err)
}
if err := config.Validate(); err != nil {
log.Fatalf("Invalid configuration: %v", err)
}
runApp(config)
}Security Best Practices
Avoiding Secrets in Code
// ❌ Hardcoded secrets
const (
APIKey = "sk_live_secret123" // NEVER do this
DBPassword = "mypassword" // NEVER do this
)
// ✅ Load from environment
func GetAPIKey() (string, error) {
key := os.Getenv("API_KEY")
if key == "" {
return "", errors.New("API_KEY not set")
}
return key, nil
}
// ✅ Fail fast on missing secrets
func MustGetSecret(key string) string {
value := os.Getenv(key)
if value == "" {
log.Fatalf("Required secret not set: %s", key)
}
return value
}Secret Management
// ✅ Load secrets from secret manager (AWS Secrets Manager example)
import "github.com/aws/aws-sdk-go/service/secretsmanager"
func GetSecretFromAWS(secretName string) (string, error) {
svc := secretsmanager.New(session.New())
input := &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretName),
}
result, err := svc.GetSecretValue(input)
if err != nil {
return "", fmt.Errorf("getting secret: %w", err)
}
return *result.SecretString, nil
}
// ✅ Cache secrets
type SecretCache struct {
secrets map[string]string
mu sync.RWMutex
}
func (c *SecretCache) Get(key string) (string, error) {
c.mu.RLock()
value, ok := c.secrets[key]
c.mu.RUnlock()
if ok {
return value, nil
}
// Fetch from secret manager
value, err := GetSecretFromAWS(key)
if err != nil {
return "", err
}
// Cache it
c.mu.Lock()
c.secrets[key] = value
c.mu.Unlock()
return value, nil
}Hot Reloading Configuration
Watch Config Changes
// ✅ Watch for config file changes
func WatchConfig(callback func()) {
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
log.Println("Config file changed:", e.Name)
callback()
})
}
func main() {
if err := InitConfig(); err != nil {
log.Fatal(err)
}
config, _ := LoadConfigStruct()
// Reload config on file change
WatchConfig(func() {
newConfig, err := LoadConfigStruct()
if err != nil {
log.Println("Error reloading config:", err)
return
}
// Update application config
updateAppConfig(newConfig)
log.Println("Configuration reloaded")
})
runApp(config)
}How It Works
Configuration Loading Pipeline
Go configuration follows a layered approach with priority resolution:
- Default Values: Hardcoded fallbacks in application code
- Config Files: YAML/JSON/TOML loaded from filesystem
- Environment Variables: OS-level configuration (12-factor apps)
- Command-Line Flags: Runtime overrides via CLI arguments
- Merge and Resolve: Higher priority sources override lower ones
Environment Variable Resolution
os.Getenv() reads from process environment:
- Queries OS environment table (inherited from parent process)
- Returns empty string if variable not set (no error)
- Case-sensitive on Unix-like systems
- Available at any point in program execution
- No parsing - always returns string
Viper Configuration Cascade
Viper merges multiple configuration sources:
defaults (SetDefault)
↓
config files (ReadInConfig)
↓ overrides
environment variables (AutomaticEnv)
↓ overrides
command-line flags (BindPFlags)
↓ overrides
explicit Set calls (highest priority)Each layer overrides values from layers above it.
godotenv File Parsing
godotenv loads .env files into environment:
- Read File: Parse
.envline by line - Parse Lines: Extract
KEY=valuepairs - Set Environment: Call
os.Setenv()for each pair - Skip Existing: Does NOT override existing env vars
- Comments: Ignore lines starting with
#
Important: godotenv sets actual environment variables, affecting os.Getenv() globally.
Flag Package Parsing
flag package provides CLI argument parsing:
- Definition Phase:
flag.String(),flag.Int()register flags - Parse Phase:
flag.Parse()readsos.Args - Access Phase: Dereference flag pointers to get values
- Type Safety: Automatic type conversion and validation
- Help Text:
-hor-helpshows usage automatically
Variations
1. Multiple Environment Files
Load different .env files per environment:
func LoadEnvByEnvironment() error {
env := os.Getenv("GO_ENV")
if env == "" {
env = "development"
}
// Load base .env
if err := godotenv.Load(".env"); err != nil {
log.Println("No .env file")
}
// Load environment-specific file (overrides base)
envFile := fmt.Sprintf(".env.%s", env)
if err := godotenv.Load(envFile); err != nil {
log.Printf("No %s file\n", envFile)
}
return nil
}
// File structure:
// .env (shared defaults)
// .env.development (dev overrides)
// .env.production (prod overrides)
// .env.test (test overrides)Trade-offs: Flexible per-environment config but more files to manage.
2. Configuration as Code
Use Go code for complex configuration logic:
type Config struct {
Environment string
Database DatabaseConfig
Cache CacheConfig
}
func NewConfig() *Config {
env := os.Getenv("GO_ENV")
if env == "" {
env = "development"
}
config := &Config{Environment: env}
switch env {
case "production":
config.Database = DatabaseConfig{
Host: os.Getenv("DB_HOST"),
MaxConnections: 100,
SSLMode: "require",
}
config.Cache = CacheConfig{
Enabled: true,
TTL: time.Hour,
}
case "development":
config.Database = DatabaseConfig{
Host: "localhost",
MaxConnections: 10,
SSLMode: "disable",
}
config.Cache = CacheConfig{
Enabled: false,
}
}
return config
}Trade-offs: Type-safe and flexible but requires recompilation for changes.
3. Remote Configuration with etcd/Consul
Fetch configuration from distributed key-value stores:
import "github.com/hashicorp/consul/api"
func LoadConfigFromConsul() (*Config, error) {
client, err := api.NewClient(api.DefaultConfig())
if err != nil {
return nil, err
}
kv := client.KV()
// Get configuration values
pair, _, err := kv.Get("app/config/database_url", nil)
if err != nil {
return nil, err
}
config := &Config{
DatabaseURL: string(pair.Value),
}
return config, nil
}Trade-offs: Centralized config and dynamic updates but adds infrastructure dependency.
4. Configuration Providers Pattern
Abstract configuration source behind interface:
type ConfigProvider interface {
GetString(key string) string
GetInt(key string) int
GetBool(key string) bool
}
// Environment provider
type EnvProvider struct{}
func (p *EnvProvider) GetString(key string) string {
return os.Getenv(key)
}
// Viper provider
type ViperProvider struct{}
func (p *ViperProvider) GetString(key string) string {
return viper.GetString(key)
}
// Use in application
type App struct {
config ConfigProvider
}
func (app *App) Start() {
dbURL := app.config.GetString("DATABASE_URL")
// Use dbURL...
}Trade-offs: Decouples from specific config library but adds abstraction layer.
5. Typed Configuration with Struct Tags
Use struct tags for validation and transformation:
import "github.com/kelseyhightower/envconfig"
type Config struct {
Port int `envconfig:"PORT" default:"8080"`
Host string `envconfig:"HOST" default:"localhost"`
Debug bool `envconfig:"DEBUG" default:"false"`
DBMaxConn int `envconfig:"DB_MAX_CONN" required:"true"`
}
func LoadTypedConfig() (*Config, error) {
var config Config
err := envconfig.Process("", &config)
if err != nil {
return nil, err
}
return &config, nil
}Trade-offs: Declarative and type-safe but limited to environment variables.
Common Pitfalls
1. Hardcoding Secrets
Problem: Secrets committed to version control:
// Bad: Secret in code
const APIKey = "sk_live_1234567890abcdef"
func callAPI() {
client := api.NewClient(APIKey) // Secret exposed!
}Solution: Always load secrets from environment:
// Good: Secret from environment
func callAPI() error {
apiKey := os.Getenv("API_KEY")
if apiKey == "" {
return errors.New("API_KEY not set")
}
client := api.NewClient(apiKey)
return nil
}
// Even better: Fail fast at startup
func MustGetAPIKey() string {
apiKey := os.Getenv("API_KEY")
if apiKey == "" {
log.Fatal("API_KEY environment variable required")
}
return apiKey
}2. Not Validating Configuration
Problem: Invalid configuration causes runtime failures:
// Bad: No validation
func main() {
port := os.Getenv("PORT")
// What if PORT="invalid"? Runtime panic!
portNum, _ := strconv.Atoi(port)
server.Start(portNum)
}Solution: Validate early at startup:
// Good: Validate and fail fast
func LoadConfig() (*Config, error) {
portStr := os.Getenv("PORT")
if portStr == "" {
return nil, errors.New("PORT required")
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("PORT must be integer: %w", err)
}
if port < 1024 || port > 65535 {
return nil, fmt.Errorf("PORT must be 1024-65535, got %d", port)
}
return &Config{Port: port}, nil
}
func main() {
config, err := LoadConfig()
if err != nil {
log.Fatalf("Configuration error: %v", err)
}
server.Start(config.Port)
}3. Ignoring Config File Errors
Problem: Silently ignoring missing or invalid config files:
// Bad: Ignoring errors
viper.ReadInConfig() // Might fail silently
config := viper.GetString("database.url") // Returns empty string on error!Solution: Handle config file errors explicitly:
// Good: Explicit error handling
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
log.Println("Config file not found, using defaults")
} else {
log.Fatalf("Error reading config file: %v", err)
}
}
dbURL := viper.GetString("database.url")
if dbURL == "" {
log.Fatal("database.url not configured")
}4. Not Setting Defaults
Problem: Application breaks when optional config missing:
// Bad: No defaults
timeout := os.Getenv("TIMEOUT")
timeoutDuration, _ := time.ParseDuration(timeout) // Zero value if empty!
// timeoutDuration is 0, requests never timeoutSolution: Always provide sensible defaults:
// Good: Defaults for optional config
func GetTimeout() time.Duration {
timeoutStr := os.Getenv("TIMEOUT")
if timeoutStr == "" {
return 30 * time.Second // Default
}
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
log.Printf("Invalid TIMEOUT, using default: %v", err)
return 30 * time.Second
}
return timeout
}5. Mutating Configuration at Runtime
Problem: Changing configuration during execution leads to race conditions:
// Bad: Mutable global config
var GlobalConfig = &Config{}
func UpdateConfig(newPort int) {
GlobalConfig.Port = newPort // Race condition!
}
func HandleRequest() {
port := GlobalConfig.Port // Might read mid-update
}Solution: Make configuration immutable or use proper synchronization:
// Good: Immutable config
func main() {
config := LoadConfig() // Load once
server := NewServer(config) // Pass to components
server.Start()
}
// Better: If config must update, use atomic operations
type AtomicConfig struct {
value atomic.Value // stores *Config
}
func (ac *AtomicConfig) Load() *Config {
return ac.value.Load().(*Config)
}
func (ac *AtomicConfig) Store(config *Config) {
ac.value.Store(config)
}6. Mixing Configuration Concerns
Problem: Business logic mixed with configuration loading:
// Bad: Logic in config loading
func LoadConfig() *Config {
port, _ := strconv.Atoi(os.Getenv("PORT"))
// Business logic doesn't belong here!
if port == 8080 {
setupDevMode()
} else {
setupProdMode()
}
return &Config{Port: port}
}Solution: Separate configuration from application logic:
// Good: Pure configuration loading
func LoadConfig() (*Config, error) {
port, err := getEnvAsInt("PORT", 8080)
if err != nil {
return nil, err
}
return &Config{Port: port}, nil
}
// Business logic elsewhere
func main() {
config, err := LoadConfig()
if err != nil {
log.Fatal(err)
}
// Application logic based on config
if config.Port == 8080 {
setupDevMode()
} else {
setupProdMode()
}
runApp(config)
}Related Patterns
Related Tutorial: See Beginner Tutorial - Configuration for configuration basics.
Related How-To: See Handle Files and Resources for config file reading, Build CLI Applications for flag parsing, Handle Errors Effectively for config validation errors.
Related Cookbook: See Cookbook recipes “Environment Variables”, “Viper Configuration”, “Configuration Validation” for ready-to-use config patterns.
Related Explanation: See Best Practices for configuration principles.