Environment Variables and Config Files

Updated

September 8, 2026

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.

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:

  1. Document that users export $(cat .env | xargs) in shell
  2. Parse a simple KEY=VAL file yourself (~30 lines)
  3. 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_FILE pointing 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

  1. Implement parse with flag > env > JSON defaults.
  2. Add --print-config that dumps resolved options (redact tokens).
  3. Support TOKEN_FILE and test with t.Setenv.