ADR-015: Configuration Management Strategy
Status
Section titled “Status”Accepted (production secrets amended by ADR-025; variable surface amended 2026-08-02 and 2026-09-30, see notes)
Amended 2026-08-02: the code and env samples in this ADR predate the adoption of
kelseyhightower/envconfig— the authoritative config surface isinternal/config/config.goand.env.example. The real variables areENV(notENVIRONMENT),HTTP_PORT(default4000, notPORT=8080),DATABASE_URL,SUPABASE_URL/SUPABASE_ANON_KEY(set both or neither — auth is disabled when both are empty), optionalSUPABASE_SERVICE_ROLE_KEYandMETRICS_TOKEN(there is no separate metrics port), and pool tuning viaDB_MAX_CONNS/DB_MIN_CONNS/DB_MAX_CONN_LIFETIME.JWT_SECRET,JWT_EXPIRY,ENABLE_CACHE, andCACHE_TTLdo not exist. Production env vars are set on the container host per ADR-025 (Fly.io worked example), not in Cloudflare. The §3 env samples and §5 production story below have been corrected; the §2 Go sample stands as the original decision illustration.
Amended 2026-09-30 (docs health audit; append-only per ADR-033): the 2026-08-02 list is incomplete.
internal/config/config.goalso readsDB_MAX_CONN_IDLE_TIME,MAX_REQUEST_BODY_BYTES,PUBLIC_BASE_URL,TRUSTED_PROXY_CIDRSandCLIENT_IP_HEADER(ADR-027),GUEST_MODE_ENABLED,GUEST_TTLandREAPER_INTERVAL(ADR-024);LOG_LEVELis read incmd/api/main.go;DEMO_MODEgates the Taskfile demo tasks and the nightly reset (ADR-031)..env.exampleis the canonical, complete list.JWT_SECRET,ENABLE_CACHEandMETRICS_PORTin the §2/§5 samples remain illustrative only.
Context
Section titled “Context”Applications require different configuration across environments (development, staging, production). Configuration must be secure, manageable, and follow the Twelve-Factor App principle of storing config in the environment. This ADR establishes patterns for configuration management, secrets handling, and environment-specific settings.
Decision
Section titled “Decision”1. Configuration Principles
Section titled “1. Configuration Principles”Follow Twelve-Factor App principles:
- Store config in environment variables, not in code
- Strict separation between code and config
- No hardcoded credentials or API keys
- Environment parity: Same code runs in all environments with different config
2. Configuration Structure
Section titled “2. Configuration Structure”package config
import ( "fmt" "os" "strconv" "time")
// Config holds all application configurationtype Config struct { // Server Port int Environment string // dev, staging, production
// Database DatabaseURL string MaxDBConns int DBTimeout time.Duration
// Authentication JWTSecret string JWTExpiry time.Duration
// External Services SupabaseURL string SupabaseKey string
// Performance EnableCache bool CacheTTL time.Duration
// Observability LogLevel string MetricsPort int}
// LoadConfig loads configuration from environment variablesfunc LoadConfig() (*Config, error) { cfg := &Config{ Port: getEnvInt("PORT", 8080), Environment: getEnv("ENVIRONMENT", "development"),
DatabaseURL: getEnv("DATABASE_URL", ""), MaxDBConns: getEnvInt("MAX_DB_CONNS", 25), DBTimeout: getEnvDuration("DB_TIMEOUT", 10*time.Second),
JWTSecret: getEnv("JWT_SECRET", ""), JWTExpiry: getEnvDuration("JWT_EXPIRY", 1*time.Hour),
SupabaseURL: getEnv("SUPABASE_URL", ""), SupabaseKey: getEnv("SUPABASE_ANON_KEY", ""),
EnableCache: getEnvBool("ENABLE_CACHE", true), CacheTTL: getEnvDuration("CACHE_TTL", 5*time.Minute),
LogLevel: getEnv("LOG_LEVEL", "info"), MetricsPort: getEnvInt("METRICS_PORT", 9090), }
// Validate required configuration if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("config validation: %w", err) }
return cfg, nil}
// Validate ensures required configuration is presentfunc (c *Config) Validate() error { if c.DatabaseURL == "" { return fmt.Errorf("DATABASE_URL is required") }
if c.JWTSecret == "" && c.Environment == "production" { return fmt.Errorf("JWT_SECRET is required in production") }
if c.SupabaseURL == "" { return fmt.Errorf("SUPABASE_URL is required") }
if c.SupabaseKey == "" { return fmt.Errorf("SUPABASE_ANON_KEY is required") }
return nil}
// IsProd returns true if running in productionfunc (c *Config) IsProd() bool { return c.Environment == "production"}
// IsDev returns true if running in developmentfunc (c *Config) IsDev() bool { return c.Environment == "development"}
// Helper functionsfunc getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue}
func getEnvInt(key string, defaultValue int) int { if value := os.Getenv(key); value != "" { if i, err := strconv.Atoi(value); err == nil { return i } } return defaultValue}
func getEnvBool(key string, defaultValue bool) bool { if value := os.Getenv(key); value != "" { if b, err := strconv.ParseBool(value); err == nil { return b } } return defaultValue}
func getEnvDuration(key string, defaultValue time.Duration) time.Duration { if value := os.Getenv(key); value != "" { if d, err := time.ParseDuration(value); err == nil { return d } } return defaultValue}3. Environment-Specific Configuration
Section titled “3. Environment-Specific Configuration”Development Environment (.env)
Section titled “Development Environment (.env)”# .env (local development only - never commit!)ENV=developmentHTTP_PORT=4000
# DatabaseDATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Supabase (set both or neither — auth is disabled when both are empty)SUPABASE_URL=https://your-project.supabase.coSUPABASE_ANON_KEY=your-anon-key
# Observability (read by the logger bootstrap in cmd/api/main.go)LOG_LEVEL=debugExample Configuration (.env.example)
Section titled “Example Configuration (.env.example)”# .env.example (committed to repository as template — the real file at the# repo root carries the full annotated surface)ENV=developmentHTTP_PORT=4000
# DatabaseDATABASE_URL=postgres://postgres:postgres@localhost:5432/alpine_saas?sslmode=disable
# Supabase (set both or neither — auth is disabled when both are empty)SUPABASE_URL=SUPABASE_ANON_KEY=
# Optional overrides (compiled-in defaults shown)# DB_MAX_CONNS=25# DB_MIN_CONNS=2# DB_MAX_CONN_LIFETIME=30m# METRICS_TOKEN=Production Environment (container host secrets — ADR-025)
Section titled “Production Environment (container host secrets — ADR-025)”# Secrets go in the container host's secret store (Fly.io worked example);# non-secret vars (ENV=production, HTTP_PORT) live in fly.toml [env].fly secrets set \ DATABASE_URL=<managed-db-connection-string> \ SUPABASE_URL=<production-supabase-url> \ SUPABASE_ANON_KEY=<production-key> \ METRICS_TOKEN=<bearer-token-gating-/metrics>4. Loading Configuration
Section titled “4. Loading Configuration”Application Startup
Section titled “Application Startup”package main
import ( "log" "github.com/joho/godotenv" "yourapp/internal/config")
func main() { // Load .env file in development only if err := godotenv.Load(); err != nil { log.Println("No .env file found, using environment variables") }
// Load and validate configuration cfg, err := config.LoadConfig() if err != nil { log.Fatalf("Failed to load config: %v", err) }
// Use configuration server := NewServer(cfg) server.Start()}5. Secrets Management
Section titled “5. Secrets Management”Development
Section titled “Development”- Use
.envfile with weak secrets (e.g.,JWT_SECRET=dev-secret) - NEVER commit
.envto version control - Rotate development secrets quarterly
Production
Section titled “Production”- Use the container host’s secret store (
fly secrets setin the ADR-025 worked example) or equivalent platform - Use secret rotation (every 90 days minimum)
- Use per-environment secrets (staging ≠ production)
- Consider secret management service (AWS Secrets Manager, HashiCorp Vault)
Secret Rotation Process
Section titled “Secret Rotation Process”- Generate new secret
- Deploy new secret to environment variables
- Deploy application with backward compatibility (accept old + new)
- Verify new secret works
- Remove old secret after grace period
- Update documentation
6. Configuration Testing
Section titled “6. Configuration Testing”package config
import ( "os" "testing")
func TestLoadConfig(t *testing.T) { // Set required environment variables os.Setenv("DATABASE_URL", "postgres://localhost/test") os.Setenv("JWT_SECRET", "test-secret") os.Setenv("SUPABASE_URL", "https://test.supabase.co") os.Setenv("SUPABASE_ANON_KEY", "test-key")
defer func() { os.Unsetenv("DATABASE_URL") os.Unsetenv("JWT_SECRET") os.Unsetenv("SUPABASE_URL") os.Unsetenv("SUPABASE_ANON_KEY") }()
cfg, err := LoadConfig() if err != nil { t.Fatalf("LoadConfig failed: %v", err) }
if cfg.DatabaseURL != "postgres://localhost/test" { t.Errorf("DatabaseURL = %s; want postgres://localhost/test", cfg.DatabaseURL) }}
func TestConfigValidation(t *testing.T) { tests := []struct { name string cfg *Config wantErr bool }{ { name: "valid config", cfg: &Config{ DatabaseURL: "postgres://localhost/db", SupabaseURL: "https://test.supabase.co", SupabaseKey: "key", JWTSecret: "secret", }, wantErr: false, }, { name: "missing database URL", cfg: &Config{ SupabaseURL: "https://test.supabase.co", SupabaseKey: "key", }, wantErr: true, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.cfg.Validate() if (err != nil) != tt.wantErr { t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) } }) }}7. Configuration Documentation
Section titled “7. Configuration Documentation”Maintain configuration documentation in:
.env.example: Template with all required variablesREADME.md: Setup instructions and configuration guide- ADRs: Architectural decisions about configuration strategy
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Environment Parity: Same code runs everywhere with different config
- Security: Secrets never committed to version control
- Flexibility: Easy to change configuration without code changes
- Testability: Configuration can be easily mocked in tests
- Twelve-Factor Compliance: Follows industry best practices
Negative
Section titled “Negative”- Environment Setup: Requires manual configuration of environment variables
- Discovery: Need documentation to know which variables are required
- Debugging: Misconfigured environments can be hard to diagnose
- Secret Leakage: Risk of accidentally committing
.envfile - Missing Variables: Application fails to start if required variables missing
- Drift: Development and production configs may drift over time
Alternatives Considered
Section titled “Alternatives Considered”1. Configuration Files (YAML/JSON)
Section titled “1. Configuration Files (YAML/JSON)”- Rejected: Files encourage committing secrets to repository
- Note: Config files appropriate for non-secret settings (if needed)
2. Hardcoded Configuration
Section titled “2. Hardcoded Configuration”- Rejected: Violates Twelve-Factor principles, creates security risks
3. Command-Line Flags
Section titled “3. Command-Line Flags”- Rejected: Flags don’t scale well for many configuration options
- Note: Flags acceptable for simple utilities, not web applications
Implementation Checklist
Section titled “Implementation Checklist”Amended 2026-09-30 (docs health audit; append-only per ADR-033): this checklist is the original plan, kept unticked as history; everything it lists shipped. Current status lives in the Enforcement section and the guides under
docs/guides/.
- Create
internal/config/config.gowith Config struct - Implement
LoadConfig()with environment variable parsing - Add
Validate()method to check required configuration - Create
.env.examplewith all configuration variables - Add
.envto.gitignore - Document configuration in README.md
- Add configuration tests
- Set up production environment variables on the container host (Fly.io per ADR-025)
- Implement secret rotation process
- Create configuration troubleshooting guide
References
Section titled “References”- The Twelve-Factor App: Config
- ADR-001: Foundation (Secret management strategy)
- godotenv
- ADR-025: Deployment Target (production env/secrets on the container host)
- Fly.io Secrets
Review Cadence
Section titled “Review Cadence”Review Date: 2026-05-15
Reviewed: 2026-09-30 (docs health audit — decision unchanged; next review 2027-03-31)
Date: 2025-11-15 Author: System Architecture Team
Enforcement
Section titled “Enforcement”- Testable consequences:
- TC-1: Environment reads (
os.Getenv/os.LookupEnv) happen only ininternal/config(allowlisted exception: thecmd/api/main.gobootstrap, which loads dotenv and the logger before config exists). - TC-2:
.envis gitignored;.env.exampleexists at the repo root.
- TC-1: Environment reads (
- Checks:
- TC-1, TC-2 →
adr015-env-only-configinscripts/adrcheck(status: block, promoted 2026-09-30)
- TC-1, TC-2 →
- Not machine-checkable: Environment parity is deployment discipline. A secret that matches none of the scanner’s shapes (TC-3) is still review territory.
- Amendment 2026-08-19 (#91): TC-3 — no credential-shaped literal in shipped source/config (
cmd/,internal/,scripts/,sql/,migrations/,.github/, Taskfile/fly.toml/Dockerfile/docker-compose/.env.example/.env.tpl/app.js; generated, vendored and docs files out of scope): AWS/GitHub/Slack/Google/Stripe key shapes, private-key blocks and JWT literals everywhere incl. tests; secret-named assignments to opaque ≥20-char literals outside tests, placeholders excused →adr015-no-hardcoded-secretsinscripts/adrcheck(status: block since 2026-09-30, added 2026-08-19). Closes the ADR-033 TODO; gitleaks remains the escalation if the graduation log shows these shapes miss too much. - Graduation log:
- 2026-09-30 — promoted warn → block (ADR-033 §4): clean since 2026-07-12 (80 days, zero false positives; the rule asks for 7).
checks/enforcement.config.jsoncarriesgraduated: 2026-09-30. Demotion back to warn is allowed with the same trail. - 2026-09-30 —
adr015-no-hardcoded-secretspromoted warn → block (ADR-033 §4): clean since 2026-08-19 (42 days, zero false positives; the rule asks for 7).checks/enforcement.config.jsoncarriesgraduated: 2026-09-30. Demotion back to warn is allowed with the same trail.
- 2026-09-30 — promoted warn → block (ADR-033 §4): clean since 2026-07-12 (80 days, zero false positives; the rule asks for 7).