Skip to content

Configuration

Typed, namespaced configuration loaded from environment variables — written by hand, validated on the way in.

Configuration in nestrs is a typed struct, one file per concern, mapped explicitly to NESTRS_<NAMESPACE>__<KEY> environment variables. The crate is hand-rolled (no figment at runtime): you write from_env, the framework loads it once at boot, validates it, and registers Arc<MyConfig> into the container. Consumers inject it like any other provider.

The whole pipeline reads from environment variables and a small .env cascade the framework merges before any from_env runs. No TOML loader, no YAML, no remote source baked in — but a ConfigSource trait lets you ship one when you need Vault, K8s, or AWS Parameter Store.

Terminal window
cargo add nest-rs --features config

#[config] carries the Validate derive itself, so a config struct needs no validator entry and no version to keep aligned with the framework’s.

The common case is three short blocks: a #[config] struct, a derived Validate, and a from_env that maps one line per variable.

crates/features/src/posts/config.rs
use nest_rs::config::{config, Config, ConfigService};
#[config(namespace = "posts")]
#[derive(Clone)]
pub struct PostsConfig {
#[validate(range(min = 1, max = 500))]
pub page_size: u32,
pub cache_enabled: bool,
}
impl Default for PostsConfig {
fn default() -> Self {
Self { page_size: 50, cache_enabled: false }
}
}
impl Config for PostsConfig {
fn from_env(env: &ConfigService, base: Self) -> nest_rs::config::Result<Self> {
Ok(Self {
page_size: env.parse("PAGE_SIZE")?.unwrap_or(base.page_size),
cache_enabled: env.flag("CACHE_ENABLED", base.cache_enabled)?,
})
}
}

Three pieces do the heavy lifting:

  • #[config(namespace = "posts")] binds the struct to the NESTRS_POSTS__* scheme. The macro wires the Namespaced trait — no manual constant to write.
  • impl Config { fn from_env(env: &ConfigService, base: Self) } is the hand-written contract: one line per variable. A reviewer reading this file knows exactly which env vars exist, with which defaults, and what happens when one is malformed. Each line reads env, else basebase is where the fallback comes from, which is what makes the dual-path rule work per field.
  • impl Default is that fallback when nothing is pinned at the call site. Defaults live here and nowhere else, so from_env has no second copy to drift from.
  • #[derive(Validate)] plus #[validate(...)] attributes run after the load. A failure aborts boot naming the field. A rule the derive cannot express is the one case for a hand-written impl Validate — see the Advanced section at the end of this page.

env.parse("PAGE_SIZE")? reads + parses into the field’s type; unset returns None, set-but-unparseable returns Err naming the variable — boot-fatal, no silent fallback. env.flag accepts the usual boolean spellings.

Every field a module’s config exposes must be reachable two ways:

  1. The pinned struct, passed to Module::for_root(MyConfig { ... }) — programmatic, type-checked, comes from code.
  2. The NESTRS_<NS>__<KEY> environment variable, mapped explicitly in from_env.

That is the framework-wide rule for every nest-rs-* module. An env-only field can’t be set from a config file; a code-only field can’t be overridden in deployment. Both, always.

And the two compose per field, not per struct. A pinned struct is the base the environment overlays — never the final answer. So this, the shape nestrs new scaffolds:

apps/api/src/module.rs
HttpModule::for_root(HttpConfig { port: 3000, ..Default::default() }),

pins the port as a default and leaves every NESTRS_HTTP__* key live. NESTRS_HTTP__PORT=3555 moves the port; NESTRS_HTTP__TLS_CERT_FILE still turns on TLS. The ..Default::default() does not freeze the other fields alongside the one you meant to set.

Read “the environment” there as the real process environment only. Once a field is pinned, the .env cascade no longer reaches it — the same NESTRS_HTTP__PORT=3555 moves the port when it is exported into the process and is ignored when it sits in a committed .env file. That asymmetry is the whole point of the tier order below: a pin is a deliberate choice by the app’s author, and a file committed next to the code must not undo it silently.

