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.
Search across all documentation pages
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.
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.
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:
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:
APP_ prefixed environment variables (APP_PORT, APP_DB_URL)-config flagdb_url validation before server startflag.Parse runs once in main before other packages read flags.AutomaticEnv binds env keys to nested config keys with replacers.| Priority (high to low) | Source |
|---|---|
| 1 | Explicit flags |
| 2 | Environment variables |
| 3 | Config file |
| 4 | SetDefault values |
| 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 |
// 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 { /* ... */ }| Reload-friendly | Requires restart |
|---|---|
| Log level | Database DSN |
| Feature flags | TLS certificates |
| Timeout tuning | Listen ports |
Config at startup.viper.New() per load in libraries and tests.port for live reload.init - Order conflicts across packages; parse only in main.0s timeouts silently break HTTP servers; validate positive durations.| 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 |
No.
Start with env and flags; add Viper when you need file defaults or many keys.
Use consistent prefixes per service (APP_, ORDERS_) and document them in README tables.
Only for safe knobs like log level.
Listeners, credentials, and connection pools usually need restart.
Containers typically use env and ConfigMaps; flags remain useful for local go run overrides.
Store secrets in Kubernetes Secrets or vault sidecars; never commit them to git config files.
Fail readiness when required config failed to load or validation did not pass at startup.
Libraries like envconfig map env to structs cleanly when you skip Viper.
Set t.Setenv in tests and assert returned Config structs without touching global Viper.
YAML is common for defaults; JSON and TOML work too.
Wrap errors with field names, log once in main, exit non-zero before listening.
Yes.
Treat them as env-driven toggles with defaults documented per environment.
Add config_version field and migrate files in deploy notes when breaking keys rename.
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).
Reviewed by Chris St. John·Last updated Jul 18, 2026