background-shape
Configuration Management for Go Services, Viper, Env, or Just Flags?
January 19, 2022 · 6 min read · by Muhammad Amal programming

TL;DR — For most Go services, plain env vars decoded into a struct beats Viper. 60 lines, no dependencies beyond a tiny helper, validates at boot. Use Viper only when you actually need its multi-source merging.

Every Go microservice we’ve built so far has a config struct loaded at startup. Database URL, port, log level, feature flags, downstream service addresses, secrets. Boring stuff. The boring stuff is also where you spend three days of the year debugging “why does staging behave differently from production.”

The Go ecosystem has roughly three paths for config management as of January 2022: Viper, an env-vars-into-struct library like kelseyhightower/envconfig, or the standard library’s flag package. People will argue about which is best. The honest answer is that for the boring middle of a backend service, env vars + a 60-line loader wins, and Viper is overkill until you’ve felt the pain it solves.

Here’s how we land that decision and the loader we keep reusing.

What “config” actually means

Before picking a tool, three concrete questions:

Where does config come from? For 12-Factor-style services running in containers, the answer is “environment variables, set by the orchestrator.” Not files, not a remote service, not a database. Env vars only. This is the model Compose, ECS, Kubernetes, and Cloud Run all converge on.

When is it loaded? Once, at process startup. Not refreshed at runtime. Not hot-reloaded. If config changes, you redeploy. This rules out half of Viper’s feature surface (file watching, multi-source priority chains).

What happens on a missing or invalid value? The process should refuse to start. Every config error caught at boot is a config error not caught in the middle of a request at 3 AM.

If your answers are “env vars, at startup, fail-fast” — and they probably are — you do not need Viper. You need a struct, a decoder, and a validator.

The 60-line loader

This is what we use:

package config

import (
    "fmt"
    "log/slog"
    "os"
    "strconv"
    "time"
)

type Config struct {
    Env             string
    Port            int
    LogLevel        slog.Level
    DatabaseURL     string
    RedisURL        string
    BillingGRPCAddr string
    ShutdownTimeout time.Duration
}

func Load() (*Config, error) {
    cfg := &Config{
        Env:             getEnvDefault("APP_ENV", "development"),
        Port:            mustGetEnvInt("PORT", 8080),
        DatabaseURL:     mustGetEnv("DATABASE_URL"),
        RedisURL:        mustGetEnv("REDIS_URL"),
        BillingGRPCAddr: mustGetEnv("BILLING_GRPC_ADDR"),
        ShutdownTimeout: getEnvDuration("SHUTDOWN_TIMEOUT", 10*time.Second),
    }

    lvl, err := parseLogLevel(getEnvDefault("LOG_LEVEL", "info"))
    if err != nil {
        return nil, fmt.Errorf("LOG_LEVEL: %w", err)
    }
    cfg.LogLevel = lvl

    if err := cfg.validate(); err != nil {
        return nil, err
    }
    return cfg, nil
}

func (c *Config) validate() error {
    if c.Port < 1 || c.Port > 65535 {
        return fmt.Errorf("PORT %d out of range", c.Port)
    }
    if c.Env != "development" && c.Env != "staging" && c.Env != "production" {
        return fmt.Errorf("APP_ENV %q invalid", c.Env)
    }
    return nil
}

Plus a tiny env helpers file:

func getEnvDefault(key, def string) string {
    if v, ok := os.LookupEnv(key); ok {
        return v
    }
    return def
}

func mustGetEnv(key string) string {
    v, ok := os.LookupEnv(key)
    if !ok || v == "" {
        panic(fmt.Sprintf("required env %s not set", key))
    }
    return v
}

func mustGetEnvInt(key string, def int) int {
    s, ok := os.LookupEnv(key)
    if !ok {
        return def
    }
    n, err := strconv.Atoi(s)
    if err != nil {
        panic(fmt.Sprintf("env %s=%q is not int", key, s))
    }
    return n
}

func getEnvDuration(key string, def time.Duration) time.Duration {
    s, ok := os.LookupEnv(key)
    if !ok {
        return s2dur(def)
    }
    d, err := time.ParseDuration(s)
    if err != nil {
        panic(fmt.Sprintf("env %s=%q invalid duration", key, s))
    }
    return d
}

That’s the whole thing. Used in main.go:

cfg, err := config.Load()
if err != nil {
    log.Fatalf("config: %v", err)
}

If a required env var is missing, the service refuses to start with a clear message. If PORT is “abc”, same thing. If APP_ENV is “prod” instead of “production”, same thing. Nothing surprising at request time.

When Viper actually earns its keep

I’m not anti-Viper. It’s a competent library. There are real cases where it pulls its weight:

You need a config file as the source of truth. Some services genuinely benefit from a YAML or TOML config file with hundreds of options that humans curate. Viper handles file parsing and the priority chain (env > flag > file > default) elegantly.

You’re building a CLI, not a server. CLI tools usually want flags first, env second, file third. Viper’s BindPFlag integration with cobra is genuinely nice.

You need hot reload. Viper can watch a file and re-emit config on changes. Useful for some long-running daemons. Not useful for stateless web services where redeploy is cheap.

You’re consuming config from a remote source. Viper has integrations for Consul, etcd, and Vault. If you’re already in that ecosystem, Viper saves you writing the glue.

If none of those apply, Viper is a 70 KB dependency for a problem you don’t have.

envconfig: the middle ground

github.com/kelseyhightower/envconfig sits between rolling your own and pulling in Viper. It uses struct tags to auto-decode env vars:

type Config struct {
    Port        int           `envconfig:"PORT" default:"8080"`
    DatabaseURL string        `envconfig:"DATABASE_URL" required:"true"`
    LogLevel    string        `envconfig:"LOG_LEVEL" default:"info"`
    Timeout     time.Duration `envconfig:"TIMEOUT" default:"10s"`
}

func Load() (*Config, error) {
    var cfg Config
    if err := envconfig.Process("", &cfg); err != nil {
        return nil, err
    }
    return &cfg, nil
}

Less code than the rolled-your-own version. No external footprint at runtime (just a small dep). Honestly fine. We use envconfig on services with twenty-plus config fields. We use the hand-rolled loader on services with five or six. Both end up at the same place.

Common Pitfalls

Reading env vars deep in your code, not at startup. os.Getenv("DATABASE_URL") halfway through a request handler is a nightmare. It bypasses validation, it can’t be changed in tests, and it makes the code dependent on global state. Read everything at boot, pass the config struct down explicitly.

Defaulting required values. “DATABASE_URL defaults to localhost” is a footgun. The default lets the service start in production with the wrong database. If a value is required, fail loudly when it’s missing. No defaults for secrets, ever.

Logging the config struct at startup. Tempting for debuggability. But the struct contains your DB password and your API keys. Either log a redacted version (String() method that masks sensitive fields) or don’t log it at all.

Using flags for production config. flag.String("db-url", ...) is fine for CLIs and tests. In production, env vars win because every orchestrator (Docker, Kubernetes, ECS, systemd) is built around them. Mixing flags and env in the same service is a maintenance burden.

Forgetting to validate enums. “ENV is one of dev, staging, production” feels obvious. Until someone sets it to “Prod” and the service silently treats it as not-production. Always validate.

Wrapping Up

Pick the simplest config strategy that fails loudly. For most Go services, that’s env vars decoded into a struct, validated at boot, with a fail-fast mustGetEnv for required values. Reach for Viper when you actually need multi-source merging or remote config. Next post: structured logging in Go with zap, the other half of the boot-time setup ritual.