Environment Variables and Config Files
Environment Variables and Config Files
Overview
Flags are per-invocation. Environment variables and config files hold defaults that follow the user or deployment. Stdlib-only tooling can load both cleanly; Viper (chapter 313) automates layering when complexity grows.
Precedence (recommended)
flags > env > config file > built-in defaults
Document this in --help. Users should never wonder why a value “stuck.”
Environment variables
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getenvInt(key string, def int) int {
v := os.Getenv(key)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def // or return error — prefer fail-fast for CLIs
}
return n
}12-factor style names
MYTOOL_API_URL=https://api.example.com
MYTOOL_TOKEN=...
MYTOOL_TIMEOUT=10s
apiURL := getenv("MYTOOL_API_URL", "http://127.0.0.1:8080")Parse duration from env
func envDuration(key string, def time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return def, nil
}
return time.ParseDuration(v)
}Booleans
func envBool(key string, def bool) bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
switch v {
case "":
return def
case "1", "true", "yes", "y", "on":
return true
case "0", "false", "no", "n", "off":
return false
default:
return def
}
}Prefer failing on garbage if the var is critical.
Config file locations
func defaultConfigPath(app string) (string, error) {
dir, err := os.UserConfigDir() // platform-correct
if err != nil {
return "", err
}
return filepath.Join(dir, app, "config.json"), nil
}| OS | UserConfigDir typical |
|---|---|
| macOS | ~/Library/Application Support |
| Linux | ~/.config |
| Windows | %AppData% |
Also support explicit --config path.
JSON config (stdlib)
type Config struct {
APIURL string `json:"api_url"`
Timeout string `json:"timeout"` // parse as duration
Verbose bool `json:"verbose"`
}
func loadConfig(path string) (Config, error) {
var c Config
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return Config{}, nil // missing file OK
}
return Config{}, err
}
if err := json.Unmarshal(b, &c); err != nil {
return Config{}, fmt.Errorf("config %s: %w", path, err)
}
return c, nil
}Merge with flags
type Options struct {
APIURL string
Verbose bool
Timeout time.Duration
}
func parse(args []string) (Options, error) {
fs := flag.NewFlagSet("mytool", flag.ContinueOnError)
cfgPath := fs.String("config", "", "config file path")
api := fs.String("api-url", "", "API base URL")
verbose := fs.Bool("verbose", false, "verbose")
timeout := fs.Duration("timeout", 0, "timeout (0=default)")
if err := fs.Parse(args); err != nil {
return Options{}, err
}
path := *cfgPath
if path == "" {
path, _ = defaultConfigPath("mytool")
}
fileCfg, err := loadConfig(path)
if err != nil {
return Options{}, err
}
opt := Options{
APIURL: firstNonEmpty(*api, os.Getenv("MYTOOL_API_URL"), fileCfg.APIURL, "http://127.0.0.1:8080"),
Verbose: *verbose || envBool("MYTOOL_VERBOSE", false) || fileCfg.Verbose,
Timeout: 10 * time.Second,
}
if *timeout > 0 {
opt.Timeout = *timeout
} else if d, err := envDuration("MYTOOL_TIMEOUT", 0); err == nil && d > 0 {
opt.Timeout = d
} else if fileCfg.Timeout != "" {
d, err := time.ParseDuration(fileCfg.Timeout)
if err != nil {
return Options{}, err
}
opt.Timeout = d
}
return opt, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}Dotenv? Not in stdlib
.env files are not standard. Options:
- Document that users
export $(cat .env | xargs)in shell - Parse a simple
KEY=VALfile yourself (~30 lines) - Use a small library later
A minimal dotenv:
func loadDotEnv(path string) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
if os.Getenv(k) == "" { // don't override existing env
_ = os.Setenv(k, v)
}
}
return nil
}Secrets
- Prefer env or OS keychain for tokens—not committed JSON
- Support
MYTOOL_TOKEN_FILEpointing at a file (Kubernetes-friendly)
func loadToken() (string, error) {
if t := os.Getenv("MYTOOL_TOKEN"); t != "" {
return t, nil
}
if p := os.Getenv("MYTOOL_TOKEN_FILE"); p != "" {
b, err := os.ReadFile(p)
return strings.TrimSpace(string(b)), err
}
return "", fmt.Errorf("set MYTOOL_TOKEN or MYTOOL_TOKEN_FILE")
}Rules of thumb
| Do | Don’t |
|---|---|
| Document precedence | Silent flag < env surprises |
| Allow missing config file | Crash if optional file absent |
| Fail on invalid config JSON | Ignore corrupt files |
Use UserConfigDir |
Hardcode ~/.mytool only |
Try next
- Implement
parsewith flag > env > JSON defaults. - Add
--print-configthat dumps resolved options (redact tokens). - Support
TOKEN_FILEand test witht.Setenv.