Skip to main content
Version: 1.20.0

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:

#LayerCatchesStatus
1SyntacticUnparseable YAML/TOML/JSON, file > 1 MiB, mixed-format lists when WithConfigStrict(true). Errors: ErrUnsupportedConfigFormat, ErrConfigFileTooLarge, ErrMixedConfigFormats.shipped
2SchemaKeys 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
3Field-semanticOut-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
4ReferentialSettings 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
5Module-specificAt 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 as app.Config).
  • default:"<value>" — fills the field when both the config file and the programmatic Config baseline leave it at its zero value.
  • validate:"<rule>" — enforced at Run time via pkg/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:

  1. The modules.billing.* subtree is sliced out of the merged config.
  2. It is unmarshalled into a fresh BillingConfig value, starting from the programmatic Module[C].Config baseline.
  3. default: tags fill any fields still at their zero value.
  4. validate: tags are checked. A failure returns nucleus.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. The NUCLEUS_* env layer covers only the registered app.Config schema keys. To supply module config from the environment, read the env var inside Module[C].OnStart and set the relevant field yourself.
  • nucleus config print --effective does not include module config. Module schemas are open-ended and may carry secrets; there is no framework-level redaction contract for modules.* values. The --effective output excludes the modules.* 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:

LabelMeaning
[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.