Skip to content

Day 152 — Secrets & 12-factor config

Month 6 · Week 2 · ⬅ Day 151 · Day 153 ➡ · Journal index

🎯 Learning Objective

Apply the 12-factor config discipline in Go: read all config from the environment, fail fast on invalid values, source secrets from files (not inline env), and never log them.

📚 Topics

  • 12-factor III: strict separation of config from code
  • os.LookupEnv, typed parsing, accumulate-and-join validation
  • The *_FILE secret pattern; redaction; never committing secrets

📖 Reading / Sources

📝 Notes

  • Factor III: config that varies between deploys lives in the environment, not in code or a committed file. The same binary then runs in dev/staging/prod by swapping env → [[12-factor]].
  • Parse env into a typed Config struct once at startup; the rest of the program depends on the struct, never scattered os.Getenv calls → [[config-struct]].
  • Use os.LookupEnv, not os.Getenv, when you must distinguish "unset" from "empty". Getenv returns "" for both; LookupEnv returns the ok boolean.
  • Fail fast and accumulate. Validate every field, collect problems, and return them together with errors.Join. A misconfigured deploy then reports all issues at once instead of one-restart-at-a-time → [[fail-fast]].
  • Secrets are not config. Inject them as files (<NAME>_FILE → path), because env vars leak via docker inspect, /proc/<pid>/environ, child processes, and crash dumps → [[secrets]]. Docker/K8s mount secrets at paths like /run/secrets/db_password.
  • Never log a secret. Redact (mask all but a few chars) before printing, and keep secrets out of error messages and structured-log fields. Pair with slog's ReplaceAttr to drop sensitive keys.
  • Never commit secrets. Use .gitignore, scanning (gitleaks), and rotate anything that lands in history.

💻 Code Examples

// Load builds a typed Config from the environment and reports EVERY problem.
func Load() (Config, error) {
    var problems []error
    cfg := Config{}

    port := getenv("PORT", "8080") // helper: LookupEnv with a fallback
    if n, err := strconv.Atoi(port); err != nil {
        problems = append(problems, fmt.Errorf("PORT %q not an int: %w", port, err))
    } else {
        cfg.Port = n
    }
    if cfg.DatabaseURL = getenv("DATABASE_URL", ""); cfg.DatabaseURL == "" {
        problems = append(problems, errors.New("DATABASE_URL is required"))
    }
    return cfg, errors.Join(problems...) // one error wrapping all causes
}

Full code: examples/month-06/config/main.go · Run: go run ./examples/month-06/config

Secret resolution (*_FILE beats env) + redaction: examples/month-06/secrets/main.go · Run: go run ./examples/month-06/secrets

🏋️ Exercises / Practice

Exercise Status Link
Typed env config with accumulate-and-join validation exercises/month-06/week-2/envconfig
*_FILE-first secret resolver + redaction exercises/month-06/week-2/secretsource

🐛 Mistakes Made

  • Used os.Getenv("FEATURE_X") and couldn't tell "unset" from "explicitly empty". Switched to os.LookupEnv to get the ok flag.
  • Returned the first config error and stopped; ops had to fix-and-restart five times. Switched to errors.Join to surface all problems at once.

❓ Open Questions

  • For real secret stores (Vault, AWS Secrets Manager), is the *_FILE pattern still the integration point? (Often yes — a sidecar/CSI driver mounts the secret as a file the app reads unchanged.)

🧠 Active Recall (answer without looking)

  1. Q: Why prefer mounting a secret as a file over passing it in an environment variable?
    A

Env vars leak: they appear in docker inspect, /proc/<pid>/environ, are inherited by child processes, and can land in crash dumps. A file (mode 0600) is read only by the process and isn't broadcast through the environment. 2. Q: What does errors.Join give you in a config loader, and how do callers still inspect causes?

A

It combines many validation errors into one value (so you report all problems at once). errors.Is/errors.As still match any of the joined causes, since Join builds an error whose Unwrap() []error exposes them.

🪶 Feynman Reflection

12-factor config is "build once, configure per-deploy." The binary knows how to run; the environment tells it where (DB, ports) and the mounted files tell it the secrets. Validate it all at the door, shout about everything that's wrong, and never write a secret into a log.

🕳️ Knowledge Gaps

  • Hot-reloading config (SIGHUP, file-watch) vs restart-to-reload — touches [[day-153]] (signals).

✅ Summary

I can load a typed config from the environment, fail fast with joined errors, source secrets from files via the *_FILE pattern, and redact them so they never hit the logs.

⏭️ Next Steps / Prep for Tomorrow

  • Day 153: handle SIGINT/SIGTERM and shut the server down gracefully.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦🟦⬜

Suggested commit: docs(journal): secrets & 12-factor config (day 152)