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.
Comments