Configuration: Viper, Env & Config Files
Operators configure Go CLIs and services through flags, environment variables, and files.
Viper (github.com/spf13/viper) centralizes that layering: defaults in code, optional YAML/TOML/JSON on disk, env overrides, and flag binding from cobra.
The same module often powers both cmd/server and cmd/tool, so configuration rules stay consistent across binaries.
Summary
Twelve-factor style config keeps secrets out of source control and lets containers inject env vars at runtime.
Viper reads multiple sources and exposes GetString, GetInt, and friends after merge.
Document precedence (flags beat env beat file beat defaults) in README so on-call engineers know which value wins.
Recipe
Quick-reference recipe card - copy-paste ready.
viper.SetDefault("api.timeout", "30s")
viper.SetConfigName("config")
viper.AddConfigPath(".")
_ = viper.ReadInConfig()
viper.SetEnvPrefix("MYAPP")
viper.AutomaticEnv()
viper.BindPFlags(cmd.Flags())
timeout := viper.GetDuration("api.timeout")When to reach for this:
- CLIs and services sharing config keys across binaries.
- Multiple file formats (YAML for humans, JSON for machines).
- Env var overrides in Kubernetes without rebuilding images.
- cobra tools that need
--configplusMYAPP_*env vars.
Working Example
package main
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func main() {
var cfgFile string
root := &cobra.Command{Use: "worker"}
root.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
viper.SetDefault("api.url", "http://localhost:8080")
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.SetConfigName("config")
viper.AddConfigPath(".")
}
_ = viper.ReadInConfig() // optional file
viper.SetEnvPrefix("WORKER")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
return viper.BindPFlags(cmd.Flags())
}
root.RunE = func(cmd *cobra.Command, args []string) error {
fmt.Println("api", viper.GetString("api.url"))
return nil
}
if err := root.Execute(); err != nil {
os.Exit(1)
}
}What this demonstrates:
PersistentPreRunEloads config before every subcommand runs.- Missing config files can be ignored when defaults and env suffice.
SetEnvKeyReplacermapsapi.urltoWORKER_API_URL.BindPFlagslets--api.urloverride file and env at runtime.
Deep Dive
How It Works
- Viper stores key/value pairs in a merged map with case-insensitive keys for env.
ReadInConfigloads the first found file from search paths.WatchConfigandOnConfigChangeenable hot reload (more common in servers than CLIs).UnmarshalorUnmarshalKeyprojects settings into structs for type-safe access.
Precedence (typical cobra + viper setup)
| Priority (high to low) | Source |
|---|---|
| 1 | Explicit CLI flags bound with BindPFlags |
| 2 | Environment variables (AutomaticEnv) |
| 3 | Config file |
| 4 | SetDefault in code |
Document any deviation if your PreRun order differs.
Config File Layout
api:
url: https://api.example.com
timeout: 30s
database:
dsn: postgres://localhost:5432/app- Nested keys become
api.urlin viper accessors. - Keep secrets in env, not committed YAML templates.
- Ship
config.example.yamlwith safe placeholders.
Go Notes
- Validate required keys after merge:
if viper.GetString("api.url") == "" { return err }. - Prefer struct tags with
mapstructurefor large configs instead of scatteredGet*calls. - In tests,
viper.Reset()or use a fresh viper instance viaviper.New()to avoid global pollution.
Gotchas
- Global viper singleton in tests - Keys leak between tests. Fix:
viper.New()per test orviper.Reset()int.Cleanup. - Wrong env key replacer -
API_URLdoes not map toapi.urlwithout.to_rules. Fix:SetEnvKeyReplacer(strings.NewReplacer(".", "_")). - Silent missing file -
ReadInConfigerror ignored when file is optional. Fix: distinguishConfigFileNotFoundErrorfrom parse failures. - Duration strings in YAML - Must match Go duration syntax (
30s, not30). Fix: validate in startup or use integers for seconds. - Secrets logged on debug -
viper.AllSettings()dumps passwords. Fix: redact known secret keys in debug printers.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| envconfig / caarlos0/env | Struct-only env, no files | You need YAML hierarchies |
| koanf | Explicit merge chains | Team already on viper+cobra |
| manual os.Getenv | Two env vars total | Many nested keys |
| flags only | Ephemeral CI tools | Operators need config files |
FAQs
Do CLIs need hot reload?
Rarely.
Long-running controllers yes; one-shot CLIs read config once at start.
How do I support multiple config paths?
Call AddConfigPath for /etc/myapp, $HOME/.myapp, and . in order.
First readable file wins unless you use explicit --config.
Can viper work without cobra?
Yes.
Call viper.BindPFlags with a pflag.FlagSet or read values after stdlib flag.Parse.
How are secrets handled?
Load from env or secret managers; never commit real DSNs.
Validate presence at startup with clear error messages.
What about Kubernetes ConfigMaps?
Mount YAML as files and set AddConfigPath to the mount, or project keys to env vars.
Document which keys the chart sets.
How do I test config merge?
Use viper.New(), set env with t.Setenv, write temp config files, and assert GetString results.
Avoid the global viper in parallel tests.
Does viper support TOML and JSON?
Yes with the appropriate build tags and parser imports.
YAML is the most common for human-edited operator config.
How do defaults interact with zero values?
GetInt returns 0 for missing keys unless SetDefault or a file provides a value.
Use pointers in structs when 0 is valid and ambiguous.
Should services and CLIs share one config package?
Often yes - an internal/config package loads viper once and exposes a typed struct to both binaries.
How does this relate to observability config?
The observability section covers similar layering for production services.
CLIs reuse the same env names when wrapping those services.
Related
- CLI Tools Basics - manual env defaults
- cobra & urfave/cli Command Trees - BindPFlags wiring
- CLI Design in Go: Single Binary, Fast Startup - embed for default configs
- Color Output & User-Friendly Errors - config error messages
- CLI Tools Best Practices - config testing checklist
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).