Skip to content

Alternative sources

Plug Vault, a K8s ConfigMap, or AWS Parameter Store behind ConfigSource — the trait ConfigService reads through.

ConfigService reads its raw values through a small trait: ConfigSource. The default EnvSource returns process env vars after the .env cascade has merged. Swap it for anything that can answer “what’s the value of NESTRS_SEAORM__URL?” and the same Config struct loads from there — HashiCorp Vault, a K8s ConfigMap, AWS Parameter Store, an in-process map for tests.

nest_rs::config::ConfigSource
pub trait ConfigSource: Send + Sync + 'static {
fn get(&self, var: &str) -> Option<String>;
}

One method. var is the fully-qualified name (e.g. "NESTRS_SEAORM__URL") — namespacing happens in ConfigService, the source just answers lookups. Empty strings count as unset, same rule as EnvSource.

The trait is sync on purpose: Config::from_env runs synchronously at boot. A remote source pre-fetches its keys into an in-memory map during a factory phase, then serves get from that map.

A Vault source that hits the API once at startup and caches the result:

apps/api/src/vault.rs
use std::collections::HashMap;
use std::sync::Arc;
use nest_rs::config::{ConfigService, ConfigSource};
pub struct VaultSource {
snapshot: HashMap<String, String>,
}
impl VaultSource {
pub async fn fetch(client: &VaultClient, path: &str) -> anyhow::Result<Self> {
let response = client.read(path).await?;
let snapshot = response
.data
.into_iter()
.map(|(k, v)| (format!("NESTRS_{}", k.to_ascii_uppercase()), v))
.collect();
Ok(Self { snapshot })
}
}
impl ConfigSource for VaultSource {
fn get(&self, var: &str) -> Option<String> {
self.snapshot.get(var).cloned()
}
}

get is the only required method. The trait also carries get_from_deployment, which defaults to get — that is the tier allowed to outrank a value pinned in code, and a custom source counts as deployment-supplied unless it says otherwise. That default is the safe direction: a Vault secret is never shadowed by a struct literal. Override it only if your source also serves values that should lose to a pin.

Wire it in by building the ConfigService directly and calling Config::from_env yourself — bypassing ConfigModule::for_feature:

apps/api/src/main.rs
use nest_rs::config::ConfigService;
let vault = Arc::new(
VaultSource::fetch(&vault_client, "secret/api").await?,
);
let env = ConfigService::with_source("seaorm", vault);
let db_config = SeaOrmConfig::from_env(&env, SeaOrmConfig::defaults())?;
App::builder()
.module::<AppModule>()
.provide(db_config)
.build()
.await?;

A seeded config wins over ConfigModule::for_feature::<SeaOrmConfig>(), so the rest of the app sees the Vault-loaded value without changing any downstream code. The pattern matches Repo::scoped: build the seam where the source lives, hand the result to the framework.

The default source is the right answer for most apps:

  • Local dev reads from the .env cascade.
  • Production reads from real env vars set by the orchestrator.
  • A secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager) typically already injects env vars into the running container.

Reach for a custom ConfigSource only when:

  • The secrets manager cannot inject env vars (a sidecar pattern that serves values over HTTP).
  • A K8s ConfigMap mounts as a file tree rather than env vars.
  • A test needs a programmatic source decoupled from std::env.

What fails if you get it wrong — hermeticity and the cascade

Section titled “What fails if you get it wrong — hermeticity and the cascade”

ConfigService::for_namespace(ns) is shorthand for with_source(ns, Arc::new(EnvSource)). Reading through it mutates nothing. EnvSource resolves the real process environment first, then falls back to the .env cascade parsed into a map inside the crate — std::env::set_var is unsound against a concurrent getenv on another thread, so no read path may write.

One function publishes the cascade into the process environment: Environment::init(), which every scaffolded main calls on its first line. It exists for the consumers that only know std::env::var and never see a ConfigService — the framework’s own NESTRS_LOG* setup, OpenTelemetry::init, your migrate/seed binaries. It writes set-if-absent (the real env still wins) and belongs at the top of main precisely because being single-threaded there is what makes the write sound.

What this means in practice:

  • Resolving config is side-effect-free, whichever source you use. Neither for_namespace nor ConfigModule::for_root() publishes anything; for_root’s collect registers the active Environment and stops there.
  • TestApp::builder() does publish the cascade. The e2e harness reads backend URLs through std::env before any ConfigService exists, so it calls load_cascade (the explicit form of what Environment::init does) once per process. The merge is bounded — set-if-absent, real env still wins, and .env.local is skipped under NESTRS_ENV=test — and a for_root pin still outranks every cascade value. Reach for with_source (or .provide(cfg)) when a test must not see a committed .env at all: it decides where the values come from, which is the stronger guarantee.
  • In an app, assume the cascade is in std::env, because main put it there. That is the point: a .env value reaches code the DI graph never sees.

var is always the fully-qualified NESTRS_<NS>__<KEY> string. A few implications:

  • The source receives the uppercased namespace + key — the casing conversion happens in ConfigService::var before the lookup.
  • The source has no idea which Config struct is being loaded. It’s a flat key-value store. If you need per-namespace routing (e.g. database from Vault, http from env), wrap a couple of sources in a dispatching one.
  • Empty strings are unset. A Vault entry with an empty value will not blank an in-code default — return None instead.

A dispatching source for two backends:

apps/api/src/dispatch.rs
pub struct Dispatch {
vault: VaultSource,
env: EnvSource,
}
impl ConfigSource for Dispatch {
fn get(&self, var: &str) -> Option<String> {
if var.starts_with("NESTRS_SEAORM__") || var.starts_with("NESTRS_ISSUER__") {
self.vault.get(var)
} else {
self.env.get(var)
}
}
}