open atlas
↑ Back to track
Go, zero to senior GO · 07 · 02

Configuration without magic: env, flags, files, and a typed Config that fails at boot

Parse configuration once at startup into a typed, eagerly validated Config: defaults, then file, then env, then flags. Fail at boot, not at 3am request time. Secrets get a redacting String method; hot reload is complexity most services should trade for a restart.

GO Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The deploy shipped Tuesday afternoon; the page came Saturday at 3:12 a.m. A cache client read its timeout lazily, deep inside the request path: os.Getenv("CACHE_TIMEOUT_MS"), strconv.Atoi, error ignored. Staging had the variable; production did not. Getenv returned an empty string, Atoi failed silently into zero, and in that client zero meant no timeout. For four days nothing happened — until a cache node died Saturday and every request began waiting on a dead TCP connection forever. Goroutines piled up at two thousand per second, memory followed, the OOM killer did the rest. The 3 a.m. fix was setting one env var. The real fix landed Monday: every config read moved to a Load() at boot that uses LookupEnv, parses into a typed struct, and returns an error on anything missing or malformed. The bad deploy would have failed its readiness probe Tuesday at 14:07, in front of an engineer with coffee, with the old pods still serving.

Three sources, one ladder

Why does configuration need a precedence ladder at all? Because flags, environment variables, and files each have a different owner — and when they conflict, you need a rule that is obvious enough to debug at 3 a.m. Production Go services pull configuration from three places, each with a real constituency. Environment variables are the 12-factor baseline: per-deployment, no rebuild, native to every container orchestrator — but they are flat strings, invisible until you print them, and inherited by every child process. Flags serve operators: -help is living documentation, and a one-off override at the terminal beats editing a manifest. Files carry complex shapes — nested structures, lists, per-tenant maps — that die a thousand deaths when encoded into env strings, and they go through code review. The resolution order is a precedence ladder, most deliberate wins:

Parse once, validate eagerly

The discipline that prevents the Hook incident has two halves. First: configuration is read in exactly one place, at startup, into a typed struct — no os.Getenv below main. Second: validation is eager and fatal. A service that boots with garbage config fails at request time, hours or days later, under traffic; a service that refuses to boot fails inside the deploy, where rollouts halt automatically and old pods keep serving.

type Config struct {
	HTTPAddr     string
	CacheTimeout time.Duration
	DatabaseURL  Secret // see below
}

func Load(args []string) (Config, error) {
	cfg := Config{HTTPAddr: ":8080", CacheTimeout: 50 * time.Millisecond} // defaults

	if v, ok := os.LookupEnv("CACHE_TIMEOUT_MS"); ok { // unset and empty are different
		ms, err := strconv.Atoi(v)
		if err != nil || ms <= 0 {
			return Config{}, fmt.Errorf("CACHE_TIMEOUT_MS=%q: want a positive integer", v)
		}
		cfg.CacheTimeout = time.Duration(ms) * time.Millisecond
	}

	fs := flag.NewFlagSet("api", flag.ContinueOnError)
	fs.StringVar(&cfg.HTTPAddr, "http", cfg.HTTPAddr, "listen address") // flag beats env
	if err := fs.Parse(args); err != nil {
		return Config{}, err
	}
	return cfg, cfg.validate() // required fields, ranges, URL syntax — all now
}

LookupEnv is the load-bearing detail: Getenv cannot distinguish unset from set to empty, which is exactly the ambiguity that turned a missing variable into a silent zero. Handlers receive Config — or just the sub-struct they need — through constructors, so the dependency is visible in signatures and trivial to fake in tests.

Quiz

A deploy ships with CACHE_TIMEOUT_MS=abc. The service uses the boot-time Load() above. What happens, and what would have happened with the old lazy Getenv-in-handler code?

Secrets do not belong in dumps

When you print a config struct — and you will, in a boot log or a crash report — every unprotected field is a leak vector. Config structs get printed: boot logs, crash reports, %+v in an error path, debug endpoints. Every one of those is a leak vector for the database password embedded in a DSN. Flags are worse — process arguments are world-readable in ps output on shared hosts. The Go-native containment is a named type whose formatting lies:

type Secret string

