One Struct to Configure Them All

Reading a service's configuration from environment variables, flags, and files, folding them into a single typed Config validated once at startup, with flags-over-env-over-file precedence and a fail-fast on a missing required value. Compiled and run against Go 1.26.5.

The service so far has its listen address, timeouts, and log level baked into the code. That’s fine until you need to run the same binary in three environments with three different settings — at which point recompiling per environment is absurd, and configuration becomes its own small design problem. Go doesn’t hand you a config framework, and it doesn’t need to: the standard library reads environment variables, parses flags, and decodes files, and the good pattern folds all three into one typed struct, validated once, at startup. This chapter builds that loader and runs it. Every line of output below was compiled and run against Go 1.26.5.

Getenv, LookupEnv, and the empty-string trap

The environment is where a twelve-factor service gets most of its config, and Go reads it two ways that are not interchangeable. os.Getenv returns the value, or the empty string if the variable is unset. os.LookupEnv returns the value and a boolean telling you whether it was actually set. That second return exists for a real reason — it distinguishes “unset” from “set to the empty string,” which Getenv flattens into the same "":

// PORT is not set in the environment.
fmt.Printf("Getenv(PORT)    = %q\n", os.Getenv("PORT"))
v, ok := os.LookupEnv("PORT")
fmt.Printf("LookupEnv(PORT) = %q, ok=%v\n", v, ok)

os.Setenv("PORT", "") // now present, but empty
fmt.Printf("Getenv(PORT)    = %q\n", os.Getenv("PORT"))
v, ok = os.LookupEnv("PORT")
fmt.Printf("LookupEnv(PORT) = %q, ok=%v\n", v, ok)
Getenv(PORT)    = ""
LookupEnv(PORT) = "", ok=false
Getenv(PORT)    = ""
LookupEnv(PORT) = "", ok=true

Both variables print "" from Getenv, and only LookupEnv tells them apart — ok=false when unset, ok=true when present-but-empty. This matters for precedence more than it first looks. “The user didn’t set APP_ADDR, so keep the default” and “the user explicitly set APP_ADDR='', which is a mistake I should reject” are different situations, and Getenv alone cannot see the difference. Use LookupEnv whenever a missing value and an empty value should behave differently — which, in a config loader, is nearly always.

The shape of the goal

Everything funnels into one struct. Config that lives in a typed struct is config the rest of the program reads by field, with the compiler checking the field names — instead of scattering os.Getenv calls through the codebase and re-parsing the same string in five places, each free to disagree with the others.

type Config struct {
	Addr     string        `json:"addr"`
	Timeout  time.Duration `json:"timeout"`
	LogLevel string        `json:"log_level"`
	DBPass   string        `json:"-"` // secret: env only, never a file
}

The json:"-" on DBPass is deliberate and load-bearing — it means the field is never read from or written to a config file. Secrets belong in the environment, injected by whatever runs the process, and they must never sit in a file you might accidentally commit. Tagging the field out of the JSON path enforces that at the type level, so a future teammate can’t quietly add it to the config file and get away with it. The rule is old and absolute: credentials come from the environment, and the repository never sees them.

Precedence: default, then file, then env, then flag

A config loader is a stack of sources, each overriding the one below. The conventional order, lowest to highest, is built-in default → config file → environment variable → command-line flag. Flags win because they’re the most explicit and immediate thing an operator can do; the default loses to everything because it’s only there so the service boots at all. The loader applies them in exactly that order:

func Load(args []string) (Config, error) {
	// 1. defaults
	cfg := Config{Addr: ":8080", Timeout: 15 * time.Second, LogLevel: "info"}

	// 2. file overrides defaults, if CONFIG_FILE points at one
	if path, ok := os.LookupEnv("CONFIG_FILE"); ok {
		b, err := os.ReadFile(path)
		if err != nil {
			return Config{}, fmt.Errorf("reading config file: %w", err)
		}
		var fc fileConfig
		if err := json.Unmarshal(b, &fc); err != nil {
			return Config{}, fmt.Errorf("parsing config file: %w", err)
		}
		if fc.Addr != "" {
			cfg.Addr = fc.Addr
		}
		if fc.Timeout != "" {
			d, err := time.ParseDuration(fc.Timeout)
			if err != nil {
				return Config{}, fmt.Errorf("config file timeout: %w", err)
			}
			cfg.Timeout = d
		}
		if fc.LogLevel != "" {
			cfg.LogLevel = fc.LogLevel
		}
	}

	// 3. environment overrides the file
	if v, ok := os.LookupEnv("APP_ADDR"); ok {
		cfg.Addr = v
	}
	if v, ok := os.LookupEnv("APP_LOG_LEVEL"); ok {
		cfg.LogLevel = v
	}
	if v, ok := os.LookupEnv("APP_TIMEOUT"); ok {
		d, err := time.ParseDuration(v)
		if err != nil {
			return Config{}, fmt.Errorf("APP_TIMEOUT %q: %w", v, err)
		}
		cfg.Timeout = d
	}
	cfg.DBPass = os.Getenv("APP_DB_PASSWORD")

	// 4. flags override everything
	fs := flag.NewFlagSet("service", flag.ContinueOnError)
	addr := fs.String("addr", cfg.Addr, "listen address")
	level := fs.String("log-level", cfg.LogLevel, "log level")
	if err := fs.Parse(args); err != nil {
		return Config{}, err
	}
	cfg.Addr, cfg.LogLevel = *addr, *level

	// 5. validate once, below
	return validate(cfg)
}