The full precedence chain, highest first:

TierWins overWhy
Real process environmenteverythingThe deployment is the last word — it is the only tier the code cannot see.
Pinned in code (for_root).env, defaultsA deliberate choice by the app’s author.
The .env cascadedefaultsCommitted beside the code, so it reads as another default and defers to a pin.
Config::defaults()The in-code baseline, profile-aware where safety demands it.

Module::for_root(x) is the only way to configure a module, and x is one value carrying everything the app declares about it. There is no builder chain to continue on the result, and no second constructor on the module type — so “how do I configure this?” has one answer per module, readable in one expression.

for_root configures; for_feature registers. They are not two ways to do one thing. ConfigModule::for_feature::<C>() takes no value on purpose: it declares that C must be loaded, and the module that owns C is where a base is pinned. A config reachable through two seams is a config whose value depends on import order.

Every nest-rs-* module that owns a config writes both — for_feature in its imports, and a for_root for you to pin from.

Your own feature modules write only for_feature. You own the struct, so its impl Default is already your in-code path; that is precisely what a framework module cannot offer, and what its for_root exists to replace. Add one the day another crate must pin your config — four lines with ConfigSetup — not before.

The converse keeps the seam from spreading: a module that owns no config of its own gets no for_root. SocialModule is the case — each social provider carries its own #[config], discovered along with the provider, so the module has nothing to be configured about and stays a bare import.

You cannot get this wrong quietly. A pinned base supersedes the environment-only factory a bare import queues, wherever the two fall in imports; and two pinned bases for one config fail the boot naming it.

Most modules take the config itself:

apps/api/src/module.rs
GraphqlModule::for_root(None), // all from the environment
HttpModule::for_root(HttpConfig { port: 3000, ..Default::default() }),

A module that also carries a declaration with no env twin — the app’s own MCP name and version — takes an Options struct instead, so the two still travel in one value:

apps/api/src/module.rs
McpModule::for_root(McpOptions {
config: Some(McpConfig::default().with_allowed_hosts(["mcp.example.com"])),
server: Some(McpIdentity::new("acme-assistant", env!("CARGO_PKG_VERSION"))),
}),

The config field stays an Option for the reason the precedence table above gives: None means “nothing pinned”, which lets the .env cascade outrank the defaults. Passing the bare config still works — McpConfig converts into McpOptions — so a call site that declares no identity reads exactly like every other module’s.

The reader handed to from_env is a thin wrapper around the namespace prefix. Five methods, all sync.

MethodReads
env.get("KEY")Option<String>NESTRS_<NS>__KEY as a raw string
env.parse::<T>("KEY")?Option<T>Same, parsed via FromStr
env.flag("KEY", default)?bool1/true/yes/on and their negatives (case-insensitive)
env.list("KEY", default)Vec<String>Comma-separated, trimmed, empties dropped
env.var_name("KEY")StringThe full env-var name (for error messages)

Empty strings count as unset, so FOO= in a .env file does not blank an in-code default.

It is an unset override, not a no-op: a present-but-empty entry suppresses the fallback, so FOO= in .env.local also masks FOO=33 in .env.development and FOO=11 in .env, and the in-code default is what lands. Same for an empty value in the real environment. Delete the line to defer to the next layer; leave it empty to mean “nothing, whatever the files below say”.

The wiring is two module imports. The feature owns its config; the root module imports ConfigModule::for_root() once.

