Day 77 — Config layering
Day 77 — Config layering
Stage VIII · ~3h
Goal: Implement a layered configuration loader—defaults → file → environment → flags—with clear precedence, typed config structs, validation, and no secret-in-repo footguns.
Only the composition root (and a dedicated config package) should read env/flags. Lower layers receive values.
Why this day exists
Production processes need configuration. Ad-hoc os.Getenv sprinkled across packages causes:
- Undocumented knobs
- Different defaults in tests vs prod
- Secrets committed to git
- Flags that fight env vars
12-factor-ish discipline: config from environment (and files for local), validated at startup, injected into the composition root. Capstone days 83–88 will copy this pattern.
Theory 1 — Precedence model
Recommended order (later wins):
1. Code defaults
2. Config file (optional YAML/JSON/TOML)
3. Environment variables
4. CLI flags
Document this in README. Surprises here are outages.
type Config struct {
Addr string
DatabaseURL string
LogLevel string
ShutdownSec int
}Why flags win last
Local overrides for debugging (--addr :9090) should beat shell env without editing files. Deployments usually set env and pass no flags.
Theory 2 — Stdlib-first loading
func Load(args []string) (Config, error) {
cfg := Config{
Addr: ":8080",
LogLevel: "info",
ShutdownSec: 15,
}
// optional file
if path := os.Getenv("CONFIG_FILE"); path != "" {
if err := loadFile(path, &cfg); err != nil {
return Config{}, err
}
}
if v := os.Getenv("ADDR"); v != "" {
cfg.Addr = v
}
if v := os.Getenv("DATABASE_URL"); v != "" {
cfg.DatabaseURL = v
}
if v := os.Getenv("LOG_LEVEL"); v != "" {
cfg.LogLevel = v
}
if v := os.Getenv("SHUTDOWN_SEC"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return Config{}, fmt.Errorf("SHUTDOWN_SEC: %w", err)
}
cfg.ShutdownSec = n
}
fs := flag.NewFlagSet("service", flag.ContinueOnError)
fs.SetOutput(io.Discard) // or os.Stderr for user CLIs
addr := fs.String("addr", cfg.Addr, "listen address")
db := fs.String("database-url", cfg.DatabaseURL, "database URL")
logLevel := fs.String("log-level", cfg.LogLevel, "log level")
if err := fs.Parse(args); err != nil {
return Config{}, err
}
cfg.Addr = *addr
cfg.DatabaseURL = *db
cfg.LogLevel = *logLevel
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c Config) Validate() error {
if c.DatabaseURL == "" {
return errors.New("DATABASE_URL required")
}
if c.ShutdownSec < 1 {
return errors.New("shutdown sec must be >= 1")
}
switch strings.ToLower(c.LogLevel) {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("invalid log level %q", c.LogLevel)
}
return nil
}File format
JSON is enough without deps:
func loadFile(path string, cfg *Config) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(b, cfg)
}{
"addr": ":8080",
"log_level": "debug",
"shutdown_sec": 20
}YAML needs a library—fine if you already use one. Prefer one format per project.
Theory 3 — Secrets
| Do | Don’t |
|---|---|
| Env / secret manager | Commit .env with real secrets |
| Document var names | Log full DATABASE_URL |
Separate Config vs Secrets if helpful |
Put tokens in config files in images |
// redact when logging
func (c Config) LogValue() slog.Value {
return slog.GroupValue(
slog.String("addr", c.Addr),
slog.String("log_level", c.LogLevel),
slog.Int("shutdown_sec", c.ShutdownSec),
slog.String("database_url", redactDSN(c.DatabaseURL)),
)
}
func redactDSN(dsn string) string {
// naive: hide password between : and @
// improve for your DSN shape
if i := strings.Index(dsn, "://"); i >= 0 {
rest := dsn[i+3:]
if at := strings.Index(rest, "@"); at >= 0 {
return dsn[:i+3] + "***@" + rest[at+1:]
}
}
return "***"
}.env for local dev is OK if gitignored; provide .env.example with empty values.
# .env.example
DATABASE_URL=
ADDR=:8080
LOG_LEVEL=info
SHUTDOWN_SEC=15
CONFIG_FILE=
Theory 4 — Injection at the root
func main() {
cfg, err := config.Load(os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(2)
}
logger := newLogger(cfg.LogLevel)
logger.Info("starting", "cfg", cfg) // uses LogValue if slog.LogValuer
db := mustOpen(cfg.DatabaseURL)
// wire adapters with cfg fields — packages don't call os.Getenv
_ = db
}Rule: only config package (and main) reads env/flags. Lower layers receive values.
Why exit code 2
Convention: 2 for misuse/config; 1 for runtime failure. CLIs (Day 71) already care—services should too.
Theory 5 — Testing config
func TestLoadDefaults(t *testing.T) {
t.Setenv("DATABASE_URL", "sqlite://file.db")
t.Setenv("ADDR", "") // ensure not leaking from parent env if needed
cfg, err := Load(nil)
if err != nil {
t.Fatal(err)
}
if cfg.Addr != ":8080" {
t.Fatalf("addr %s", cfg.Addr)
}
}
func TestFlagBeatsEnv(t *testing.T) {
t.Setenv("DATABASE_URL", "sqlite://file.db")
t.Setenv("ADDR", ":9090")
cfg, err := Load([]string{"-addr", ":9091"})
if err != nil {
t.Fatal(err)
}
if cfg.Addr != ":9091" {
t.Fatalf("got %s", cfg.Addr)
}
}
func TestValidateMissingDB(t *testing.T) {
t.Setenv("DATABASE_URL", "")
_, err := Load(nil)
if err == nil {
t.Fatal("expected error")
}
}t.Setenv keeps tests hermetic (Go 1.17+).
Theory 6 — K8s / Docker alignment
| Surface | Maps to |
|---|---|
container env: |
process environment |
| ConfigMap file mount | CONFIG_FILE |
| Secret mount | env or file; never image layers |
| CLI debug | flags on command: |
Document one canonical name per knob (DATABASE_URL not sometimes DB_URL).
GOMEMLIMIT is process env that Go runtime reads—document alongside app config even if you do not parse it in Go.
Worked example — layered demo
export DATABASE_URL=postgres://localhost/app
export ADDR=:9090
go run ./cmd/service --addr :9091
# effective ADDR :9091 (flag wins)Document matrix in CONFIG.md:
| Key | Default | Env | Flag |
|---|---|---|---|
| addr | :8080 |
ADDR |
-addr |
| database | (none) | DATABASE_URL |
-database-url |
| log level | info |
LOG_LEVEL |
-log-level |
| shutdown | 15 |
SHUTDOWN_SEC |
-shutdown-sec |
Worked example — empty env must not clobber
// wrong: always assign
cfg.Addr = os.Getenv("ADDR") // empty string wipes file/default
// right: only override when set
if v := os.Getenv("ADDR"); v != "" {
cfg.Addr = v
}Lab
Suggested workspace: service composition root.
- Central
internal/config(orconfig) package.
- Precedence: defaults → file (optional) → env → flags.
Validate()fails fast on missing required values.
.env.example+ README table.
- Unit tests for precedence and validation errors.
- Redacted logging of secrets.
Stretch
GOMEMLIMITdocumented alongside app config.
- Feature flag file path field prepared for Day 79.
String()method that never prints secrets (for fmt debugging).
Common gotchas
| Gotcha | Fix |
|---|---|
| Env read deep in store package | Inject DSN from main |
| Empty string overwriting file values | Only override when env set |
flag.Parse global in library |
FlagSet in Load |
| Secrets in logs | Redact |
| Different names in K8s vs local | One canonical env name |
| Panic on bad config | Return error; main exits 2 |
| Bool flags defaulting wrong | Document true/false strings |
| Required vs optional confusion | Validate() is the source of truth |
Checkpoint
- Typed
Config+Load
- Documented precedence
- Validation at startup
- Tests for env/flag behavior
- Example env file without secrets
- Redaction on log path
Commit
git add .
git commit -m "day77: config layering flags env file"Write three personal gotchas before continuing.
Tomorrow
Day 78 — Graceful shutdown & health: production process lifecycle—SIGTERM, drain, liveness vs readiness.