Cloud Configuration and Secrets

Load parameters and secrets at boot with an async config factory, so the app starts only once the values are in place.

SyntaxConfigModule.forRoot({ isGlobal: true, load: [cloudConfig] }): Promise<DynamicModule>

A parameter store (AWS SSM Parameter Store) and a secret manager (AWS Secrets Manager, Google Secret Manager) hold configuration outside your image: no database password in the repository, no long .env block in the deployment manifest. The application reads them once at startup and then behaves as if the values had come from the environment all along.

Loading at boot

ConfigModule.forRoot({ load: [...] }) takes factory functions. A factory may be async — its type is () => T | Promise<T> — and each one is registered as a provider with useFactory, so an async factory is simply an async provider: nothing that depends on it is constructed, and the server does not listen, until it resolves. In @nestjs/config 12 forRoot itself returns a Promise<DynamicModule>; put the call directly in imports and Nest resolves it for you.

The order of events

.env and process.env are read and validated first, then the load factories run, then the rest of the providers are built, then the app listens. Nothing in a request lifecycle is involved — this is all over before the first connection.

What trips people up

validate and validationSchema only see environment variables; whatever a load factory returns is never checked by them, so a missing parameter surfaces as undefined deep inside a service. In @nestjs/config 12 validationSchema takes a Standard Schema (Zod, Valibot, ArkType); up to version 4 it was a Joi schema.

Example

Example · typescript
// src/config/cloud.config.ts
import { registerAs } from '@nestjs/config';
import { paginateGetParametersByPath, SSMClient } from '@aws-sdk/client-ssm';
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';

interface DatabaseSecret {
  username: string;
  password: string;
}

// registerAs files everything this factory returns under the key 'cloud',
// and hangs a KEY token off the factory for direct injection.
// The factory is async, so it becomes an async provider: bootstrap waits.
export default registerAs('cloud', async () => {
  const region = process.env.AWS_REGION ?? 'eu-west-1';

  // Local and test runs must not reach out to AWS.
  if (process.env.NODE_ENV !== 'production') {
    return {
      region,
      databaseHost: 'localhost',
      database: { username: 'dev', password: 'dev' } satisfies DatabaseSecret,
    };
  }

  const ssm = new SSMClient({ region });
  const parameters: Record<string, string> = {};

  // One page holds at most 10 parameters, so paginate. Recursive walks the
  // whole subtree; WithDecryption resolves SecureString values with KMS.
  const pages = paginateGetParametersByPath(
    { client: ssm },
    { Path: '/acme/prod', Recursive: true, WithDecryption: true },
  );
  for await (const page of pages) {
    for (const parameter of page.Parameters ?? []) {
      // '/acme/prod/DATABASE_HOST' -> 'DATABASE_HOST'
      parameters[parameter.Name!.split('/').pop()!] = parameter.Value!;
    }
  }
  ssm.destroy();

  const secrets = new SecretsManagerClient({ region });
  const { SecretString } = await secrets.send(
    new GetSecretValueCommand({ SecretId: 'acme/prod/database' }),
  );
  secrets.destroy();

  return {
    region,
    databaseHost: parameters.DATABASE_HOST,
    // Secrets Manager stores a string. A JSON secret has to be parsed.
    database: JSON.parse(SecretString ?? '{}') as DatabaseSecret,
  };
});

When to use it

  • Pull the database password from Secrets Manager at startup so it is never in an env var or image layer.
  • Read a whole /acme/prod SSM subtree and expose it through ConfigService like any other setting.
  • Skip every cloud call in development and test while keeping identical code paths.
  • Fail the deploy at bootstrap when a required parameter is missing, instead of returning 500s later.

More examples

Wiring it into the root module

The root wiring, and the one place the validation boundary is visible: environment variables go through the schema, cloud-loaded values do not. isGlobal spares every feature module an extra ConfigModule import. Set cache only when nothing mutates process.env after startup.

Example · typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
import cloudConfig from './config/cloud.config';
import { DatabaseModule } from './database/database.module';

