Skip to content

Config

gas/config reads configuration from one or more sources, merges it into one tree of lowercased keys, and binds that tree into Go structs with validation. *config.Config satisfies gas.ConfigProvider, the interface core and every module read their settings through. Core binds its own server settings from it, and each module binds its section (database.*, email.*, jwt.*, …) in its Init.

Config is not a service. It has no Init or Close; you build and load it before the app, so a bad configuration fails before any service starts, then register the loaded value as an instance.

Terminal window
go get github.com/gasmod/gas/config
main.go
// Config is built and loaded before the app, so a bad configuration
// fails before any service starts. Later providers override earlier ones.
cfg := config.New(
config.WithProvider(providers.NewJSONProvider(
providers.WithJSONFilePath("config.json"),
)),
config.WithProvider(providers.NewEnvProvider()), // overrides the file
config.WithExtension(gasenv.NewExtension()),
)
if err := cfg.Load(); err != nil {
log.Fatal(err)
}
app := gas.NewApp(
// Registered as an instance under the interface every module binds
// its own settings through.
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
gas.WithSingletonService[*Notes](NewNotes),
)

config.New takes providers and extensions and returns the config without reading anything. Load reads every source. When New is given no provider at all, it adds providers.NewEnvProvider() by itself; as soon as you pass one provider explicitly, add the environment provider yourself if you still want environment variables.

This is the rule for every config key on this site. To set a key from the environment, uppercase it and replace each . with a double underscore:

Config key Environment variable
server.port SERVER__PORT
database.dsn DATABASE__DSN
email.fromemail EMAIL__FROMEMAIL
jwt.signingkey JWT__SIGNINGKEY

The environment provider lowercases each variable name and splits it on __ into levels, so names are matched case-insensitively and a single underscore stays part of the key: REQUEST_TIMEOUT is the key request_timeout. Values arrive as strings; binding converts them to the field’s type.

WithEnvPrefix keeps only the variables that start with the prefix (compared case-insensitively) and cuts it off literally, so include the trailing underscore. With the prefix APP_, the variable for database.dsn is APP_DATABASE__DSN:

// Only variables starting with APP_ are read, and the prefix is cut off
// literally, so APP_DATABASE__DSN becomes database.dsn.
func prefixedEnv() *providers.EnvProvider {
return providers.NewEnvProvider(providers.WithEnvPrefix("APP_"))
}

A fixed list of system variables, such as PATH and HOME, is never read. See The environment provider.

Provider Source
providers.NewEnvProvider() The process environment
providers.NewDotEnvProvider() A .env file
providers.NewJSONProvider() A JSON file, from disk or an fs.FS
secretsmanager.NewProvider() AWS Secrets Manager, fetched at load

Load reads the providers in registration order and merges them, so a later provider overrides the keys it defines and leaves the rest alone. See Load order for the full sequence.

// Reads ./.env with the same naming rule as the environment. A missing file
// is an error unless WithDotEnvFileNotFoundPanic(false) is passed.
func localDotEnv() *providers.DotEnvProvider {
return providers.NewDotEnvProvider(
providers.WithDotEnvFileNotFoundPanic(false),
)
}

NewDotEnvProvider reads .env from the working directory unless WithDotEnvFilePath says otherwise, and turns each KEY=VALUE line into a key with the same rule as the environment (__ splits levels; change it with WithDotEnvSeparator). A missing file is an error by default.

NewJSONProvider reads one JSON object from the path set with WithJSONFilePath. There is no default path: without one, Load fails with providers.ErrJSONFilePathNotSet. JSON numbers arrive as float64, and a number has no unit, so a duration in a JSON file is written as a string such as "30s" (see durations).

To ship a file inside the binary, read it from an embed.FS:

//go:embed defaults.json
var defaultsFS embed.FS
// Reads defaults.json from the binary instead of the working directory.
func embeddedDefaults() *providers.JSONProvider {
return providers.NewJSONProvider(
providers.WithJSONFileFS(defaultsFS),
providers.WithJSONFilePath("defaults.json"),
)
}

See File providers for the path checks, the size limit and the .env syntax.

