Configuration That Is Wrong Before It Is Late

Six sources resolved in a fixed order, environment overrides that only work for registered keys, and validation that fails the process rather than the request.


The same binary has to run on a laptop with a JSON file next to it, in CI with a handful of environment variables, and in production with secrets injected by a platform the developer has never logged into. It has to behave the same way in all three, and when it cannot start it has to say which value was wrong.

A configuration mistake usually does not stop the service. It starts a service that is subtly wrong: the read pool pointed at the write database, a timeout set to zero, the thing you needed disabled, and nobody finds out until the behaviour is strange enough to investigate.


One binary, several environments

Each environment has its own answer to where values come from. A laptop has a file system and a text editor, so a JSON file beside the binary is the fastest thing that works. CI and the integration suite need a fixture committed alongside the tests and unaffected by a developer’s home directory, so they point --configDir at a directory in the repository. A host installed from a deb or an rpm gets a package-managed /etc/<app>/config.json that the next upgrade may replace. A container image is immutable, often has no writable file system, and its per-deployment values are decided long after the build, so they arrive as environment variables. A cluster with a configuration service holds the document centrally and the process fetches it at startup.

Deciding this in code, branching on if env == "production" or compiling a different build tag per environment, ships an artefact to production that is not the artefact CI tested, and its real configuration is knowable only by reading the source. The layered model needs no such branch. config.Load is called once, with the same four arguments, everywhere. Which layers are present is a property of the deployment rather than of the build, so a new environment is a packaging question instead of a code change.


The shape of the answer

Every value the service can be told has a default. Layers override each other in a fixed, documented order. The merged result is decoded into a typed struct and validated once, before anything is constructed. A failure names the offending field and exits.

Activity diagram of configuration resolution, running from built-in defaults through to validation. The six layers are listed in the section below.

config.Load is the whole thing in one call:

cfg := &appConfig{}

err := config.Load(AppName, argConfigDir, appEnvPrefix, cfg)
if err != nil {
	return fmt.Errorf("failed loading config: %w", err)
}

Four arguments. The command name decides the search paths and the config file name. The optional directory from --configDir is searched before them. The environment prefix namespaces the variables. The struct pointer is both the destination and, through two methods, the source of the defaults and the validation rules.

Underneath is Viper, which handles file formats, remote providers and environment binding. The wrapper’s contribution is the order, the struct contract, and the validation step.


The six layers

1. Built-in defaults. Log level, log format, log network and address, and shutdown_timeout, defaulting to 30 seconds. Every service needs these and none of them should have to be written out.

2. Application defaults. Your struct implements SetDefaults and registers one line per key:

func (c *appConfig) SetDefaults(v config.Viper) {
	v.SetDefault("enabled", true)

	v.SetDefault("servers.monitoring.address", ":8071")
	v.SetDefault("servers.monitoring.timeout", 60)
	v.SetDefault("servers.private.address", ":8072")
	v.SetDefault("servers.private.timeout", 60)
	v.SetDefault("servers.public.address", ":8073")
	v.SetDefault("servers.public.timeout", 60)

	v.SetDefault("clients.ipify.address", "https://api.ipify.org")
	v.SetDefault("clients.ipify.timeout", 1)

	v.SetDefault("db.enabled", false)
	v.SetDefault("db.main.driver", dbDriver)
	v.SetDefault("db.main.dsn", "")
	v.SetDefault("db.main.conn_max_open", 50)
	// ...
}

Repetitive, for the reason in the next section.

3. The local config file. config.json, searched in this order, first hit wins:

<the directory given to --configDir>
./
$HOME/.<cmdName>/
/etc/<cmdName>/

/etc/inventorysvc/config.json is where the deb and rpm packages put it, per page 19. $HOME/.inventorysvc/ holds a developer’s personal overrides, ./ is the copy in the working directory during development, and --configDir is what CI and the integration tests point at a fixture.

4. Remote configuration. Selected entirely by environment variables:

<PREFIX>_REMOTECONFIGPROVIDER
<PREFIX>_REMOTECONFIGENDPOINT
<PREFIX>_REMOTECONFIGPATH
<PREFIX>_REMOTECONFIGSECRETKEYRING
<PREFIX>_REMOTECONFIGDATA

Setting the provider to envvar decodes a base64 JSON document out of <PREFIX>_REMOTECONFIGDATA and merges it. That covers the container case where there is no file system to put a config file on and the whole document arrives as one environment variable.