Three things are worth naming. Every environment variable is a string, so any field that isn’t one needs an explicit parse, and that parse can fail. APP_TIMEOUT goes through time.ParseDuration, and a bad value is a startup error rather than a silently-wrong default:

$ APP_TIMEOUT=30 APP_DB_PASSWORD=s3cret go run .
config error: APP_TIMEOUT "30": time: missing unit in duration "30"

The operator wrote 30 meaning thirty seconds; Go’s duration syntax needs a unit, so 30 is rejected with the exact string it choked on. Catching that at boot beats discovering it when the first slow request never times out. The file is likewise parsed into a separate fileConfig whose Timeout is a string, because JSON has no duration type either — a value like "30s" has to go through time.ParseDuration before it becomes a real time.Duration. And each flag’s default is set to the value accumulated so far (cfg.Addr, cfg.LogLevel), so a flag the operator doesn’t pass leaves the lower-precedence value untouched — the flag only wins when it’s actually supplied on the command line. Using a fresh flag.NewFlagSet rather than the global flag package is what keeps the loader testable: you pass args in, so a test can drive it without touching os.Args or leaking state between cases.

Validate once, fail fast, say why

The last step is the one that saves you a 3 a.m. incident. A service should refuse to start when its configuration is invalid — loudly, immediately, at the door — rather than boot into a half-working state and then fall over on the first request that touches the missing value. Validate the whole struct once, at the end of Load, and return a concrete error naming exactly what’s wrong:

func validate(cfg Config) (Config, error) {
	if cfg.DBPass == "" {
		return Config{}, fmt.Errorf("APP_DB_PASSWORD is required but unset")
	}
	switch cfg.LogLevel {
	case "debug", "info", "warn", "error":
	default:
		return Config{}, fmt.Errorf("log_level %q is invalid (want debug|info|warn|error)", cfg.LogLevel)
	}
	if cfg.Timeout <= 0 {
		return Config{}, fmt.Errorf("timeout must be positive, got %s", cfg.Timeout)
	}
	return cfg, nil
}

main calls Load, and on error prints it and exits non-zero. That’s the fail-fast — no partial boot, no degraded mode, just a dead process and a message an operator can act on before the pod even goes into rotation:

cfg, err := Load(os.Args[1:])
if err != nil {
	fmt.Fprintln(os.Stderr, "config error:", err)
	os.Exit(1)
}

Running it

The proof is in the runs. First, boot with no APP_DB_PASSWORD set — the required secret is missing, so the service refuses to start and names the value at fault:

$ go run .
config error: APP_DB_PASSWORD is required but unset
$ echo $?
1

Now provide the secret and exercise all three override levels at once. A config file sets timeout to 30s and log_level to warn; the environment sets APP_LOG_LEVEL=error; a flag sets -addr=:7777:

$ CONFIG_FILE=/tmp/config.json APP_LOG_LEVEL=error APP_DB_PASSWORD=s3cret \
    go run . -addr=:7777
addr=:7777 timeout=30s log_level=error db_pass="s3cret"

Read the precedence straight off that one line. addr=:7777 came from the flag, beating both the file and the default. log_level=error came from the environment, beating the file’s warn. timeout=30s came from the file, beating the built-in 15s. Three sources, three winners, exactly the order we specified — and the secret arrived through the environment, never the file. One more run to watch validation reject a bad enum:

$ APP_LOG_LEVEL=trace APP_DB_PASSWORD=s3cret go run .
config error: log_level "trace" is invalid (want debug|info|warn|error)
$ echo $?
1

trace isn’t one of the four levels, so the service dies at startup with a message that hands the operator the valid set. That’s the whole contract: if the process is running, its configuration is known-good — because anything else already stopped it.

Final thoughts

Configuration in Go is a small amount of standard library and one firm discipline. Read the environment with LookupEnv when unset and empty must differ; layer default, file, env, and flag in that precedence; fold the result into one typed Config; validate it once and fail fast with an error that names the offending field; and keep every secret in the environment, tagged out of any file. The payoff is a binary that runs unchanged in every environment and refuses to start misconfigured — which is exactly the property you want the first time a deploy goes out with a typo in it. Next, we make that service stop as carefully as it starts, so a redeploy doesn’t drop the requests already in flight.

Next: stop without dropping a request

Comments