Configuration Management: Env, Flags & Viper
Load service configuration from environment variables and flags for 12-factor deploys, with optional Viper for file defaults and hot-reload when the complexity is justified.
Summary
Configuration is everything that changes between deploy environments: ports, DSNs, feature flags, log levels.
The flag package parses CLI args; os.Getenv reads container env vars injected by Kubernetes or CI.
spf13/viper merges files, env, and flags with precedence rules and optional fsnotify reload.
Observability improves when startup logs print safe config snapshots and readiness fails on missing required values.
Recipe
Quick-reference recipe card - copy-paste ready.
port := flag.String("port", envOr("PORT", "8080"), "HTTP listen port")
logLevel := flag.String("log-level", envOr("LOG_LEVEL", "info"), "slog level")
flag.Parse()When to reach for this:
- Containerized services with env-driven staging vs production
- CLIs that accept overrides via flags for local dev
- Teams needing YAML defaults checked into git plus env overrides in prod
- Feature flags and timeout tuning without rebuilds
- Operators who want file watch reload for non-critical settings only
Working Example
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/spf13/viper"
)
type Config struct {
Port string
LogLevel slog.Level
DBURL string
ReadTimeout time.Duration
EnableTracing bool
}
func main() {
cfg, err := loadConfig()
if err != nil {
slog.Error("config invalid", "err", err)
os.Exit(1)
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
slog.SetDefault(logger)
slog.Info("startup config",
"port", cfg.Port,
"log_level", cfg.LogLevel.String(),
"tracing", cfg.EnableTracing,
"read_timeout", cfg.ReadTimeout.String(),
)
slog.Info("ready to serve", "addr", ":"+cfg.Port)
}
func loadConfig() (Config, error) {
_ = flag.String("config", "", "optional config file path")
flag.Parse()
v := viper.New()
v.SetDefault("port", "8080")
v.SetDefault("log_level", "info")
v.SetDefault("read_timeout", "5s")
v.SetDefault("enable_tracing", false)
v.SetEnvPrefix("APP")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if p := flag.Lookup("config").Value.String(); p != "" {
v.SetConfigFile(p)
if err := v.ReadInConfig(); err != nil {
return Config{}, fmt.Errorf("read config: %w", err)
}
}
level, err := parseLevel(v.GetString("log_level"))
if err != nil {
return Config{}, err
}
timeout, err := time.ParseDuration(v.GetString("read_timeout"))
if err != nil {
return Config{}, fmt.Errorf("read_timeout: %w", err)
}
db := v.GetString("db_url")
if db == "" {
return Config{}, fmt.Errorf("APP_DB_URL is required")
}
return Config{
Port: v.GetString("port"),
LogLevel: level,
DBURL: db,
ReadTimeout: timeout,
EnableTracing: v.GetBool("enable_tracing"),
}, nil
}
func parseLevel(s string) (slog.Level, error) {
switch strings.ToLower(s) {
case "debug":
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return slog.LevelInfo, fmt.Errorf("unknown log level %q", s)
}
}What this demonstrates:
- Viper defaults plus
APP_prefixed environment variables (APP_PORT,APP_DB_URL) - Optional config file via
-configflag - Required
db_urlvalidation before server start - Safe startup log of non-secret config fields
Deep Dive
How It Works
flag.Parseruns once inmainbefore other packages read flags.- Env vars override file defaults in 12-factor deployments.
- Viper
AutomaticEnvbinds env keys to nested config keys with replacers. - Hot reload watches files and calls callbacks; not all fields should reload live.
Precedence (typical Viper setup)
| Priority (high to low) | Source |
|---|---|
| 1 | Explicit flags |
| 2 | Environment variables |
| 3 | Config file |
| 4 | SetDefault values |
12-Factor Config Rules
| Rule | Go practice |
|---|---|
| Store config in env | os.Getenv, Viper AutomaticEnv |
| Strict separation | No prod branches in code; use env |
| One codebase | Same binary, different env per stage |
| Fail fast | main exits on missing DB_URL |
Go Notes
// Small services: skip Viper
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// Validate once, pass struct - do not read env deep in handlers
type Config struct { Port string }
func NewServer(cfg Config) *http.Server { /* ... */ }Hot-Reload Trade-offs
| Reload-friendly | Requires restart |
|---|---|
| Log level | Database DSN |
| Feature flags | TLS certificates |
| Timeout tuning | Listen ports |
Gotchas
- Reading env in every handler - Hides dependencies and breaks tests; inject
Configat startup. - Logging secrets at Info - DSNs and API keys in startup logs leak to aggregators; redact hosts only.
- Viper global singleton - Hard to test; use
viper.New()per load in libraries and tests. - Hot-reloading listen ports - Cannot rebind without restart; do not watch
portfor live reload. - Flag parsing in
init- Order conflicts across packages; parse only inmain. - Missing duration validation -
0stimeouts silently break HTTP servers; validate positive durations. - Implicit defaults for prod secrets - Empty default for secrets forces explicit env in production.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Env only | Containers, few variables | Large hierarchical config files |
flag only | CLIs and local tools | Kubernetes-only deploys without args |
| Viper | Files + env + flags merged | Two env vars total in a microservice |
| koanf, envconfig | Lighter structured env parsing | You already standardized on Viper |
FAQs
Should I use Viper for every service?
No.
Start with env and flags; add Viper when you need file defaults or many keys.
How do I name environment variables?
Use consistent prefixes per service (APP_, ORDERS_) and document them in README tables.
Can I reload config without restart?
Only for safe knobs like log level.
Listeners, credentials, and connection pools usually need restart.
How do flags interact with Kubernetes?
Containers typically use env and ConfigMaps; flags remain useful for local go run overrides.
What about secrets vs config?
Store secrets in Kubernetes Secrets or vault sidecars; never commit them to git config files.
How does config relate to readiness?
Fail readiness when required config failed to load or validation did not pass at startup.
Should I use struct tags?
Libraries like envconfig map env to structs cleanly when you skip Viper.
How do I test config loading?
Set t.Setenv in tests and assert returned Config structs without touching global Viper.
What file format for Viper?
YAML is common for defaults; JSON and TOML work too.
How do I handle config errors?
Wrap errors with field names, log once in main, exit non-zero before listening.
Are feature flags config?
Yes.
Treat them as env-driven toggles with defaults documented per environment.
How do I version config schema?
Add config_version field and migrate files in deploy notes when breaking keys rename.
Related
- Observability Basics - env var example
- Structured Logging with slog - log level from config
- Health, Readiness & Liveness Probes - fail readiness on bad config
- Graceful Shutdown & Signal Handling - shutdown timeout from config
- Observability Best Practices - safe startup logging
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).