// Fetches secrets when the config loads. app/prod must hold a JSON object,
// merged at the root; app/db-password is placed at database.password.
func secrets() *secretsmanager.Provider {
return secretsmanager.NewProvider(
secretsmanager.WithRegion("eu-west-1"),
secretsmanager.WithSecret("app/prod"),
secretsmanager.WithSecretAtKey("app/db-password", "database.password"),
)
}

The provider lives in github.com/gasmod/gas/config/providers/secretsmanager. Secrets are fetched on every load, in registration order, and merged with later ones winning. A secret registered with WithSecret must hold a JSON object, which is merged at the root. WithSecretAtKey places a secret at a dot-separated key: a JSON object is nested there, and any other value is stored there as a string.

See Secrets Manager client and timeouts for the AWS client options.

Get("database.host") walks the dot-separated path, case-insensitively, and returns nil when the key is missing; Find also reports whether it exists. Get, Find and Values return deep copies, so changing a returned map does not change the config.

Set(key, value) writes a value, overriding what is there. SetDefault(key, value) writes only when the key is not set yet. SetDefaults(values) does the same for a whole map[string]any or struct; it returns config.ErrNilValues for nil. Map keys passed to SetDefaults are taken as one level each and not split on ., so pass nested maps.

notes.go
// NotesConfig is bound from the notes.* keys. Fields match their lowercased
// name: notes.pagesize, notes.timeout, notes.tags.
type NotesConfig struct {
gas.WithGasEnv
Notes struct {
PageSize int `validate:"min=1,max=500"`
Timeout time.Duration
Tags []string
}
}
// Notes binds its settings in Init, the way every Gas module does.
type Notes struct {
cfgProvider gas.ConfigProvider
cfg NotesConfig
}
func NewNotes(cfgProvider gas.ConfigProvider) (*Notes, error) {
return &Notes{cfgProvider: cfgProvider}, nil
}
func (n *Notes) Name() string { return "notes" }
func (n *Notes) Init(context.Context) error {
// Defaults first: Bind sets only the fields whose keys are present.
n.cfg.Notes.PageSize = 50
n.cfg.Notes.Timeout = 5 * time.Second
if err := n.cfgProvider.Bind(&n.cfg); err != nil {
return fmt.Errorf("notes: %w", err)
}
if n.cfg.GasEnv.IsProduction() {
n.cfg.Notes.Timeout = 2 * time.Second
}
return nil
}
func (n *Notes) Close(context.Context) error { return nil }

Bind(&dest) fills a struct from the whole tree. Each field matches its json tag, or else its lowercased field name; when both are present in the config, the tag wins.

Bind sets only the fields whose keys are present and leaves every other field as it was, so the usual pattern is to fill in defaults and bind over them, as every Gas module does with its DefaultConfig().

After binding, Bind validates the struct with go-playground/validator against its validate tags. Pass config.WithValidate(false) to one Bind call to skip it, or config.WithValidator(v) to config.New to use your own validator instance with custom rules registered. A binding failure is returned as bind config: ... and a validation failure as validate config: ....

See Binding rules for field matching and the supported types.

The gasenv extension (github.com/gasmod/gas/config/extensions/gasenv) resolves which environment the app runs in and stores it at the config key gasenv. The allowed values are development, testing, staging and production, compared case-sensitively. A value set by a provider comes first, then the GAS_ENV variable, then development.

Embed gas.WithGasEnv in a config struct and Bind fills its GasEnv field, which has IsDevelopment, IsTesting, IsStaging, IsProduction, IsDevelopmentLike (development or testing) and IsProductionLike (staging or production). Every Gas module’s config embeds it except Storage’s: the s3 and dir configs do not.

To ask before the app exists, keep the extension; it has the same helpers plus Current():

// Keep the extension to ask for the environment before the app exists.
func loadWithEnv() (*config.Config, *gasenv.Extension, error) {
envExt := gasenv.NewExtension(
gasenv.WithAllowedEnvs(gasenv.Development, gasenv.Production),
)
cfg := config.New(config.WithExtension(envExt))
if err := cfg.Load(); err != nil {
return nil, nil, err
}
if envExt.IsProduction() {
cfg.Set("notes.timeout", "2s")
}
return cfg, envExt, nil
}

See Environment resolution for the exact rules and the extension’s options.

An extension implements config.Extension: Name(), PreLoad(ctx, cfg) and PostLoad(ctx, cfg), called around the providers as described in Load order.