@Module({
  imports: [
    // forRoot returns a Promise<DynamicModule> in @nestjs/config 12. Nest
    // resolves a promise in `imports`, so there is nothing to await here.
    ConfigModule.forRoot({
      isGlobal: true, // ConfigService injectable everywhere, no re-import
      cache: true,    // cache process.env reads
      load: [cloudConfig],

      // Standard Schema in @nestjs/config 12; a Joi schema up to version 4.
      // This validates environment variables only -- it never sees what
      // cloudConfig returns, so the schema lists AWS_REGION, not databaseHost.
      validationSchema: z.object({
        NODE_ENV: z.enum(['development', 'test', 'production']),
        AWS_REGION: z.string().min(1),
      }),
    }),
    DatabaseModule,
  ],
})
export class AppModule {}

Two ways to read the same value

The dotted ConfigService.get() path and the KEY injection reach the same object; registerAs sets up both. Prefer the KEY form inside a module that owns a namespace, because the compiler checks the property names. Keep get() for one-off reads, and getOrThrow() wherever a missing value should stop the app rather than quietly become undefined.

Example · typescript
// src/database/database.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService, ConfigType } from '@nestjs/config';
import cloudConfig from '../config/cloud.config';

@Injectable()
export class DatabaseService {
  constructor(
    // Typed form: inject the namespace's own token. ConfigType unwraps the
    // factory's Promise, so this is the real return type, not any.
    @Inject(cloudConfig.KEY)
    private readonly cloud: ConfigType<typeof cloudConfig>,
    private readonly config: ConfigService,
  ) {}

  connectionUrl(): string {
    // Typed: a typo in `username` fails the build.
    const { username, password } = this.cloud.database;
    const host = this.cloud.databaseHost;

    // Dotted form: the same three values through ConfigService. Shorter,
    // but 'cloud.database.usernmae' compiles fine and returns undefined.
    const alsoUsername = this.config.get<string>('cloud.database.username');
    void alsoUsername;

    // getOrThrow refuses to return undefined: it throws at the call site
    // instead of letting an empty string reach the driver.
    const port = this.config.getOrThrow<string>('DATABASE_PORT');

    return `postgres://${username}:${password}@${host}:${port}/acme`;
  }
}

A secret that has to be usable before the first request

Not everything belongs in a config namespace. When a secret has to be parsed into an object — a signing key, a client certificate, a parsed connection descriptor — load it in its own async provider instead. You get the same bootstrap-blocking behaviour, a typed value rather than a string, and a failure that stops the deploy rather than surfacing on the first login attempt.

Example · typescript
// src/auth/jwt-keys.provider.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
import { createPublicKey, KeyObject } from 'node:crypto';

export const JWT_KEYS = Symbol('JWT_KEYS');

export interface JwtKeys {
  privateKeyPem: string;
  publicKey: KeyObject;
}

@Module({
  providers: [
    {
      provide: JWT_KEYS,
      // An async factory is an async provider: Nest will not construct
      // anything that injects JWT_KEYS, and will not start listening,
      // until this resolves. A rejection here fails the bootstrap loudly
      // instead of producing 500s on the first signed request.
      useFactory: async (config: ConfigService): Promise<JwtKeys> => {
        const client = new SecretsManagerClient({
          region: config.getOrThrow<string>('AWS_REGION'),
        });
        try {
          const { SecretString } = await client.send(
            new GetSecretValueCommand({ SecretId: config.getOrThrow<string>('JWT_SECRET_ID') }),
          );
          if (!SecretString) throw new Error('JWT signing key secret is empty');

          const privateKeyPem = SecretString;
          return { privateKeyPem, publicKey: createPublicKey(privateKeyPem) };
        } finally {
          client.destroy();
        }
      },
      inject: [ConfigService],
    },
  ],
  exports: [JWT_KEYS],
})
export class JwtKeysModule {}

Discussion

  • Be the first to comment on this lesson.