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

TagEffect
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:

FieldVariable
AppNameAPP_NAME
ItemsPerUserITEMS_PER_USER
DatabaseURLDATABASE_URL
APIKeyAPI_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, float32 and float64
  • time.Duration, written as 1500ms, 30s, 1h30m
  • any type implementing encoding.TextUnmarshaler, which covers time.Time and uuid.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_"),
)
OptionWhat 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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.