Secrets & config in production
@modularityjs/config is the only thing application code reads at runtime; everything — defaults, env vars, vault secrets, per-tenant overrides — feeds into a single priority-ordered pipeline. That property is what makes the production story sane: rotate a secret driver, switch from env to Vault, layer in a per-tenant override — none of it requires touching ConfigService.get('db/host').
The pipeline
ConfigService.get<T>(path) walks every registered ConfigSource in descending priority until one returns a value. Built-in sources:
| Source | Priority | Provided by |
|---|---|---|
DefaultsSource | 0 | @modularityjs/config (always) |
EnvConfigSource | 1000 | @modularityjs/config-env |
SecretsConfigSource | 1500 | @modularityjs/secrets-config (when wired) |
Apps register more sources at any priority via ConfigSourcePool if they need to (e.g., a per-tenant override at 2000, a YAML file at 500).
Every config class declares its shape and defaults, and validation runs at boot — missing required values fail the app before it accepts traffic. Pair that with the boot smoke gate and misconfiguration never reaches a live pod.
Environment variables
@modularityjs/config-env maps config paths to env vars via MODULARITYJS__<PATH>:
/becomes__-becomes_- everything uppercased
So db/host reads MODULARITYJS__DB__HOST; outbox-scheduler/lock-ttl-ms reads MODULARITYJS__OUTBOX_SCHEDULER__LOCK_TTL_MS. Values are coerced before validation: 'true', 'false', 'null', numeric strings, and JSON-parseable objects are decoded; anything else stays a string.
Where env vars actually land.
EnvConfigSourceonly feeds paths that come throughConfigService.get(). A*Configclass loaded viaconfigureModule(e.g.OutboxSchedulerModule.forRoot(...)) reads from its own fields, not fromConfigSource. To make a*Configfield env-overridable, register the path inConfigSchemaPool(or read it viaConfigServiceinside the module's config object). The example below assumes the schedule path has been registered.
This is what the production deployment usually looks like — every per-environment knob is an env var, app code never reaches process.env:
MODULARITYJS__DB__HOST=postgres.prod.internal
MODULARITYJS__DB__PORT=5432
MODULARITYJS__REDIS__HOST=redis.prod.internal
MODULARITYJS__REDIS__KEY_PREFIX=orders-api:
MODULARITYJS__OUTBOX_SCHEDULER__SCHEDULE='*/2 * * * * *'Validate at boot
Two validation hooks fire at boot, on different surfaces:
- Config class
validate()— the framework calls each*Config'svalidate()at DI activation time (insideconfigureModule, immediately afterforRoot()overrides are merged in). It runs once per config instance, before any module'sonInit, and a thrownValidationExceptionfails boot at the binding step. ConfigModule.onInit()— separately validates every entry registered viaConfigSchemaPool(the rawstring/number/boolean/jsonschema declarations, not the*Configclasses). Required-but-missing values and type mismatches are collected and surface as oneValidationExceptionper boot — so a missingDATABASE_URLand a malformedREDIS_PORTboth report in a single failure.
@Injectable()
export class HttpConfig {
port = 8080;
host = '0.0.0.0';
validate(): void {
if (this.port < 1 || this.port > 65535) {
throw ValidationException.invalidField(
'port',
`"port" must be a TCP port (1-65535), got ${this.port}.`,
);
}
}
}The pattern is in Configuration; the production-shaped take is "every config field that varies by env has a validate() check, no exceptions" — silent fallbacks like ?? 'default-secret' are the kind of thing that ships to prod and starts an incident at 02:00.
Secrets
SecretsService.get(name) is async — it makes a network call to a vault. That's why apps don't usually inject SecretsService directly into hot-path code: every request would round-trip the vault.
The supported drivers, each as a *Module.forRoot():
@modularityjs/secrets-memory—entries: Record<string,string>. Tests and dev only.@modularityjs/secrets-vault— HashiCorp Vault KV v2. Config:addr,token,mount,pathPrefix,field.@modularityjs/secrets-aws— AWS Secrets Manager. Config:region,endpoint?,namePrefix?,clientFactory?.@modularityjs/secrets-gcp— GCP Secret Manager. Config:projectId,keyFilename?,version,clientFactory?. Note: GCP secret IDs allow[A-Za-z0-9_-]only, so/in names becomes_.
All drivers throw NotFoundException on a missing secret — failed lookups never silently turn into undefined.
The integration: secrets in ConfigService.get
Hot-path code wants config.get('stripe/api-key'), not await secrets.get('stripe-api-key'). @modularityjs/secrets-config is the extension that makes both look the same:
SecretsModule,
SecretsVaultModule.forRoot({
addr: process.env.VAULT_ADDR!,
token: process.env.VAULT_TOKEN!,
pathPrefix: 'orders-api',
}),
SecretsConfigModule.forRoot({
paths: ['stripe/api-key', 'mailer/smtp-password', 'jwt/private-key'],
// priority defaults to 1500 (above env at 1000)
}),On boot, SecretsConfigSource calls SecretsService.get(name) for every entry in paths and caches the result in memory. From then on, config.get('stripe/api-key') returns synchronously — the secret behaves exactly like any other config value. If a path isn't in paths, the source returns undefined and resolution falls through to env / defaults.
Three things to know:
- Boot-time loading. A missing secret fails boot (the smoke gate catches it). A missing secret can't surface at request time.
- No live rotation. Values live in process memory until exit. Rotating a secret requires a deploy / restart. This is a deliberate trade for synchronous reads; if rotation without restart matters, inject
SecretsServicedirectly and pay the async cost at call sites. - Priority 1500 > env 1000. A path declared in both
pathsand an env var resolves to the secret, not the env. Useful for production / dev parity: dev sets env, prod swaps in vault, no app code changes.
Naming convention
Pick one convention and apply it across config paths, env vars, and secret names — the extension's mental model only works if a config path translates cleanly to both an env var and a secret name.
- Config path:
mailer/smtp-password - Env var:
MODULARITYJS__MAILER__SMTP_PASSWORD - Secret name (Vault / AWS):
mailer/smtp-password - Secret name (GCP):
mailer_smtp-password
Tooling stays straightforward when the path is canonical and the others are mechanical translations of it.
Scoped config (multi-tenant)
@modularityjs/config-scope swaps in a ScopedConfigService that walks the scope chain before falling back to global config. @modularityjs/config-env-scope adds the same behavior to env vars via a hierarchical key format:
# Tenant-specific override
MODULARITYJS__SCOPES__TENANT__ACME__DB__HOST=postgres.acme.dedicated.internal
# Global default
MODULARITYJS__DB__HOST=postgres.shared.internalWhen the request is scoped to { level: 'tenant', id: 'acme' }, config.get('db/host') returns the dedicated host; for any other tenant, it returns the shared one. Wire @modularityjs/scope and run handlers via ScopeService.runInScope(...) to make this kick in. See Scope.
Production checklist
- Every required config field has
validate(). Boot fails on missing / malformed values. - The smoke gate boots with production-shaped env. A missing env var breaks CI, not the first request.
- Secrets come from a vault driver, not env vars, for material that ought to rotate (Stripe keys, JWT signing keys, DB passwords for prod-tier credentials).
SecretsConfigModule.forRoot({ paths })lists every secret a sync caller needs. Anything not listed forces asyncSecretsService.get(...)calls.- Naming is consistent. Config path → env var → secret name is a mechanical translation; humans can grep for any of them and find the others.
- Restart policy understood. Secret rotation requires a deploy; bake that into the rotation runbook.
Next Steps
- Configuration —
ConfigSource, schemas, coercion - Scope —
ScopeService, scoped config and env - Secrets — driver options per vault
- Build & Deploy — boot smoke gate, env-driven driver swap