Any other provider name is delegated to a function the application registers:

err := config.Load(AppName, argConfigDir, appEnvPrefix, cfg,
	config.WithRemoteLoader(myConsulLoader),
)

The package does not depend on Consul, etcd or a secrets manager. It gives you the selection settings and calls your function with them, so the service pulls in only the client it actually uses.

5. Environment variables. Applied last over everything above, so they carry the values that differ per deployment and the secrets that must not sit in a file:

INVENTORYSVC_SERVERS_PUBLIC_ADDRESS=":9000"
INVENTORYSVC_DB_MAIN_DSN="user:pass@tcp(db:3306)/inventorysvc?parseTime=true"
INVENTORYSVC_LOG_LEVEL="INFO"

The prefix, then the config key with dots replaced by underscores, upper-cased.

Secrets belong in this layer, for a mechanical reason. A password written into config.json is in the repository, in the image layer that copied it, in the deb or rpm that packaged it, and in every registry cache and backup of those. Removing it later does not remove it from any of them. Set as an environment variable by whatever starts the process, it stays out of every static artefact, and the same image can be handed a different database password in each environment without being rebuilt. That is the layer a Kubernetes Secret or a Vault template feeds, which page 19 shows.

A process environment is readable through /proc/<pid>/environ, through docker inspect, and in a crash dump, so a secret there is protected by the platform rather than concealed. Page 06’s redaction covers the other half, because a DSN that loaded correctly can still reach a log line through an error message.

6. Validation. Validate() runs on the decoded struct. Everything before this point was merging. This is the step that decides whether the process starts.


The rule that catches people

A key needs a registered default to be overridable from the environment.

Viper builds its list of known keys from the defaults, the config file and the remote source. Environment binding is checked against that list. A key that has no default, and appears in no file, is not a key Viper knows about, so INVENTORYSVC_FEATURE_NEWTHING=true sets an environment variable that nothing reads.

The failure is silent in the worst way. There is no error, no warning, and no log line. The service starts, the value is the zero value, and the person who set the variable has every reason to believe it took effect.

Hence the repetition in SetDefaults. Registering a default for every configurable key, including the ones whose default is the empty string, is what makes the key exist:

v.SetDefault("db.main.dsn", "")

That line looks like it does nothing. It is the line that makes INVENTORYSVC_DB_MAIN_DSN work.


The struct is the schema

type cfgServer struct {
	Address string `mapstructure:"address" validate:"required,hostname_port"`
	Timeout int    `mapstructure:"timeout" validate:"required,min=1"`
}

type cfgServers struct {
	Monitoring cfgServerMonitoring `mapstructure:"monitoring" validate:"required"`
	Private    cfgServerPrivate    `mapstructure:"private"    validate:"required"`
	Public     cfgServerPublic     `mapstructure:"public"     validate:"required"`
}

type appConfig struct {
	config.BaseConfig `mapstructure:",squash" validate:"required"`

	Enabled bool       `mapstructure:"enabled"`
	Servers cfgServers `mapstructure:"servers" validate:"required"`
	Clients cfgClients `mapstructure:"clients" validate:"required"`
	DB      cfgDatabases `mapstructure:"db"     validate:"-"`
}

Two tags per field. mapstructure names the configuration key, and mapstructure is what decodes the merged map into the struct. validate carries the rules for go-playground/validator.

mapstructure:",squash" embeds config.BaseConfig’s fields at the top level, so log.level and shutdown_timeout are root keys rather than nested under base.

The rules double as documentation. hostname_port on a server address rejects 8073 and http://localhost:8073 and accepts :8073. min=1 on a timeout rejects zero, which is the value that means “no timeout” in most Go APIs and is almost never what a configuration file intends.

Distinct types for identical shapes

type cfgServerMonitoring cfgServer
type cfgServerPrivate cfgServer
type cfgServerPublic cfgServer

Three named types with the same fields. The compiler will not let you pass a cfgServerPublic where a cfgServerMonitoring is expected, so the arguments to the three near-identical server constructors cannot be swapped by accident. Given what page 17 says about which listener a route lands on, wiring the public server’s routes to the monitoring address is a mistake worth making impossible.


Conditional validation

The database is optional. When it is off, the service should not need to supply a DSN, and when it is on, a missing DSN should stop the process.

