Configuration Patterns Across 51 Go Projects#

Summary#

Go projects converge on three dominant configuration archetypes — flags-only, config struct with multi-source merge, and file-primary with env overlay — but the details vary enormously by project size, deployment model, and age. Viper is conspicuously absent from most large, mature projects, which have built custom config stacks; it appears primarily in medium-sized tools that need multi-source support without the maintenance burden of a bespoke system. The most architecturally interesting patterns are live-reload mechanisms, feature flag strategies, and the distinction between application config (flags + files) and component config (functional options or config structs), which nearly every project treats differently.

Taxonomy#

Approach 1: Flags-Only (Pure CLI)#

  • Projects using it: go (the toolchain), cobra, fzf, wireguard-go (env vars + UAPI protocol), dapr
  • How it works: All configuration comes from flag or pflag parsed at startup. No config file, no env-var binding beyond explicit os.Getenv calls. Zero-value means “use default.”
  • When it’s appropriate: Developer tools and system daemons where configuration is inherently per-invocation; tools that should be composable in shell pipelines; projects that prioritize auditability and simplicity over operator convenience.
  • Representative example: The go toolchain uses per-subcommand flag.FlagSet with environment variables (GOPATH, GOPROXY, etc.) normalized separately via envcmd.MkEnv(). Absolutely no YAML or TOML config file — configuration is explicit in every invocation.

Approach 2: Config Struct + Multi-Source Merge (Custom or Library)#

  • Projects using it: kubernetes, etcd, moby, cockroach, consul, vault, terraform, nomad, traefik, k3s, nats-server, prometheus, restic, rclone, buildkite-agent, frp, headscale, syncthing, air
  • How it works: A typed Go struct is populated from multiple sources in a priority-ordered merge. Sources vary: flags, env vars, YAML/TOML/JSON/HCL/INI/custom file formats. The struct is the single authoritative in-memory representation.
  • When it’s appropriate: Infrastructure tools and daemons with dozens to hundreds of configuration options; tools that must support multiple deployment contexts (bare metal, container, Kubernetes).
  • Sub-variants:
    • HCL-family (HashiCorp): vault, consul, terraform use HCL with their own parsers. Expressive config language; no Viper dependency. HCL allows comments, variable interpolation, and block nesting that JSON/YAML cannot.
    • Custom file format: nats-server has its own .conf lexer and parser (standalone, no external deps). syncthing uses XML with schema versioning and migration. wireguard-go uses the UAPI key=value protocol to match kernel WireGuard’s interface.
    • YAML-primary: frp, headscale, temporal, delve, crush use YAML. frp adds backward-compatible INI parsing.
    • TOML-primary: air, hugo (TOML + YAML + JSON).
    • INI-primary: gitea, gogs, grafana, beego use INI files parsed by go-ini/ini or similar.
    • Merge library: moby uses mergo to merge a JSON daemon config onto a flag-initialized struct. air uses mergo with a custom slice transformer to prevent user values from being overwritten by defaults.

Approach 3: Flags + Env Vars Only (12-Factor / Container-Native)#

  • Projects using it: drone, dapr, temporal (partially)
  • How it works: All config comes from environment variables. drone uses kelseyhightower/envconfig to populate a types.Config struct from DRONE_* env vars; optionally loads a .env file via godotenv for local dev. No config file in production.
  • When it’s appropriate: Container-native services designed for Kubernetes/Docker environments where config injection via env is idiomatic. Maximally portable; zero file system assumptions.
  • Tradeoff: Env vars are harder to discover and document than config files; no comments or grouping; secret leakage risk via process listing.

Approach 4: Global Config Vars (Legacy Pattern)#

  • Projects using it: gogs, gitea (partially), beego, cobra
  • How it works: Config is loaded once at startup into package-level variables (conf.Server.HTTPPort, conf.Auth.RequireSigninView). Callers access these globals directly — no injection.
  • When it’s appropriate: Historically dominant pattern (pre-2016 Go); still found in older projects. Works for single-binary tools where testability is not a priority.
  • Problems: Impossible to inject different config in tests without file system side effects; creates hidden dependencies between packages; race conditions if any field is ever mutated after startup.

