Configuration: Viper, Env & Config Files
Operators configure Go CLIs and services through flags, environment variables, and files.
Search across all documentation pages
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.
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.
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:
--config plus MYAPP_* env vars.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:
PersistentPreRunE loads config before every subcommand runs.SetEnvKeyReplacer maps api.url to WORKER_API_URL.BindPFlags lets --api.url override file and env at runtime.ReadInConfig loads the first found file from search paths.WatchConfig and OnConfigChange enable hot reload (more common in servers than CLIs).Unmarshal or UnmarshalKey projects settings into structs for type-safe access.| 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.
api:
url: https://api.example.com
timeout: 30s
database:
dsn: postgres://localhost:5432/appapi.url in viper accessors.config.example.yaml with safe placeholders.if viper.GetString("api.url") == "" { return err }.mapstructure for large configs instead of scattered Get* calls.viper.Reset() or use a fresh viper instance via viper.New() to avoid global pollution.viper.New() per test or viper.Reset() in t.Cleanup.API_URL does not map to api.url without . to _ rules. Fix: SetEnvKeyReplacer(strings.NewReplacer(".", "_")).ReadInConfig error ignored when file is optional. Fix: distinguish ConfigFileNotFoundError from parse failures.30s, not 30). Fix: validate in startup or use integers for seconds.viper.AllSettings() dumps passwords. Fix: redact known secret keys in debug printers.| 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 |
Rarely.
Long-running controllers yes; one-shot CLIs read config once at start.
Call AddConfigPath for /etc/myapp, $HOME/.myapp, and . in order.
First readable file wins unless you use explicit --config.
Yes.
Call viper.BindPFlags with a pflag.FlagSet or read values after stdlib flag.Parse.
Load from env or secret managers; never commit real DSNs.
Validate presence at startup with clear error messages.
Mount YAML as files and set AddConfigPath to the mount, or project keys to env vars.
Document which keys the chart sets.
Use viper.New(), set env with t.Setenv, write temp config files, and assert GetString results.
Avoid the global viper in parallel tests.
Yes with the appropriate build tags and parser imports.
YAML is the most common for human-edited operator config.
GetInt returns 0 for missing keys unless SetDefault or a file provides a value.
Use pointers in structs when 0 is valid and ambiguous.
Often yes - an internal/config package loads viper once and exposes a typed struct to both binaries.
The observability section covers similar layering for production services.
CLIs reuse the same env names when wrapping those services.
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 16, 2026