Configuration
Configuration is a struct. Its tags say where each field comes from and what happens when the value is absent, and the whole thing is read once at start-up.
// Settings is the service configuration, read once at start-up from the
// environment and from a .env file when one is present.
type Settings struct {
// AppName titles the API in the generated documentation.
AppName string `env:"APP_NAME" default:"Awesome API"`
// AdminEmail is who to contact about the API.
AdminEmail string `env:"ADMIN_EMAIL" required:"true"`
// ItemsPerUser caps how many items one user may hold.
ItemsPerUser int `env:"ITEMS_PER_USER" default:"50"`
// Addr is the address the server listens on.
Addr string `env:"ADDR" default:":8080"`
// AdminToken guards the admin subtree. It is marked secret so that a
// malformed value never appears in a start-up error.
AdminToken string `env:"ADMIN_TOKEN" secret:"true" required:"true"`
// TrustedProxies lists the proxies whose X-Forwarded-For header is
// believed, as a comma-separated list of addresses or CIDR prefixes.
TrustedProxies []string `env:"TRUSTED_PROXIES"`
}
// LoadSettings reads the configuration, stopping the process if a required
// value is missing.
func LoadSettings() Settings {
return muzak.MustLoadConfig[Settings](muzak.EnvFile(".env"))
}
Publishing the value with WithSingleton is what lets a handler read it with
muzak.From[core.Settings](ctx) rather than through a package-level variable.
Loading
settings, err := muzak.LoadConfig[Settings](muzak.EnvFile(".env"))
if err != nil {
return err
}
MustLoadConfig is the same thing for a program that cannot run without its
configuration. It panics, which is the right behaviour in a main function: a service
missing a required setting should stop immediately and visibly rather than start in an
undefined state. Prefer LoadConfig anywhere the failure can be handled.
Every problem found is reported together:
muzak: configuration could not be loaded:
muzak: ADMIN_EMAIL is required but was not set in the environment or .env
muzak: ITEMS_PER_USER must be a valid integer (got "fifty")
muzak: ADMIN_TOKEN could not be parsed (value hidden because the field is marked secret)
A first run in a new environment lists everything missing at once instead of one thing per attempt.
The tags
| Tag | Effect |
|---|---|
env:"NAME" | The variable to read. Without it, the name is derived from the field |
env:"-" | Skip the field entirely |
default:"value" | The value used when no source holds the variable |
required:"true" | An absent variable is an error |
secret:"true" | Keep the value out of the error produced when it fails to parse |
A field with neither default nor required:"true" is simply left at its zero value when
nothing supplies it.
Derived names
Without an env tag, the name is the field name upper-cased with underscores between
words:
| Field | Variable |
|---|---|
AppName | APP_NAME |
ItemsPerUser | ITEMS_PER_USER |
DatabaseURL | DATABASE_URL |
APIKey | API_KEY |
Secrets
secret:"true" matters for the error path. A malformed value normally appears in the
message, which is what makes a typo obvious, but a malformed credential in a start-up log
is a credential in a log.
DatabaseURL string `env:"DATABASE_URL" secret:"true" required:"true"`
That applies to a custom type's own error too. A type implementing
encoding.TextUnmarshaler writes its own message, which may well echo the text it was
given, so a secret field's failure is replaced wholesale rather than filtered.
Types
Fields are converted with the same setters the request binder uses, so configuration and
requests agree on what an int is:
string,bool, every integer width,float32andfloat64time.Duration, written as1500ms,30s,1h30m- any type implementing
encoding.TextUnmarshaler, which coverstime.Timeanduuid.UUID - a slice of any of the above, written as a comma-separated list
type Settings struct {
Addr string `env:"ADDR" default:":8080"`
ReadTimeout time.Duration `env:"READ_TIMEOUT" default:"30s"`
Workers int `env:"WORKERS" default:"4"`
Debug bool `env:"DEBUG" default:"false"`
AllowedOrigins []string `env:"ALLOWED_ORIGINS"`
}
ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com
Entries are split on commas and trimmed of surrounding spaces. An empty variable yields an empty slice rather than a slice holding one empty string.
Composing settings
An embedded struct is flattened, so a group of settings several services share is declared once and reused.
type DatabaseSettings struct {
URL string `env:"DATABASE_URL" required:"true" secret:"true"`
MaxConns int `env:"DATABASE_MAX_CONNS" default:"10"`
DialTimeout time.Duration `env:"DATABASE_DIAL_TIMEOUT" default:"5s"`
}
type Settings struct {
DatabaseSettings
AppName string `env:"APP_NAME" default:"Awesome API"`
Addr string `env:"ADDR" default:":8080"`
}
settings.URL and settings.AppName both read as if declared on Settings.
Sources
The process environment is consulted first, then each source in the order it was added, and the first source holding a name wins. That ordering is what lets a deployed value beat a checked-in file.
settings, err := muzak.LoadConfig[Settings](
muzak.EnvFile(".env"),
muzak.EnvPrefix("AWESOME_"),
)
| Option | What it adds |
|---|---|
EnvFile(path) | A dotenv file. A missing file is not an error; a malformed one is |
ConfigValues(map) | An explicit set of values, which is what a test uses |
WithConfigSource(src) | Anything implementing ConfigSource, such as a secret manager |
EnvPrefix(prefix) | Requires every name to carry the prefix, so APP_NAME is read as AWESOME_APP_NAME |
WithoutEnvironment() | Stops the process environment being consulted at all |
The dotenv format
# Copy to .env and adjust. Values exported in the real environment always win
# over this file, so a container runtime can override anything here.
APP_NAME=Awesome API
ADMIN_EMAIL=admin@example.com
ITEMS_PER_USER=50
ADDR=:8080
# Guards the /admin subtree. Compared in constant time.
ADMIN_TOKEN=coneofsilence
export DATABASE_URL="postgres://localhost:5432/awesome?sslmode=disable"
# Proxies whose X-Forwarded-For header is believed, as a comma-separated list.
# TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
One KEY=VALUE pair per line. Blank lines and lines beginning with # are ignored, an
optional leading export is stripped, and a value may be wrapped in single or double
quotes to preserve surrounding spaces or a #. Escape sequences are interpreted only
inside double quotes.
Keep the file out of version control and check in a .env.example beside it.
Configuration in tests
Pin the values a test needs and shut the environment out, so a test does not inherit whatever the developer happens to have exported.
func TestLoadSettings(t *testing.T) {
settings, err := muzak.LoadConfig[core.Settings](
muzak.WithoutEnvironment(),
muzak.ConfigValues(map[string]string{
"ADMIN_EMAIL": "admin@example.com",
"ADMIN_TOKEN": "coneofsilence",
}),
)
if err != nil {
t.Fatalf("LoadConfig = %v", err)
}
if settings.ItemsPerUser != 50 {
t.Errorf("ItemsPerUser = %d, want the default 50", settings.ItemsPerUser)
}
}
A source of your own
type vaultSource struct {
client *vault.Client
path string
}
func (v vaultSource) Name() string { return "vault:" + v.path }
func (v vaultSource) Lookup(key string) (string, bool) {
secret, err := v.client.Read(v.path + "/" + key)
if err != nil || secret == nil {
return "", false
}
return secret.Value, true
}
settings, err := muzak.LoadConfig[Settings](
muzak.WithConfigSource(vaultSource{client: client, path: "secret/awesome"}),
muzak.EnvFile(".env"),
)
Name appears in the error listing the sources that were searched. A present but empty
value must be reported as present, so that an explicitly blank setting can override a
default.
Where to go next
Lifecycle covers building resources from those settings, and Logging covers the logger the application uses while it does.