Approach 5: Viper-Based#

  • Projects using it: headscale (Viper for the CLI layer), a handful of medium-sized tools
  • How it works: Viper provides multi-source config loading (flags via pflag, env vars, config file, remote KV store) with a single viper.GetXxx(key) API. Keys are strings; no compile-time type safety.
  • When it’s appropriate: Medium-sized projects that want multi-source support without writing a custom merge engine. The cobra + viper combination is extremely common in community tooling.
  • Observation: Conspicuously absent from large, mature projects. vault, consul, terraform (all HashiCorp), kubernetes, etcd, moby, prometheus, grafana, nats-server, syncthing, frp, temporal — none use Viper. The pattern is that projects that achieve significant scale either pre-date Viper or outgrow it and write custom config stacks. Viper’s string-keyed API lacks type safety and makes refactoring difficult.

Approach 6: Functional Options for Component Config#

  • Projects using it: etcd (client library), moby (client), helm, gin, viper, gorm, syncthing, syncthing (sub-packages), buildkite-agent, istio, k3s (HTTP clients), tailscale (envknob)
  • How it works: type Option func(*T) or type Option interface{ apply(*T) } with WithXxx(val) Option constructors. Applied at construction: New(opts ...Option).
  • When it’s appropriate: Library APIs where callers rarely need to specify all parameters; where defaults should be composable; where the API surface must remain stable as options grow. Universally preferred for library clients over config structs.
  • Key distinction: Projects consistently apply functional options at the component or library client level while using flat structs at the application level. Very few projects (only helm, gin) use functional options as the primary mechanism for top-level application configuration.

Approach 7: Context-Carried Config (Kubernetes Pattern)#

  • Projects using it: tekton-pipeline, kubernetes (partially)
  • How it works: Kubernetes ConfigMaps are watched live by a configmap.Store. On each change, the store attaches an updated config struct to a context.Context via context.WithValue. Reconcilers call config.FromContext(ctx) — no argument passing, no blocking.
  • When it’s appropriate: Kubernetes operators and controllers where configuration itself is a Kubernetes resource (ConfigMap). The Knative configmap machinery is the standard implementation.
  • Tradeoff: Config in context is invisible to function signatures; makes data flow harder to trace. Acceptable for Kubernetes reconcilers where context threading is already pervasive.

Comparison Dimensions#

Config Source Priority#

Most projects define a clear precedence chain. The consensus ordering across the corpus:

CLI flags > environment variables > config file > compiled-in defaults
ProjectPriority order
kubernetesflags > defaults (no file/env at runtime)
mobyCLI flags > daemon.json > compiled defaults
etcdpflag > ETCD_* env vars > defaults
prometheuskingpin flags (static) + YAML file (reloadable)
grafanaCLI flags > env vars > custom.ini > defaults.ini
traefikCLI flags > TRAEFIK_* env vars > YAML/TOML file > defaults
consulCLI flags > env vars > HCL/JSON files > defaults
vaultVAULT_* env vars + HCL file (not ordered; different sources cover different settings)
droneDRONE_* env vars only (flags: none)
buildkite-agentCLI flags > BUILDKITE_AGENT_* env vars > .cfg file > defaults
headscalepflag > Viper (YAML file + env vars)
nats-serverdefaults > flags > .conf file > JWT operator config
k3sYAML file (pre-processed to flags) > flags > defaults

Notable outliers:

  • nats-server reverses the typical order: the config file takes precedence over flags. This reflects NATS’s deployment model where the .conf file is the canonical operator document, not flags.
  • drone has no config file at all — a deliberate 12-factor choice.
  • k3s pre-processes YAML files into CLI args before urfave/cli parses them, achieving “YAML file overrides defaults” without special merge logic.

