Configuration
This page explains how configuration is loaded: where values come from, which layer wins, how several files merge, and what the framework validates before your app starts. For the list of keys themselves — every name, default and meaning — see the Configuration reference.
Values resolve through a layered precedence chain:
struct defaults < nucleus.yml file(s) < NUCLEUS_* env vars
nucleus.yml at the project root is the primary source. A NUCLEUS_-prefixed
environment variable overrides that key whether a file set it or it was left
at its struct default.
Unknown NUCLEUS_-prefixed variables are silently ignored. The environment is
a shared namespace, so a stray variable is not treated as a mistake — unlike
an unknown key in a config file, which is.
Anatomy of nucleus.yml
# illustrative — the schema is FLAT; see the Configuration reference for the full list
env: development # development | staging | production
debug: true
# Server
host: 0.0.0.0
port: 8080
read_timeout: 30s
write_timeout: 60s
idle_timeout: 120s
# Database
database_default: primary
databases:
primary:
url: sqlite://app.db # sqlite:// | postgres:// | mysql://
# Sessions
session_store: memory # memory | sql | redis
session_cookie_secure: true # default: true — opt out with false for local http://
session_cookie_samesite: lax
# Auth (the JWT secret is read from an env var, never from this file)
jwt_issuer: myapp
jwt_expiry: 24h
# Mail
mail_driver: noop # noop | smtp (vendor drivers ship as plugins)
# Observability (set otlp_endpoint to enable OpenTelemetry export)
log_level: info # debug | info | warn | error
log_format: json # text | json
# RBAC (Casbin policy file for the core authz enforcer)
rbac_policy_file: ""
# Multi-tenant
multitenant:
enabled: false
resolver: subdomain # subdomain | header
The above is illustrative — the canonical, exhaustive list is the Configuration reference.
The dev profile: boot without backing services
A realistic production config names up to six backing services: the database and its replicas, Redis for sessions, Redis for jobs, object storage, and SMTP. Requiring all of them locally is a poor development experience.
profile: dev boots the same file with the no-dependency counterpart of
each selection, so local development and smoke tests need no Docker:
profile: dev # or: NUCLEUS_PROFILE=dev, leaving the file untouched
Under the profile, sessions and jobs run in memory, storage uses the local
filesystem, mail is the no-op sender, and the database becomes
sqlite://nucleus_dev.db (a URL that is already SQLite is kept as-is;
extra database aliases such as replicas are dropped). Every other key —
routes, policies, module config — keeps its configured value. Unknown
profile values fail config load.
Multi-file config loader
AppBuilder.FromConfigFile accepts one or more file paths. Files are
merged left-to-right: the last file wins for scalar keys, maps
deep-merge, and lists replace by default.
nucleus.New().
WithConfigStrict(true).
FromConfigFile(
"config/nucleus.yml",
"config/nucleus.production.yml",
).
Mount(articles.Module).
Start()
Supported formats: .yaml / .yml, .toml, .json. Any other
extension returns ErrUnsupportedConfigFormat.
Merge precedence: struct defaults < file[0] < file[1] < … < file[N-1]
List operators: _append and _remove
Two suffix operators provide additive and subtractive list semantics that survive every supported parser format:
# illustrative — shows the _append / _remove operator syntax on any list key
# Add items without replacing the base list
<list_key>_append:
- https://staging.example.com
# Remove an item that was set in a base file
<list_key>_remove:
- https://old.example.com
The operator keys (<key>_append, <key>_remove) are stripped from
the merged output before schema validation runs.
null reverts to default
Setting a key to null (or ~ in YAML) reverts it to the framework's
struct default:
log_level: null # reverts to "info"
Exception — non-nullable security keys: certain keys whose null
revert would be a silent security degradation are rejected at boot with
ErrSecurityKeyNotNullable. The current non-nullable key is
jwt_secret. Setting it to null is a hard error.
Per-file size cap
Each file is read with a 1 MiB cap (MaxConfigFileBytes). A larger file
is rejected with ErrConfigFileTooLarge before any parser runs.
The cap exists because parsers alone cannot prevent denial-of-service inputs such as YAML anchor expansion or deeply nested JSON. Refusing to read the file at all does.
Validation layers (fail-fast at load)
Configuration loading is deliberately multi-layered. Each layer catches a different class of mistake as early as possible, so a misconfigured app fails at boot with an actionable error instead of at the first request:
| # | Layer | Catches | Status |
|---|---|---|---|
| 1 | Syntactic | Unparseable YAML/TOML/JSON, file > 1 MiB, mixed-format lists when WithConfigStrict(true). Errors: ErrUnsupportedConfigFormat, ErrConfigFileTooLarge, ErrMixedConfigFormats. | shipped |
| 2 | Schema | Keys outside the registered app.Config schema (with did-you-mean hint), unknown _append/_remove targets, non-nullable security keys set to null. Errors: ErrUnknownConfigKeys, ErrSecurityKeyNotNullable. | shipped |
| 3 | Field-semantic | Out-of-range values (e.g. negative timeouts, port outside [0, 65535]), invalid enum values (session_store, log_level, log_format, session_cookie_samesite), unparseable durations. | shipped |
| 4 | Referential | Settings that are each valid but contradict one another: a module pointing at a database alias that does not exist, a session or cache reference with no provider configured, session_cookie_samesite: none without session_cookie_secure, or a __Host-/__Secure- cookie name whose attributes the browser would reject. | shipped |
| 5 | Module-specific | At Run time, each mounted module's modules.<name>.* YAML subtree is bound into the module's typed Module[C].Config, default: struct tags fill still-zero fields, and validate: struct tags are enforced. A failure surfaces as ErrInvalidModuleConfig. | shipped |
Layers 1 through 4 run on every load, through every entry point: the
builder, go run ., and every nucleus subcommand that reads a config file.
That matters more than it sounds — the layers used to run only on the
builder's path, so the same file could be rejected by go run . and accepted
by the CLI. Layer 5 runs at Run time, when modules are mounted.
The one deliberate exception is nucleus config print, which renders the
merged configuration even when it is invalid — you print a broken config
precisely to see what it resolved to — and writes the rejection to stderr
instead of refusing.
Layer 2 also refuses to let a security-critical key be set to null — such a
key must either be absent, so the default applies, or carry a real value.
jwt_secret is the canonical example, called out above.
Module-specific configuration (modules.*)
Each mounted module can carry its own typed config. The framework reads the
modules.<name>.* subtree from your nucleus.yml (or other config files) and
binds it into the module's Module[C].Config field at Run time — not during
FromConfigFile or Mount.
Authoring a typed module config
Annotate the module's config struct with three struct-tag families:
koanf:"<key>"— maps a YAML/TOML/JSON key to the field (same convention asapp.Config).default:"<value>"— fills the field when both the config file and the programmaticConfigbaseline leave it at its zero value.validate:"<rule>"— enforced atRuntime viapkg/validate(go-playground/validator).
// BillingConfig holds billing-module settings.
type BillingConfig struct {
StripeKeyEnv string `koanf:"stripe_key_env" validate:"required"`
WebhookSecretEnv string `koanf:"webhook_secret_env" validate:"required"`
DefaultCurrency string `koanf:"default_currency" default:"usd"`
InvoiceDueDays int `koanf:"invoice_due_days" default:"30" validate:"min=1,max=365"`
}
var Billing = nucleus.Module[BillingConfig]{
Name: "billing",
Prefix: "/billing",
// Routes, OnStart, etc.
}.Build()
The corresponding nucleus.yml block:
modules:
billing:
stripe_key_env: STRIPE_SECRET_KEY
webhook_secret_env: STRIPE_WEBHOOK_SECRET
default_currency: usd
invoice_due_days: 30
Binding and validation at Run
When nucleus.New().FromConfigFile(...).Mount(Billing).Start() runs:
- The
modules.billing.*subtree is sliced out of the merged config. - It is unmarshalled into a fresh
BillingConfigvalue, starting from the programmaticModule[C].Configbaseline. default:tags fill any fields still at their zero value.validate:tags are checked. A failure returnsnucleus.ErrInvalidModuleConfig(wrapping the module name and the failing rule) and aborts startup.
This same sequence runs on the direct-struct nucleus.Run(nucleus.App{...})
surface too, but without the file-binding step (there is no config file on
that path).
Zero-value limitation
default: tags key off the Go zero value, and a field deliberately set to its
zero value is indistinguishable from an unset one. So InvoiceDueDays: 0
receives the tag default at Run time rather than staying at 0. Choose your
defaults with that in mind.
Unmounted modules
If modules.* carries config for a module that was never passed to
Mount(...), startup logs a non-fatal WARN and ignores the block. It is
not rejected, because an overlay file may legitimately pre-stage config for
modules that a given binary does not mount.
What is not supported
- Env-variable override of
modules.*keys is not supported. TheNUCLEUS_*env layer covers only the registeredapp.Configschema keys. To supply module config from the environment, read the env var insideModule[C].OnStartand set the relevant field yourself. nucleus config print --effectivedoes not include module config. Module schemas are open-ended and may carry secrets; there is no framework-level redaction contract formodules.*values. The--effectiveoutput excludes themodules.*namespace.
Unknown-fields handling
By default, any key in a config file that is not part of the
app.Config schema is rejected with ErrUnknownConfigKeys and a
did-you-mean hint (UnknownFieldsStrict mode). This keeps typos from
silently doing nothing.
// Development: downgrade unknown keys to a WARN slog event
nucleus.New().
WithUnknownFields(nucleus.UnknownFieldsWarn).
FromConfigFile("nucleus.yml").
Start()
WithUnknownFields and WithConfigStrict must be called before
FromConfigFile on the same builder chain. Calling them after
FromConfigFile records a deferred error that surfaces at Build /
Start.
NUCLEUS_ENV=production is the operator's escape hatch. When it is set, the
loader forces strict mode back on regardless of any code-level
WithUnknownFields("warn"), and logs a WARN recording the override.
The point is that a build accidentally left in warn mode cannot silently ship to production.
Mixed-format file lists
Passing a mix of YAML, TOML, and JSON paths to FromConfigFile emits a
startup WARN by default and proceeds with the merge. Call
WithConfigStrict(true) before FromConfigFile to reject mixed-format
lists outright with ErrMixedConfigFormats:
nucleus.New().
WithConfigStrict(true). // mixed formats → hard error
FromConfigFile("a.yml", "b.toml"). // returns ErrMixedConfigFormats
Start()
Environment overrides
Any key in nucleus.yml can be overridden by an environment variable
named with the NUCLEUS_ prefix. Nested YAML keys are joined with a
double underscore (__); a single underscore is just part of the
segment name:
NUCLEUS_PORT=9090 nucleus serve
NUCLEUS_DATABASES__PRIMARY__URL="postgres://..." nucleus migrate
NUCLEUS_LOG_LEVEL=debug nucleus serve
This applies both in the lower-level app.LoadConfig path and in the fluent
nucleus.New().FromConfigFile(...) builder path. The full precedence chain
honoured by FromConfigFile is:
struct defaults < file[0] < … < file[N-1] < NUCLEUS_* env vars
Unknown NUCLEUS_-prefixed variables (ones that do not map to a registered
config key) are silently ignored. Env is a shared ambient namespace; an
unrecognised variable is not treated as an authored mistake the way an
unknown key in a config file is.
Booleans accept true|false; durations accept Go duration strings (15s,
2m). Non-nullable security keys (e.g. NUCLEUS_JWT_SECRET) reject an empty
string the same way the file layer rejects null.
Config keys are part of the contract
Every registered config key is part of the stable surface. Unknown keys
reject the load with a did-you-mean hint by default (strict mode). The
pkg/nucleus builder exposes AppBuilder.WithUnknownFields(nucleus.UnknownFieldsWarn) to
downgrade unknown-key failures to WARN-level slog events during
development; NUCLEUS_ENV=production forces strict mode regardless of
the code-level setting.
The freeze tests under contracts/ ensure that:
- no registered config key disappears between releases without a deprecation entry,
- the YAML key shape (path, type) stays intact across versions inside the compatibility SLO window.
See Architecture → Compatibility policy for the full rules.
Diff against the registered schema
nucleus diffsettings prints the values your nucleus.yml resolves to,
including environment overrides. It is the fastest way to debug "why is
this app pointing at the wrong DB":
nucleus diffsettings
nucleus diffsettings --keys database_default,databases.primary.url
The output is deterministic and machine-friendly so you can pipe it to
diff between environments.
Inspect the effective merged config
nucleus config print --effective shows the fully merged view across
one or more config files — including environment-variable overrides — with
a per-key source label so you can see exactly which file or env var each
value came from:
nucleus config print --effective \
--config config/nucleus.yml \
--config config/nucleus.production.yml
Example output (when NUCLEUS_PORT=9090 is set in the environment):
port = 9090 [env:NUCLEUS_PORT]
host = 0.0.0.0 [default]
databases.primary.url = [REDACTED] [yaml:config/nucleus.production.yml:14]
log_level = info [yaml:config/nucleus.yml:8]
Source labels follow these rules:
| Label | Meaning |
|---|---|
[default] | Value comes from the framework struct default; no file set it. |
[yaml:path:line] | Set in a YAML file; line is the 1-based line where the key appears. |
[yaml:path] | YAML file, but the line could not be determined (e.g. anchor/alias, _append/_remove operator). |
[toml:path] | Set in a TOML file (line numbers not available for TOML). |
[json:path] | Set in a JSON file (line numbers not available for JSON). |
[env:NUCLEUS_VAR] | Overridden by a NUCLEUS_-prefixed environment variable. |
Secret values are automatically redacted. Pass --json for structured
output. See CLI overview → Effective config
for the full flag reference.