Configuration from the Environment
":8080" has been hardcoded into every main in this book so far, fine
for learning — one program, one machine, one port. A service heading
toward production runs in more than one place: a laptop, a CI job, a
staging container, a production cluster, maybe several replicas of that
cluster. Each needs a different port, a different database connection,
different timeouts, and secrets that must never be the same string checked
into source control. Hardcoding any of that means editing and recompiling
code just to change where the service runs — exactly the coupling the
"twelve-factor app" idea of config in the environment exists to remove:
the binary stays identical across environments, and only the environment
around it changes.
Reading the environment
os.Getenv(key) returns the environment variable's value, or an empty
string if it's unset — it never errors, which means every read needs an
explicit fallback:
addr := os.Getenv("ADDR")
if addr == "" {
addr = ":8080"
}
That pattern — read, check for empty, default — is the workhorse of environment-based config. It keeps the service runnable with zero configuration (sensible defaults) while letting any single value be overridden without touching code.
Typed values
Environment variables are always strings, but a timeout or a pool size isn't a string once it's in the program — it needs parsing, and parsing can fail on bad input:
timeout := 10 * time.Second
if v := os.Getenv("SHUTDOWN_TIMEOUT_SECONDS"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
log.Fatalf("invalid SHUTDOWN_TIMEOUT_SECONDS %q: %v", v, err)
}
timeout = time.Duration(n) * time.Second
}
A malformed environment variable is an operator mistake made at deploy time, and the right response is to refuse to start rather than run with a value nobody intended. Falling back to the default on a parse error would hide the typo instead of surfacing it.
CLI overrides
os.Getenv covers the deploy-time case; the flag package covers the
"just this one run" case — testing a different port locally without
exporting a variable first:
addr := flag.String("addr", envOr("ADDR", ":8080"), "listen address")
flag.Parse()
CLI flags take precedence over environment variables, which take
precedence over hardcoded defaults — flag.String's default argument is
exactly where the environment lookup fits.
Secrets don't go in code, and they don't go in logs
A database password or API key read via os.Getenv is still a secret once
it's a Go string. It should never appear in a log line, an error message,
or a panic value — the structured logging from an earlier lesson makes it
easy to log a whole config struct in one call, which is precisely how
secrets leak into aggregators. Log that a database URL was loaded, never
the URL itself.
Fail fast at startup
Config should be validated once, at startup, not discovered to be broken on the first request that needs it:
type config struct {
addr string
shutdownTimeout time.Duration
dbURL string
}
func loadConfig() (config, error) {
cfg := config{
addr: envOr("ADDR", ":8080"),
shutdownTimeout: 10 * time.Second,
dbURL: os.Getenv("DATABASE_URL"),
}
if cfg.dbURL == "" {
return config{}, fmt.Errorf("DATABASE_URL is required")
}
return cfg, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
main calls loadConfig before doing anything else and calls
log.Fatal on error — a service that can't validate its own configuration
should never accept a request, rather than start half-working and fail
unpredictably later.
You built a real service
Look back at where this started: a handler that decoded JSON and wrote it
back. Since then this book has added validation that rejects bad input,
middleware wrapping every handler for logging and recovery, structured
logs a machine can query, request context carrying identity and deadlines,
pagination so List scales past a handful of items, graceful shutdown so
a deploy doesn't drop in-flight work, and now configuration letting the
same binary run correctly on a laptop and in production without a code
change. Every piece is swappable — the Store behind an interface, each
handler behind the stdlib http.Handler your middleware wraps — so any of
them can be replaced without touching the others. That's not a toy anymore.
That's a real service.