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.
Install
Section titled “Install”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.
A typed config — by example
Section titled “A typed config — by example”The common case is three short blocks: a #[config] struct, a derived
Validate, and a from_env that maps one line per variable.
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 theNESTRS_POSTS__*scheme. The macro wires theNamespacedtrait — 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, elsebase—baseis where the fallback comes from, which is what makes the dual-path rule work per field.impl Defaultis that fallback when nothing is pinned at the call site. Defaults live here and nowhere else, sofrom_envhas 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-writtenimpl 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.
The dual-path rule
Section titled “The dual-path rule”Every field a module’s config exposes must be reachable two ways:
- The pinned struct, passed to
Module::for_root(MyConfig { ... })— programmatic, type-checked, comes from code. - The
NESTRS_<NS>__<KEY>environment variable, mapped explicitly infrom_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:
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:
| Tier | Wins over | Why |
|---|---|---|
| Real process environment | everything | The deployment is the last word — it is the only tier the code cannot see. |
Pinned in code (for_root) | .env, defaults | A deliberate choice by the app’s author. |
The .env cascade | defaults | Committed 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. |
One seam per module
Section titled “One seam per module”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:
GraphqlModule::for_root(None), // all from the environmentHttpModule::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:
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.
ConfigService API
Section titled “ConfigService API”The reader handed to from_env is a thin wrapper around the namespace
prefix. Five methods, all sync.
| Method | Reads |
|---|---|
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)? → bool | 1/true/yes/on and their negatives (case-insensitive) |
env.list("KEY", default) → Vec<String> | Comma-separated, trimmed, empties dropped |
env.var_name("KEY") → String | The 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”.
Wire it in
Section titled “Wire it in”The wiring is two module imports. The feature owns its config; the root
module imports ConfigModule::for_root() once.
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;use nest_rs::config::ConfigModule;use nest_rs::core::module;
#[module( imports = [ ConfigModule::for_root(), PostsHttpModule, ],)]pub struct AppModule;ConfigModule::for_root()registersArc<Environment>— the active profile, read fromNESTRS_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’scollectruns before any config factory. What makes the.envcascade readable isEnvironment::init()at the top ofmain, whichnestrs newscaffolds; see alternative sources.ConfigModule::for_feature::<C>()queues a factory in the boot’s factory phase. The factory callsC::load()(=from_envoverC::defaults(), thenvalidate); on failure the boot aborts with the variable named. PassSome(cfg)to makecfgthe base those variables overlay, exactly as a module’sfor_root(cfg)does — one code path, so a field cannot be reachable one way only.
Inject it
Section titled “Inject it”A consumer injects Arc<PostsConfig> like any provider — the type is
the token.
#[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; // ... }}Run it
Section titled “Run it”A bad value fails the boot before any port opens:
$ NESTRS_POSTS__PAGE_SIZE=lots nestrs run dev apiError: invalid value for NESTRS_POSTS__PAGE_SIZE: invalid digit found in stringA violated #[validate] rule aborts the same way after the load,
naming the field.
Why hand-written from_env
Section titled “Why hand-written from_env”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.
Your own prefix
Section titled “Your own prefix”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:
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
.envcascade and configures logging beforemainbuilds anything, so a.envfile 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__PORTis just another variable in the environment — inert. That is deliberate: a stale value silently winning is the failure mode a fallback would create. RUST_LOGkeeps its name. It belongs to the ecosystem, not to us — and so, for the same reason, doesNESTRS_ENV_PREFIXitself: 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.
Advanced
Section titled “Advanced”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.
In this section
Section titled “In this section”Basics
Section titled “Basics”- The .env cascade — which files load, in
what order, and how the active
Environmentselects them.
All options
Section titled “All options”- Alternative sources — Vault, a K8s ConfigMap
or AWS Parameter Store behind
ConfigSource. - Overriding in tests — seed pinned values, isolate the environment, keep both suites hermetic.
- Advanced: hand-written
from_env— custom validation and nested config, when the derive is not enough. - Env-var reference — every
NESTRS_*variable the framework reads, by namespace.
Reference
Section titled “Reference”crates/features/src/oauth/config.rs— the full real example.crates/nest-rs-config/—#[config],Config,ConfigService,ConfigModule, the.envcascade.
Going further
Section titled “Going further”- Modules — where
for_root(...)sits in a composition root. - HTTP configuration — the largest config struct the framework ships, dual-path like every other.
- CLI —
nestrs runloads the cascade before it starts an app.