// stampExtension records when the configuration was loaded. PreLoad runs
// before any provider, PostLoad after all of them.
type stampExtension struct{}
func (stampExtension) Name() string { return "stamp" }
func (stampExtension) PreLoad(_ context.Context, cfg *config.Config) error {
cfg.SetDefault("app.region", "eu-west-1")
return nil
}
func (stampExtension) PostLoad(_ context.Context, cfg *config.Config) error {
cfg.Set("app.loadedat", time.Now().UTC().Format(time.RFC3339))
return nil
}

A failing hook stops Load with an error wrapping config.ErrExtensionPreLoadHookFailed or config.ErrExtensionPostLoadHookFailed and the extension’s name.

A provider implements providers.Provider: Name() string and Load() (map[string]any, error), returning a nested map. If it also implements providers.ContextProvider, LoadContext(ctx) (map[string]any, error), LoadWithContext and LoadProviderContext call that with their context instead, which matters for anything that does network calls.

configtest.MockConfig satisfies gas.ConfigProvider. Each method records the call in Calls and delegates to its Fn field (GetFn, BindFn, …) when set; with no Fn, it returns the zero value, so Bind leaves the struct untouched and returns nil. CallCount(method) and Reset() help with assertions.

When a test needs real Get, Find and Bind behavior over known values, configtest.NewMockConfigWithValues builds a mock whose Fn fields delegate to a real *config.Config seeded with the values you pass. It does not read the process environment, so the machine running the test cannot leak into it.

notes_test.go
// NewMockConfigWithValues delegates to a real config seeded with the given
// values, without reading the process environment. Seed nested maps, not
// dotted keys.
func TestNotesBindsPageSize(t *testing.T) {
cfg, err := configtest.NewMockConfigWithValues(map[string]any{
"notes": map[string]any{"pagesize": 20, "timeout": "1s"},
})
if err != nil {
t.Fatal(err)
}
notes, err := NewNotes(cfg)
if err != nil {
t.Fatal(err)
}
if err := notes.Init(context.Background()); err != nil {
t.Fatal(err)
}
if notes.cfg.Notes.PageSize != 20 || notes.cfg.Notes.Timeout != time.Second {
t.Fatalf("got %+v", notes.cfg.Notes)
}
if cfg.CallCount("Bind") != 1 {
t.Fatalf("Bind called %d times, want 1", cfg.CallCount("Bind"))
}
}

gas/config reads one key itself, through the gasenv extension:

Key Type Default Description
gasenv string GAS_ENV when it holds an allowed value, else development The resolved environment, set by the extension during Load. See Environments.

Core binds its server settings (server.host, server.port, the server timeouts) from the same config; see Deployment.

Error Returned when
config.ErrProviderLoadFailed A provider’s Load failed during Load or LoadProvider. Wraps the provider’s own error.
config.ErrExtensionPreLoadHookFailed An extension’s PreLoad failed.
config.ErrExtensionPostLoadHookFailed An extension’s PostLoad failed.
config.ErrNilValues SetDefaults(nil).
providers.ErrJSONFilePathNotSet A JSON provider has no path.
providers.ErrJSONFileReadFailed The JSON file could not be read, including a path outside the working directory, a symlink, or a file over 1 MiB.
providers.ErrJSONDecodeFailed The file is not valid JSON, or holds an array, string, number or boolean. A file holding null loads with no error and no values (KI-047 in Known issues).
providers.ErrDotEnvFilePathNotSet A .env provider’s path was set to "".
providers.ErrDotEnvFileReadFailed The .env file could not be read, or is missing while missing files are errors.
providers.ErrDotEnvParseFailed The .env file could not be parsed, for example a quoted value that is never closed.
providers.ErrSetEnv Copying an entry into the process environment failed.
secretsmanager.ErrNoSecretsConfigured No secret was registered.
secretsmanager.ErrClientInitFailed The AWS configuration could not be loaded.
secretsmanager.ErrSecretFetchFailed GetSecretValue failed. The error names the secret.
secretsmanager.ErrSecretDecodeFailed A secret registered with WithSecret is not a JSON object.

The provider errors reach Load’s caller wrapped in config.ErrProviderLoadFailed, and errors.Is finds both. The environment provider never fails.

The sections above cover everyday use. This section is the full contract, for when an exact edge case matters.