crates/features/src/posts/module.rs
use nest_rs::config::ConfigModule;
use nest_rs::core::module;
use super::config::PostsConfig;
use super::service::PostsService;
#[module(
imports = [ConfigModule::for_feature::<PostsConfig>()],
providers = [PostsService],
)]
pub struct PostsModule;
apps/api/src/module.rs
use nest_rs::config::ConfigModule;
use nest_rs::core::module;
#[module(
imports = [
ConfigModule::for_root(),
PostsHttpModule,
],
)]
pub struct AppModule;
  • ConfigModule::for_root() registers Arc<Environment> — the active profile, read from NESTRS_ENV — so a provider can inject it and branch. It writes nothing to the process environment, and its position among the imports carries no meaning: every module’s collect runs before any config factory. What makes the .env cascade readable is Environment::init() at the top of main, which nestrs new scaffolds; see alternative sources.
  • ConfigModule::for_feature::<C>() queues a factory in the boot’s factory phase. The factory calls C::load() (= from_env over C::defaults(), then validate); on failure the boot aborts with the variable named. Pass Some(cfg) to make cfg the base those variables overlay, exactly as a module’s for_root(cfg) does — one code path, so a field cannot be reachable one way only.

A consumer injects Arc<PostsConfig> like any provider — the type is the token.

crates/features/src/posts/service.rs
#[injectable]
pub struct PostsService {
#[inject]
config: Arc<PostsConfig>,
}
impl PostsService {
pub async fn list(&self) -> Result<Vec<Post>, ServiceError> {
let page = self.config.page_size;
// ...
}
}

A bad value fails the boot before any port opens:

Terminal window
$ NESTRS_POSTS__PAGE_SIZE=lots nestrs run dev api
Error: invalid value for NESTRS_POSTS__PAGE_SIZE: invalid digit found in string

A violated #[validate] rule aborts the same way after the load, naming the field.

Auto-deserializing from env vars sounds convenient, but the contract becomes implicit (which vars exist? what are their types?). With from_env written explicitly:

  • The full env contract of a feature is one impl block, in one file.
  • A missing variable falls back to the base — the pinned value, or the config’s own Default — one visible expression per field.
  • Unparseable values fail loudly, naming the variable.
  • A reviewer reads the contract by reading ten lines, not by chasing serde attributes.

NESTRS is a default, not a fixture. Name your own once and every framework variable follows — ACME_ENV, ACME_LOG, ACME_HTTP__PORT, ACME_SEAORM__URL:

compose.yml
environment:
NESTRS_ENV_PREFIX: ACME
ACME_SEAORM__URL: postgres://…

There is exactly one place to write it, and it is an environment variable because the prefix belongs to the deployment, not to the source: the same image runs in staging and in production, and each container names its own variables. The CLI reads the same variable from its own environment, so nestrs doctor and nestrs g auth speak your names, not ours.

nestrs new acme --env-prefix ACME sets it in the generated Justfile — which is what nestrs run starts every process through — and writes the .env cascade under the new names. Your own image and your deployment must set it too; it belongs to the process, not to a file it reads.

Three things are worth knowing:

  • It must be set on the process, before it starts. The prefix selects the .env cascade and configures logging before main builds anything, so a .env file cannot carry it — it is read too late to have chosen itself. The framework aborts naming both values rather than let the rename silently not happen.
  • It is a rename, not an alias. Once set, NESTRS_HTTP__PORT is just another variable in the environment — inert. That is deliberate: a stale value silently winning is the failure mode a fallback would create.
  • RUST_LOG keeps its name. It belongs to the ecosystem, not to us — and so, for the same reason, does NESTRS_ENV_PREFIX itself: it is the one name no prefix can rename.

The value is checked on the first read (uppercase ASCII, digits and underscores, no trailing _ — the framework supplies the separator), and a malformed one aborts rather than fall back to NESTRS, which would be just as wrong and silent.

Hand-writing from_env (for custom validation, or a field carrying a structured payload, or a rule spanning the whole struct) is covered on Advanced configuration with the real IssuerConfig as the worked example.

  • The .env cascade — which files load, in what order, and how the active Environment selects them.
  • crates/features/src/oauth/config.rs — the full real example.
  • crates/nest-rs-config/#[config], Config, ConfigService, ConfigModule, the .env cascade.
  • Modules — where for_root(...) sits in a composition root.
  • HTTP configuration — the largest config struct the framework ships, dual-path like every other.
  • CLInestrs run loads the cascade before it starts an app.