Config File Format Prevalence#

FormatProjects
YAMLfrp, headscale, temporal, delve, crush, tekton (ConfigMap), tailscale (JSON conffile), istio, dapr (Kubernetes CRDs)
TOMLair, hugo (multi-format: TOML/YAML/JSON), pop (database.yml = YAML, but TOML is common)
INIgitea, gogs, grafana, beego, buildkite-agent (.cfg)
HCLvault, consul, terraform, nomad
JSONcaddy (Caddyfile is custom; JSON is the API format), moby (daemon.json), pocketbase (SQLite + JSON settings)
Customnats-server (own lexer), syncthing (XML), wireguard-go (UAPI key=value)
Nonego, cobra, fzf, dapr, drone, kubernetes (flags+env), wireguard-go (runtime UAPI only)

HCL is a HashiCorp monoculture. All four major HashiCorp tools (vault, consul, terraform, nomad) use HCL. Non-HashiCorp projects uniformly avoid it.

YAML dominance for cloud-native tools. Cloud-native projects (tekton, istio, argo-cd, temporal, dapr) favor YAML because their config is often Kubernetes CRDs or compatible with Kubernetes tooling.

Environment Variable Conventions#

Every project that supports env vars uses a project-specific prefix:

ProjectPrefixBinding mechanism
etcdETCD_*pflag env binding
traefikTRAEFIK_*paerser env loader
consulCONSUL_*manual os.Getenv in config.Load
vaultVAULT_*manual in command/server
cockroachCOCKROACH_*pkg/util/envutil
droneDRONE_*kelseyhightower/envconfig
buildkite-agentBUILDKITE_AGENT_*urfave/cli env tags
giteaGITEA__SECTION__KEYcustom override (double underscore separator)
tailscaleTS_DEBUG_*envknob (lazy evaluation package)
rcloneRCLONE_*pflag env binding
frpnone (not supported for proxy config)
hugoHUGO_*custom loader
prometheusnone (flags + YAML)

Tailscale’s envknob is worth noting: it enforces that env vars are never read in init() (lazy evaluation via sync.Once), preventing initialization order bugs. This is the only project in the corpus with an explicit architectural constraint on when env vars are evaluated.

Gitea’s double-underscore convention (GITEA__DATABASE__HOST) allows overriding any INI section/key pair via env without modifying the INI parser — a clean escape hatch for container deployments.

Live Config Reload#

StrategyProjectsMechanism
SIGHUPmoby, prometheus, grafana, nats-serverSignal handler triggers reload; applies delta
File watchersyncthing, fyne (settings), air, frpfsnotify or poll detects change; reloads struct
Kubernetes ConfigMap watchertekton-pipeline, istioKnative configmap.Store; attaches to context
SQL-backedcockroach (cluster settings), pocketbaseSET CLUSTER SETTING SQL; persists to KV
Modify(fn) transactionalsyncthingconfig.Wrapper.Modify() serializes changes
None (restart required)kubernetes, etcd, consul, vault (partial), gogs, delve, restic, fzf, go, cobra, fzfConfig is read once; changes require restart

Prometheus’s ApplyConfig convention is the most architecturally elegant: every subsystem that needs runtime config implements ApplyConfig(*config.Config) error. The coordinator calls each on SIGHUP in sequence. No subsystem needs to watch signals or know about other subsystems. This is a clean subscriber pattern without an event bus.

NATS’s typed reloaders are notable: each config field that can change at runtime has its own diffOpts() and apply() methods (server/reload.go). This makes it explicit which fields support hot reload, prevents “surprise restarts,” and is self-documenting.

CockroachDB’s cluster settings represent the most sophisticated runtime config system: 1,056 named settings (bool, int, float, string, duration, byte-size) changeable via SET CLUSTER SETTING SQL, propagated to all nodes via gossip + KV writes. Operators get a SQL interface to the config system, which integrates naturally with existing RBAC.