func (Secret) String() string       { return "[REDACTED]" }
func (Secret) LogValue() slog.Value { return slog.StringValue("[REDACTED]") }
func (s Secret) Reveal() string     { return string(s) } // explicit, greppable

// fmt %v, %s, %+v and slog all hit String/LogValue. json.Marshal does NOT —
// it reads the underlying string, so define MarshalJSON too if the struct
// can ever reach an encoder.

The Reveal() call is the point: every place the raw secret escapes is now a deliberate, searchable act instead of an accident of formatting. Reflection-based encoders bypass Stringer, so the honest version also implements MarshalJSON — teams discover this the day a support bundle with a serialized config lands in a ticket system.

Hot reload, honestly

File watching looks like an upgrade and is usually a downgrade. A restart applies config through the exact path you test on every deploy: parse, validate, refuse-or-serve. Reload creates a second path that runs rarely and under no test coverage: half-applied updates where some goroutines hold the old value, validation racing in-flight requests, fsnotify quirks around the symlink dance Kubernetes performs on ConfigMap updates, and a fleet where each pod picked up the change at a different moment. A rolling restart of a twenty-pod deployment costs a couple of minutes and zero new code paths. Reload earns its complexity in two places: fleets so large that restart storms cost real capacity, and values that change many times a day — which are feature flags, and belong in a flag service with explicit versioning and audit, not in a watched file.

Why this works

Why is boot failure so much cheaper than request failure? Because the deploy is the one moment a human is already watching and the system is built to roll back. A readiness probe failing on the new revision halts the rollout in about a minute with zero user traffic lost. The same bad value read lazily detonates whenever its code path first runs — Saturday, under load, with the deploy that caused it four days cold and an on-call engineer reconstructing history from dashboards.

Library config is a different problem

The typed-struct-at-boot pattern is for applications, which own main and can be exhaustive. Libraries face the opposite constraints: an exported API frozen by compatibility promises, callers who set two of fifteen knobs, and zero-value ambiguity — does Timeout: 0 mean “not set, use the default” or “deliberately no timeout”? Functional options resolve all three:

type Option func(*client)

func WithTimeout(d time.Duration) Option { return func(c *client) { c.timeout = d } }

func New(addr string, opts ...Option) (*Client, error) {
	c := &client{timeout: 5 * time.Second} // default lives here, unambiguous
	for _, o := range opts {
		o(c)
	}
	return c.validateAndBuild()
}

Unset means default because the option was never passed; new knobs never break existing call sites; validation runs at construction. Inside an application the same pattern is ceremony — a plain struct is greppable, exhaustive, and obvious.

Quiz

You maintain a published Go client library and need to add an optional knob. Why do functional options beat adding a field to an exported Config struct?

Recall before you leave
  1. 01
    Lay out the precedence ladder, where it gets resolved, and the two failure modes that parse-once-validate-eagerly eliminates.
  2. 02
    Why does a Secret type need three methods, and when do functional options actually earn their ceremony?
Recap

Configuration in production Go is a discipline of one moment: everything resolves at boot, nothing is read lazily. Three sources feed a precedence ladder — compiled defaults at the bottom, then the reviewed config file for complex shapes, then 12-factor environment variables per deployment, then flags as the operator’s documented override — and the ladder collapses into one typed Config inside one Load() function. LookupEnv, never Getenv, because unset and empty are different facts and conflating them is how a missing variable becomes a silent zero. Validation is eager and fatal: required fields, ranges, parseable URLs, all checked before the listener opens, so a malformed value fails the readiness probe during the rollout — a minute of halted deploy with old pods serving — instead of detonating at 3 a.m. when the bad code path finally runs. Secrets ride in a named type that redacts through String for fmt, LogValue for slog, and MarshalJSON for encoders that bypass both, with an explicit Reveal() marking every sanctioned escape; and they stay out of flags, which any ps can read. Hot reload is honestly evaluated complexity: a second, rarely-exercised application path with partial-update races and per-pod divergence, worth it only for restart-storm-scale fleets — while frequently-changing values are feature flags and belong in a flag service. Libraries flip the calculus: functional options give frozen APIs evolvable defaults and make unset unambiguous, which a config struct cannot. Now when you see a service behave differently in production than on staging, your first question is: which value won on the precedence ladder, and did Load() log where it came from?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.