Configuration with viper & envconfig
Services and CLIs need configuration that changes per environment without recompilation.
spf13/viper merges files, flags, and environment variables with precedence rules; kelseyhightower/envconfig maps env vars directly into typed structs for smaller binaries.
Recipe
Quick-reference recipe card - copy-paste ready.
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AutomaticEnv()
viper.SetDefault("http.port", 8080)
_ = viper.ReadInConfig()
port := viper.GetInt("http.port")type Config struct {
Port int `envconfig:"PORT" default:"8080"`
DSN string `envconfig:"DATABASE_URL" required:"true"`
}
var cfg Config
envconfig.Process("", &cfg)When to reach for this:
- Kubernetes-deployed services with ConfigMaps, Secrets, and env overrides
- CLIs that read a config file locally but accept flags for automation
- Teams documenting required env vars in README and Helm charts
- Gradual migration from ad hoc
os.Getenvscattered across packages
Working Example
package main
import (
"fmt"
"log"
"strings"
"github.com/spf13/viper"
)
type AppConfig struct {
HTTPPort int
LogLevel string
DSN string
}
func loadViper() AppConfig {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
viper.SetDefault("http.port", 8080)
viper.SetDefault("log.level", "info")
if err := viper.ReadInConfig(); err != nil {
log.Printf("no config file: %v", err)
}
return AppConfig{
HTTPPort: viper.GetInt("http.port"),
LogLevel: viper.GetString("log.level"),
DSN: viper.GetString("database.dsn"),
}
}
func main() {
cfg := loadViper()
if cfg.DSN == "" {
log.Fatal("database.dsn or DATABASE_DSN required")
}
fmt.Printf("port=%d level=%s\n", cfg.HTTPPort, cfg.LogLevel)
}What this demonstrates:
- YAML defaults with env override via
AutomaticEnvand key replacer - Fail-fast validation for secrets in
main - Typed
AppConfigstruct separate from viper's stringly API - Safe logging when config file is optional in dev
Deep Dive
How It Works
- viper stores a key-value map populated from multiple sources; later reads use precedence (flag > env > config file > default).
- envconfig reflects struct tags at startup and errors on missing
requiredfields. - Both run once at process start; hot reload needs
fsnotify(viper) or process restart (simplest ops model). - Keep config loading in
mainor aconfigpackage; domain packages receive typed structs, not global viper calls.
Precedence (viper)
| Source | Typical use | Override strength |
|---|---|---|
Explicit Set in code | Tests | Highest when used |
Flags (viper.BindPFlags) | CLI overrides | High |
| Environment | K8s Secrets, 12-factor | High |
| Config file | Defaults per environment | Medium |
SetDefault | Safe fallbacks | Low |
envconfig Tags
| Tag | Effect |
|---|---|
envconfig:"PORT" | Env var name (with optional prefix) |
required:"true" | Process fails if unset |
default:"8080" | Value when env missing |
split_words:"true" | HTTP_PORT maps to HTTPPort |
Go Notes
// Prefer unmarshaling into a struct once viper keys stabilize
var cfg AppConfig
if err := viper.Unmarshal(&cfg); err != nil {
log.Fatal(err)
}- Mapstructure tags on struct fields align viper keys with nested YAML.
- Never log full DSNs or API keys; print redacted snapshots at
infolevel only.
Gotchas
- Global viper in libraries - packages calling
viper.GetStringhide dependencies. Fix: load inmain, passAppConfigstructs. - Key naming drift -
http.portvsHTTP_PORTbreaks silently withoutSetEnvKeyReplacer. Fix: document env names in Helm and README tables. - Missing required secrets - empty string passes
GetStringchecks. Fix: validate with explicitif cfg.DSN == ""or envconfigrequired. - Hot reload without readiness - viper
WatchConfigreloads mid-request. Fix: restart pods or gate reload behind atomic pointer swap plus drain. - Secrets in ConfigMaps - YAML files committed to git leak credentials. Fix: mount Secrets as env vars; keep files for non-secret defaults only.
- Flag parsing order - reading viper before
flag.Parsemisses CLI overrides. Fix: parse flags first, then bind withviper.BindPFlags.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
flag + os.Getenv only | Two env vars and one port flag | Dozens of keys across files |
caarlos0/env | Struct tags without envconfig's age | You already standardized on envconfig |
koanf | Explicit merge layers without viper globals | Team knows viper and wants one tool |
| Helm values only | Config never local | Developers need offline go run |
FAQs
viper or envconfig for a new microservice?
Start with envconfig when all config comes from Kubernetes env and Secrets.
Add viper when you need YAML defaults for local dev and file-based feature flags.
How do I test configuration loading?
Set env vars in t.Setenv, write temp YAML files, or build AppConfig literals in tests without touching global viper when possible.
Should CLIs use the same stack as services?
Often yes for shared AppConfig structs; CLIs may add pflag bindings where services rely on env only.
How does this relate to the observability config article?
That page covers flags and env patterns broadly; this page compares viper and envconfig libraries specifically.
Can I mix viper and envconfig?
Yes - load file defaults with viper, then overlay with envconfig for secrets - but prefer one loader to avoid precedence confusion.
What about validation beyond required?
Unmarshal into structs and run go-playground/validator or hand-rolled checks on ports, URLs, and durations.
How do I document env vars for operators?
Generate a table from struct tags in README, duplicate in Helm values.yaml comments, and fail startup with actionable error messages.
Is viper too heavy for small tools?
For single-binary CLIs with three flags, stdlib flag is simpler and avoids a dependency.
How do feature flags fit?
Store flag keys in viper files or a remote provider; keep defaults safe when the provider is unreachable.
Should config structs live in `internal/config`?
Yes - one package owns loading, validation, and redacted String() for logs.
Related
- Configuration Management: Env, Flags & Viper - 12-factor overview
- Essential Libraries Basics - viper quick start
- Input Validation & Safe HTML/JSON Handling - validate loaded values
- Graceful Shutdown & Signal Handling - config-driven drain timeouts
- Module & Dependency Rules - minimal dependency hygiene
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).