Feature Flag Strategies#

StrategyProjectsDetail
Build tags (compile-time)tailscale, gitea, moby_enabled.go/_disabled.go pairs; lean binaries
featuregate packagekubernetes, etcdAlpha/Beta/GA lifecycle; --feature-gates=X=true
features map in configmobyConfig.Features map[string]bool in daemon.json
Named feature flags with lifecyclerestic, cockroachAlpha/Beta/Stable/Deprecated; env var enable
OpenFeature SDKgrafanaDynamic providers; per-org, per-user flags
Feature hooks (link-time)tailscalefeature.Hook.Set() in init(); optional packages self-register
Nonefzf, gogs, pop, wireguard-go, most librariesNo feature flag system

Tailscale’s two-level feature flag system is the most sophisticated: compile-time build tags produce statically different binaries (no runtime cost), while link-time hooks allow optional packages to self-register capabilities. The same source tree produces both a lean container image and a full desktop client with SSH, Taildrop, and TPM support.

Restic’s feature.Flag lifecycle (Alpha/Beta/Stable/Deprecated) is notable for a CLI tool: it gives operators a way to opt into unfinished features (RESTIC_FEATURES=backend-error-redesign=true) without shipping two binaries.


Common Patterns#

The “Two-Layer Config” Split#

Almost every production service separates startup configuration (flags, files — validated once, immutable at runtime) from runtime configuration (live settings, operator tuning — changeable without restart). Projects that conflate these two layers tend to require restarts for minor config changes.

  • Prometheus: kingpin flags (startup) + YAML (reloadable)
  • CockroachDB: Cobra flags (startup) + cluster settings SQL (runtime)
  • NATS: .conf file (most settings, restart required) + hot-reloadable subset
  • Tekton: binary flags (startup) + Kubernetes ConfigMaps (runtime)
  • Temporal: static YAML (startup) + dynamic config service (runtime)

Functional Options for Library Clients, Structs for Services#

This distinction appears in at least 15 projects. The library client API uses functional options (client.New(With*...)); the service/daemon uses flat config structs. The logic is sound: library callers benefit from the ergonomics of selective override; daemon operators typically provide all settings via a config file, so the struct literal is equally readable.

Projects that most clearly demonstrate this split: etcd, moby, syncthing, buildkite-agent.

Config Struct as DI Contract#

Several projects use the config struct as the contract for dependency injection:

  • drone: Provide*Config functions narrow types.Config to domain-specific sub-structs, passed individually to each service. No service can read another’s config.
  • grafana: Wire injects *setting.Cfg (the megastruct) into every service. Less isolated, but the typing is enforced.
  • kubernetes: *Options*Config two-stage pattern; components receive typed configs, not the monolithic ComponentConfig.

The drone approach is the best exemplar for config isolation: the Wire provider graph enforces at compile time that, e.g., the git package cannot accidentally read Redis config.

Mergo for Struct Merging#

dario.cat/mergo (formerly imdario/mergo) appears in at least three projects (moby, air, frp) for merging config structs. It fills the gap between zero-value checking and explicit field-by-field copy. The main footgun — mergo cannot distinguish between “field not specified” and “field explicitly set to zero” — led moby to add “value-set” tracking on top of mergo.


Divergent Choices#

Viper: Used vs. Avoided#

The corpus shows a clear split: Viper is used by small-to-medium projects (headscale being the largest) and avoided by most large projects. The consistent reason given (or implied) in comments and architecture notes:

  1. String-keyed APIviper.GetString("database.host") is not type-safe; renaming a key is not caught by the compiler.
  2. Global state — Viper’s default instance is a package-level singleton; multiple instances require viper.New() which is less ergonomic.
  3. Magic — Viper’s automatic env binding and config file discovery are convenient but opaque; bugs in the merge order are hard to diagnose.

