{} The Go Reference

Containers · Cloud-Native · Beginner

Configuration

Feeding a service its config the 12-factor way — environment variables and flags, precedence and defaults, fail-fast validation, and how ConfigMaps and Secrets map onto it in Kubernetes.

Containers Beginner ⏱ 4 min read Complete

🎚️ Analogy

A well-designed appliance has its settings on the outside — dials and switches anyone can adjust — not soldered into the circuit board. Config should be the same: the dials live in the environment, and the same appliance (your image) behaves differently depending on how they’re set, without opening it up and rewiring. Soldering a setting into the code means a new board for every kitchen; reading it from the environment means one board, many kitchens.

Config comes from the environment

The 12-factor rule: config lives in the environment, separated from code. The same image runs everywhere; env vars supply the differences. A typical precedence, most-specific wins:

graph LR
D["built-in defaults"] --> F["config file"]
F --> E["environment variables"]
E --> FL["command-line flags"]
FL --> CFG["resolved config<br/>(validated at startup)"]
style CFG fill:#0ea5e9,color:#fff

See it: a config loader with defaults, env, and validation

A small, dependency-free loader: defaults, env overrides, type parsing, and fail-fast validation. This runs here (env vars simulate the platform):

config.go — editable & runnable
package main

import (
"fmt"
"os"
"strconv"
)

type Config struct {
Port     int
LogLevel string
DBURL    string // required, no default
}

func getenv(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
	return v
}
return def
}

func Load() (Config, error) {
port, err := strconv.Atoi(getenv("PORT", "8080"))
if err != nil {
	return Config{}, fmt.Errorf("PORT must be an integer: %w", err)
}
cfg := Config{
	Port:     port,
	LogLevel: getenv("LOG_LEVEL", "info"),
	DBURL:    os.Getenv("DB_URL"), // no default — must be supplied
}
// Fail fast: a misconfigured app should crash now, not on request #1.
if cfg.DBURL == "" {
	return Config{}, fmt.Errorf("DB_URL is required")
}
if cfg.Port < 1 || cfg.Port > 65535 {
	return Config{}, fmt.Errorf("PORT out of range: %d", cfg.Port)
}
return cfg, nil
}

func main() {
os.Setenv("PORT", "9000")
os.Setenv("DB_URL", "postgres://db:5432/app")

cfg, err := Load()
if err != nil {
	fmt.Println("config error:", err)
	return
}
fmt.Printf("loaded: %+v\n", cfg)

// Show fail-fast: clear the required value and reload.
os.Unsetenv("DB_URL")
if _, err := Load(); err != nil {
	fmt.Println("missing required:", err)
}
}

The loader parses types, applies defaults for optional values, and requires DB_URL — crashing at boot with a clear message rather than failing mysteriously on the first request. For larger apps, libraries like kelseyhightower/envconfig or Viper map a struct to env vars, but the stdlib gets you far.

How this maps to Kubernetes

In Kubernetes, operators supply these values via ConfigMaps (non-secret) and Secrets (sensitive), which the Pod spec injects as env vars or mounted files — the Deployment manifest showed both. Your Go code doesn’t change: it still reads os.Getenv / files, unaware whether the value came from a local .env, a ConfigMap, or a secrets manager.

🐹 Validate once, pass a struct around

Load and validate config once at startup into a typed Config struct, then pass that struct (or the values it holds) explicitly to the components that need them. Don’t sprinkle os.Getenv calls throughout the codebase — that hides dependencies, makes testing painful, and defers config errors to runtime. One Load() at main, fail fast on anything missing or malformed, and the rest of the program works with validated, typed values it can trust. Wrap secret fields in a redacting type (see secrets management) so a logged config can’t leak them.

⚠️ Kubernetes Secrets are base64, not encryption

A common, dangerous assumption: that a Kubernetes Secret protects your credentials. By default it’s only base64-encoded at rest in etcd — trivially decoded by anyone with cluster/etcd access. For real protection, enable encryption at rest for Secrets, restrict access with RBAC, and for high-value secrets use an external manager (Vault, cloud KMS, External Secrets Operator) that injects short-lived credentials. Treat a plain Secret as “kept out of the image,” not “encrypted.” See secrets management.

See also

Next: making the running service observable, starting with logs — structured logging in production.

Check your understanding

Score: 0 / 5

1. What is the 12-factor rule for configuration?

Config is everything that varies between deploys (ports, URLs, credentials, flags). 12-factor says it lives in the environment, never in code, so the same image is promoted dev→staging→prod with different env vars. No per-environment builds, no committed secrets, no 'if env == prod' branches in the code.

2. What's a sensible precedence order for config sources?

A common, predictable order is defaults < config file < environment < command-line flags — the more explicit/operator-controlled the source, the higher its priority. The key is that the order is documented and deterministic, so operators know that setting a flag (or env var) reliably overrides the default.

3. Why should a service validate its config at startup and fail fast?

Fail-fast config validation turns 'DB_URL was empty' into an immediate, obvious crash at boot (which a readiness probe catches and Kubernetes reports), instead of a nil-deref or auth failure on the first user request. Parse, validate types/ranges, and require mandatory values up front; a Pod that can't be configured correctly should never report ready.

4. How do ConfigMaps and Secrets relate to a 12-factor Go app?

ConfigMaps (non-secret) and Secrets (sensitive) are how operators supply config in Kubernetes; the Pod spec maps their keys to env vars or mounted files. The Go code stays portable — it reads os.Getenv/files exactly as it would locally with a .env. (Note: Kubernetes Secrets are only base64-encoded at rest by default — enable encryption-at-rest / use a real secrets manager for strong protection.)

5. Why avoid committing a .env file with real values to the repo?

A committed .env with real credentials is a secret leak (git history is forever) and ties the build to one environment. Commit a .env.example with placeholder keys for documentation, gitignore the real .env, and supply real values via the environment / ConfigMaps / Secrets / a secrets manager. See secrets management.

Comments

Sign in with GitHub to join the discussion.