Table of contents
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.
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.