Projects that pre-date Viper (syncthing, nats-server, prometheus) built their own stacks. Projects that achieved scale after Viper existed chose not to adopt it (temporal, dapr, frp, headscale partially).

HCL vs. YAML vs. TOML#

  • HCL: Expressive, block-structured, supports comments and references. Loved within the HashiCorp ecosystem; unknown outside it. The HCL grammar is complex (parser is non-trivial).
  • YAML: Industry standard for cloud-native tooling. Kubernetes compatibility is a strong pull. Pitfalls (Norway problem, implicit type coercion, indentation sensitivity) are well-known but accepted.
  • TOML: Strong typing for primitive values; no Norway problem; excellent for human-edited config files with sections. Preferred by Rust tooling and some Go tools. Less common than YAML.
  • INI: Legacy format; simple; well-understood by operators. Still in use in older web platforms (gitea, gogs, grafana, beego).
  • Custom: Chosen when semantic requirements cannot be met by existing formats (WireGuard UAPI protocol match; NATS .conf with include directives; Syncthing XML with schema versioning).

“No Viper” as Explicit Architecture Decision#

At least 12 projects explicitly note “No Viper” in their architecture documentation: etcd, prometheus, grafana, consul, vault, terraform, frp, syncthing, nats-server, tekton-pipeline, delve, go. This frequency suggests the decision is deliberate, not accidental — these projects evaluated Viper and chose against it.


Age Correlates with Custom Config Stacks#

Older projects (nats-server 2012, syncthing 2014, gogs 2014, grafana 2014) all have custom config stacks that predate Viper’s widespread adoption. This is inertia, not quality judgment.

Newer projects have access to the full ecosystem but still often build custom stacks when their requirements are specific enough (crush 2025 uses a clean multi-source JSON loader; frp has a versioned config format with backward compatibility).

Cloud-Native Projects Externalize Config into the Platform#

Kubernetes-native projects (tekton-pipeline, istio, argo-cd) don’t load config files at all — they watch Kubernetes resources (ConfigMaps, CRDs). Config management is delegated to kubectl apply. This is a fundamentally different mental model: the operator doesn’t write config files; they apply Kubernetes resources that the operator converts to config.

The Megastruct Anti-Pattern (and Its Exceptions)#

Large config structs (grafana’s setting.Cfg, kubernetes’s component configs, nats-server’s Options with 200 fields, k3s’s ServerConfig with 100+ fields) appear across the corpus. These megastructs are convenient for simple projects but create:

  • Tight coupling between packages (everyone reads the same struct)
  • Configuration options that silently interact
  • Difficulty understanding which fields apply to which component

The exceptions that solve this well:

  • drone: Wire-enforced sub-config injection
  • kubernetes: *Options*Config with per-component struct types
  • headscale: Tuning sub-struct explicitly separated from functional config

The Feature Flag Gap#

Most projects have no formal feature flag system. Of the 51, only ~8 have an explicit lifecycle (alpha/beta/stable) or runtime toggle mechanism. The default pattern is: ship the feature, add a comment “experimental,” document a flag. This is a significant operational gap for tools used in production environments where operators need to opt into unfinished features.


Best Practices#

Based on what works across successful projects in the corpus:

  1. Establish a clear priority chain and document it. CLI flags > env vars > config file > defaults is the dominant convention and operators expect it.

  2. Separate startup config from runtime config. Startup config (flags, files) is immutable after boot. Runtime config (cluster settings, ConfigMaps, SIGHUP-reloaded subsections) is managed separately. Do not conflate these.

  3. Use typed structs, not string keys. Every successful large project uses typed config structs. String-keyed access (Viper, map lookups) is convenient early but creates maintenance problems at scale.

  4. Apply functional options at the library client level, structs at the service level. This split appears in 15+ projects and is clearly the community consensus.

  5. Name your env var prefix and be consistent. Every mature project has a PROJECT_* prefix. Document it. Use it exclusively.

  6. Make live-reload explicit. Know which fields support hot reload and which require restart. NATS’s typed reloaders are the gold standard: each field declares its own reload semantics in code, not in documentation.

  7. Prefer sub-config injection over megastructs. The drone pattern (Wire-enforced sub-config per service) is the cleanest design. If a full Wire setup is too heavy, at minimum slice the megastruct manually before passing to constructors.

  8. Consider a feature flag lifecycle from day one. Even a simple Alpha/Beta/Stable enum (restic, kubernetes) allows operators to safely experiment with unfinished features without shipping separate builds.


