Skip to content

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:

SourcePriorityProvided by
DefaultsSource0@modularityjs/config (always)
EnvConfigSource1000@modularityjs/config-env
SecretsConfigSource1500@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. EnvConfigSource only feeds paths that come through ConfigService.get(). A *Config class loaded via configureModule (e.g. OutboxSchedulerModule.forRoot(...)) reads from its own fields, not from ConfigSource. To make a *Config field env-overridable, register the path in ConfigSchemaPool (or read it via ConfigService inside 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:

bash
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:

  1. Config class validate() — the framework calls each *Config's validate() at DI activation time (inside configureModule, immediately after forRoot() overrides are merged in). It runs once per config instance, before any module's onInit, and a thrown ValidationException fails boot at the binding step.
  2. ConfigModule.onInit() — separately validates every entry registered via ConfigSchemaPool (the raw string / number / boolean / json schema declarations, not the *Config classes). Required-but-missing values and type mismatches are collected and surface as one ValidationException per boot — so a missing DATABASE_URL and a malformed REDIS_PORT both report in a single failure.
typescript
@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-memoryentries: 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:

typescript
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:

  1. Boot-time loading. A missing secret fails boot (the smoke gate catches it). A missing secret can't surface at request time.
  2. 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 SecretsService directly and pay the async cost at call sites.
  3. Priority 1500 > env 1000. A path declared in both paths and 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:

bash
# Tenant-specific override
MODULARITYJS__SCOPES__TENANT__ACME__DB__HOST=postgres.acme.dedicated.internal
# Global default
MODULARITYJS__DB__HOST=postgres.shared.internal

When 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

  1. Every required config field has validate(). Boot fails on missing / malformed values.
  2. The smoke gate boots with production-shaped env. A missing env var breaks CI, not the first request.
  3. 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).
  4. SecretsConfigModule.forRoot({ paths }) lists every secret a sync caller needs. Anything not listed forces async SecretsService.get(...) calls.
  5. Naming is consistent. Config path → env var → secret name is a mechanical translation; humans can grep for any of them and find the others.
  6. Restart policy understood. Secret rotation requires a deploy; bake that into the rotation runbook.

Next Steps