Load runs in this order:

  1. Every extension’s PreLoad hook, in registration order.
  2. Every provider’s Load, in registration order. All of them are read before anything is merged, so if one fails, Load returns an error wrapping config.ErrProviderLoadFailed with the provider’s name and merges none of them.
  3. The provider results are merged in registration order. A later provider overrides the keys it defines and leaves the rest alone; nested maps are merged key by key, not replaced.
  4. Every extension’s PostLoad hook, in registration order.

Keys are lowercased and trimmed at every level as they are merged, so Database.DSN from a JSON file and DATABASE__DSN from the environment land on the same key, database.dsn.

Load merges into the values the config already holds, so a value set with Set before Load survives unless a provider defines the same key.

LoadProvider(p) adds one more provider after the fact: it loads p, merges its values over the current ones, and registers it. A provider whose load fails is not registered.

WithEnvSeparator changes __ to another separator; an empty separator keeps every name as one flat key. The variable name is lowercased before it is split, so a separator containing uppercase letters never matches, for WithEnvSeparator and WithDotEnvSeparator alike (KI-055 in Known issues).

In a .env file, quoted values may span lines and keep #; outside quotes, # starts a comment. The .env provider has no prefix option.

A missing file fails Load with an error wrapping providers.ErrDotEnvFileReadFailed. WithDotEnvFileNotFoundPanic(false) makes a missing file yield no values instead; despite the option’s name, nothing panics in either case.

WithDotEnvFileAppendToOSEnv(true) also copies the entries into the process environment, except the names in the list above; it is off by default, because those values would then be inherited by every child process.

By default the JSON and .env providers read through a guarded file system: the path must resolve inside the working directory, its last element must not be a symlink, and the file must be at most 1 MiB (1,048,576 bytes). WithJSONFileFS and WithDotEnvFileFS read from any fs.FS instead, such as an embed.FS, without those checks.

WithRegion, WithEndpoint (for example LocalStack) and WithStaticCredentials configure the AWS client; static credentials are used only when both values are set, and otherwise the default AWS configuration and credential chain apply. WithClient injects a client instead and makes those options ignored.

The provider’s own Load() bounds the whole fetch with a 10-second timeout (secretsmanager.DefaultTimeout), which WithTimeout changes; zero or a negative duration means no timeout.

config.Config never calls that method: Load and LoadWithContext call the provider’s LoadContext, which applies no timeout, so through the config the context passed to LoadWithContext is the only bound (Load passes context.Background(), which has none). LoadWithContext passes the caller’s context through unchanged. This is tracked as KI-046 in Known issues.

Registering no secret at all makes Load fail with secretsmanager.ErrNoSecretsConfigured.

Keys are lowercase, so a tag with uppercase letters never matches and the field falls back to its name. Fields of an embedded struct bind at the level of the struct that embeds them, which is how gas.WithGasEnv picks up the top-level gasenv key.

Supported field types are every int, uint and float size, bool, string, time.Duration, slices, fixed-size arrays, maps, nested structs, embedded structs and pointers to any of these (allocated as needed). Strings are converted where needed: "true" to a bool, "8080" to an int, and a comma-separated string such as "a, b ,c" to a slice, with each element trimmed.

A fixed-size array needs a real list, and a list with more elements than the array holds is an error rather than a silent truncation. A number too large for its field is an error.

Resolution happens during Load:

  1. Before the providers load, the extension reads the OS variable GAS_ENV. If it is unset or not an allowed value, it takes the fallback, development. The result is set as a default for the key.
  2. After the providers load, an allowed value that a provider set at the key wins. Any other value there, including one a provider set that is not allowed, is replaced with the fallback.

So the order is: a provider’s value, then GAS_ENV, then development. An invalid value is never reported as an error. Because the environment provider reads every variable, GASENV=production is a provider value for the key and takes precedence over GAS_ENV.

Without the extension, nothing sets the key and GasEnv stays empty unless a provider sets gasenv.

Option Effect
WithEnvVarName Reads another OS variable instead of GAS_ENV
WithConfigKey Stores the result under another key, and also writes it to gasenv so gas.WithGasEnv still binds
WithAllowedEnvs Narrows the allowed values
WithDefault Changes the fallback

The fallback should be one of the allowed values, which is not checked.