Anti-Patterns#

  1. Package-level global config vars (gogs, older beego): Kills testability, creates hidden coupling, invites initialization order races. Refactor to explicit injection even if it means passing a *Config argument through many layers.

  2. Mergo without zero-value tracking: mergo.Merge cannot tell “user set this to 0” from “user left this unset.” moby hit this exact bug and added “value-set” tracking on top. If you use mergo, add explicit tracking or use mergo.WithOverride carefully.

  3. Config struct fields that accumulate without owner: The nats-server Options struct with 200 fields is a symptom. No single engineer knows all the interactions. Split by domain early, before the struct becomes unmanageable.

  4. Env vars read in init(): Tailscale explicitly built envknob to prevent this. Env vars read in init() create initialization order surprises and cannot be overridden in tests. Always read env vars lazily or in main().

  5. Undocumented config reload semantics: Many projects support SIGHUP but don’t document which fields are hot-reloadable. Operators accidentally send SIGHUP expecting more than they get. Prometheus and NATS are exemplary in making this explicit.


Exemplars#

Best overall config architecture: drone Pure env-var config (kelseyhightower/envconfig) with Wire-enforced sub-config isolation. Every service receives only its own config slice. 12-factor compliant. No magic. The Provide*Config pattern is immediately understandable and scales to any number of services.

Best runtime config system: cockroach 1,056 typed cluster settings changeable via SQL, propagated via gossip. Operators use the same SQL interface they already know; new settings don’t require changing call sites; the settings registry is self-documenting. Appropriate for a distributed database where operator experience matters.

Best live-reload design: prometheus (ApplyConfig convention) + nats-server (typed reloaders) Prometheus’s ApplyConfig interface is the simplest possible contract for hot reload — every subsystem implements one method. NATS’s typed reloaders are more complex but more powerful: each field has its own reload semantics encoded as a type, making the system self-documenting and compiler-verified.

Best feature flag system: tailscale Compile-time build tags + link-time hooks + runtime envknob. Three layers covering three different use cases: binary size optimization, optional module registration, and operator debugging. The layering is clean and each level has a clear rationale.

Best example of functional options done right: etcd (client library) type Option func(*Client) with WithZapLogger, WithDialTimeout, WithContext, etc. applied in clientv3.New(). Clean Go library idiom; backward compatible as new options are added; callers only specify what they need.


Note on fyne and crush#

fyne: The GUI framework uses direct struct-field assignment for widget configuration (no functional options anywhere). Application settings (theme, scale) are stored in a platform-appropriate JSON file watched via fsnotify. Build-time metadata comes from FyneApp.toml. No Viper, no env vars — platform conventions are followed instead. The configuration model is appropriate for a GUI framework where users configure via UI, not flags or files.

crush: Implements a clean multi-source JSON loader (defaults → global config → project config → env var template substitution → flag overrides) that reads well and is easy to test. The config struct (internal/config/store.go) holds both the data (*Config) and runtime overrides separately. Functional options appear only for sub-components (Prompt, Permissions dialog) — the same split seen in larger projects. Consult P51-crush--ai-development-profile.md for context on which of crush’s patterns reflect AI-assisted development vs. normal Go practice; the config system in particular appears conventional for a 2025 project.