type cfgDatabases struct {
	Enabled bool  `mapstructure:"enabled"`
	Main    cfgDB `mapstructure:"main"    validate:"required_if=Enabled true"`
	Read    cfgDB `mapstructure:"read"    validate:"required_if=Enabled true"`
}

The struct tag handles the top level. The nested fields need a second step, because a validator that descends into cfgDB unconditionally would demand a DSN even with the database disabled. Hence the validate:"-" on appConfig.DB, which skips the automatic descent, and an explicit second pass:

func (c *appConfig) Validate() error {
	opts := []validator.Option{
		validator.WithFieldNameTag(fieldTagName),
		validator.WithCustomValidationTags(validator.CustomValidationTags()),
		validator.WithErrorTemplates(validator.ErrorTemplates()),
	}

	v, err := validatorNewFn(opts...)
	if err != nil {
		return err
	}

	err = v.ValidateStruct(c)
	if err != nil {
		return err
	}

	if c.DB.Enabled {
		return v.ValidateStruct(&c.DB)
	}

	return nil
}

WithFieldNameTag("mapstructure") is the line that makes the error message usable. Without it the validator reports Go field names, so a failure reads appConfig.DB.Main.DSN. With it the message names db.main.dsn, which is the key the operator has to go and fix. The two are similar enough that the difference looks cosmetic and different enough to send someone hunting through source code.

validatorNewFn is a package-level variable holding validator.New, the same seam as exitFn on page 03: a test replaces it to exercise the branch where constructing the validator fails.


Adding your own rule

WithCustomValidationTags(validator.CustomValidationTags()) registers the package’s extra tags: e164noplus, zipcode, usstate, usterritory, datetime_rfc3339, datetime_rfc3339_relaxed, and falseif. Your own is a function and a map entry:

func validateRetention(fl vt.FieldLevel) bool {
	d := time.Duration(fl.Field().Int()) * 24 * time.Hour
	return d >= 24*time.Hour && d <= 365*24*time.Hour
}

opts := []validator.Option{
	validator.WithFieldNameTag(fieldTagName),
	validator.WithCustomValidationTags(map[string]vt.Func{
		"retention": validateRetention,
	}),
	validator.WithErrorTemplates(map[string]string{
		"retention": "{{.Namespace}} must be between one day and one year",
	}),
}

Then validate:"required,retention" on the field. The template receives the error value, so it has the namespace, field name, tag, parameter and the actual value to interpolate.

The same package validates request payloads in page 15, with the opposite failure behaviour: a configuration failure stops the process, a request failure returns 422 and the service keeps serving.


Adding a configuration section

Make the edits in this order.

// 1. The type, with both tags.
type cfgCatalog struct {
	MaxPageSize   int `mapstructure:"max_page_size"  validate:"required,min=1,max=1000"`
	RetentionDays int `mapstructure:"retention_days" validate:"required,retention"`
}

// 2. The field on appConfig.
type appConfig struct {
	// ...
	Catalog cfgCatalog `mapstructure:"catalog" validate:"required"`
}

// 3. A default for every key, including the ones with an empty default.
func (c *appConfig) SetDefaults(v config.Viper) {
	// ...
	v.SetDefault("catalog.max_page_size", 100)
	v.SetDefault("catalog.retention_days", 90)
}

Fourth, update resources/etc/<app>/config.json, resources/test/etc/<app>/config.json and resources/etc/<app>/config.schema.json. The JSON Schema is checked against both config files by make confcheck in CI, and the root and section objects are additionalProperties: false, so a section added to the struct and not to the schema fails the build rather than shipping undocumented. The prose reference in doc/CONFIG.md is maintained by hand; add the new keys there as well.


What to put in configuration, and what not to

Configuration is for values that differ between deployments of the same build: addresses, timeouts, pool sizes, feature switches, credentials.

Values that differ between builds. The version and the release number are compile-time constants injected by the linker, per page 19. A configurable version string is a version string that can lie.

Values with one correct answer. A constant in a configuration file is a constant somebody will eventually change, in one environment, at an unhelpful moment. If there is no deployment where a different value is right, it belongs in the code.

Credentials are in that list, with the placement rule from the fifth layer above: the environment or a remote provider, never a file that ships inside an artefact.


Next: The Lifecycle: One Context, One Channel, One Wait Group.