Skip to content

Configuration and Environment

Aman Kumar Singh8 min read
Part 11 of 40From the NestJS Production Guide series
Configuration and Environment — article by Aman Kumar Singh

Last time in Middleware in NestJS, part of the NestJS Production Guide, I covered where cross-cutting request logic belongs in the framework's pipeline. Configuration sits a level below all of that. Before a request ever arrives, your app has already decided which database to connect to, which Redis instance to talk to, and which third-party API keys to load. Every one of those decisions came from environment configuration.

Most NestJS apps start with a handful of process.env.SOMETHING calls scattered across a few files, and that's a reasonable place to start. It stops being reasonable once the app has more than one environment, more than one engineer touching it, or a config value that needs to be validated before the app is allowed to boot. This article covers how to structure configuration so it scales past that point: typed config modules, startup validation, environment-specific files, and where secrets should actually live once .env files aren't enough.

None of this is exotic. It's the kind of plumbing that's easy to under-invest in early and expensive to retrofit later, because by the time you notice the problem, config reads are already spread across two dozen files with no single source of truth for what the app actually needs to run.

Why scattered process.env calls become a problem

A raw process.env.DATABASE_URL call works fine the first time you write it. The trouble shows up as the app grows, for three concrete reasons.

First, there's no compile-time signal that a variable exists or what shape it's in. process.env.PORT is string | undefined in TypeScript, always, because Node has no way to know what's actually in your shell. Every call site that reads it has to defensively parse and default it. If two call sites default it differently, you now have two different ideas of what port the app runs on, depending on which code path executes first.

Second, there's no single place that documents what the app needs. A new engineer trying to run the service locally has to grep the entire codebase for process.env to reconstruct the list of required variables. That list is only as complete as whoever remembered to keep it in sync.

Third, and this is the one that actually causes production incidents, there's no validation at startup. If STRIPE_SECRET_KEY is missing, an app built on raw process.env calls will boot successfully and fail later, the first time a request tries to create a payment intent, usually in production, usually at a bad time. The failure should happen at deploy time, not at request time.

Typed configuration with @nestjs/config

NestJS's official answer is @nestjs/config, a thin wrapper around dotenv that exposes configuration through the DI container instead of through the global process.env object. Install it and register it once, near the root of the app:

npm install @nestjs/config
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ['.env.local', '.env'],
    }),
  ],
})
export class AppModule {}

isGlobal: true is one of the few places a global module is the right call. Configuration is a genuinely app-wide concern, and forcing every feature module to import ConfigModule explicitly just to read a value buys you nothing.

The real value shows up once you move past ad hoc ConfigService.get('SOME_KEY') calls and define typed configuration namespaces with registerAs:

// src/config/database.config.ts
import { registerAs } from '@nestjs/config';

export interface DatabaseConfig {
  host: string;
  port: number;
  name: string;
  ssl: boolean;
}

export default registerAs(
  'database',
  (): DatabaseConfig => ({
    host: process.env.DATABASE_HOST ?? 'localhost',
    port: Number(process.env.DATABASE_PORT ?? 5432),
    name: process.env.DATABASE_NAME ?? 'app_dev',
    ssl: process.env.DATABASE_SSL === 'true',
  }),
);
// src/app.module.ts
import databaseConfig from './config/database.config';

ConfigModule.forRoot({
  isGlobal: true,
  load: [databaseConfig],
});
// src/modules/database/database.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import databaseConfig from '../../config/database.config';

@Injectable()
export class DatabaseService {
  constructor(
    @Inject(databaseConfig.KEY)
    private readonly config: ConfigType<typeof databaseConfig>,
  ) {}

  getConnectionOptions() {
    return {
      host: this.config.host,
      port: this.config.port,
      database: this.config.name,
      ssl: this.config.ssl,
    };
  }
}

ConfigType<typeof databaseConfig> gives you a fully typed object. config.port is number, not string | undefined, and the environment variable parsing happens exactly once, in one file, instead of at every call site that needs it. This is the same pattern the framework itself uses internally. It's worth adopting one namespace per logical concern (database, redis, auth, mail) rather than one giant flat config object that every module has to import in full.

Validating configuration at startup, not at the call site

Typed access solves the shape problem. It doesn't solve the presence problem. databaseConfig above still silently defaults host to localhost if DATABASE_HOST is unset, which is fine in local development and dangerous in production if a required variable is simply missing from the deployment's environment.

The fix is a validation schema that runs once, when ConfigModule initializes, and throws if anything required is missing or malformed:

// src/config/env.validation.ts
import * as Joi from 'joi';

export const envValidationSchema = Joi.object({
  NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
  PORT: Joi.number().default(3000),
  DATABASE_HOST: Joi.string().required(),
  DATABASE_PORT: Joi.number().default(5432),
  DATABASE_NAME: Joi.string().required(),
  DATABASE_SSL: Joi.boolean().default(false),
  JWT_SECRET: Joi.string().min(32).required(),
  STRIPE_SECRET_KEY: Joi.string().required(),
});
// src/app.module.ts
import { envValidationSchema } from './config/env.validation';

ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: envValidationSchema,
  validationOptions: {
    abortEarly: false,
  },
});

abortEarly: false matters more than it looks. Without it, Joi reports the first failing variable and stops, so an engineer fixes one problem, redeploys, and hits the next one. With it, you get every missing or invalid variable in one error message, which is what you actually want when a deployment is failing and someone is trying to fix it under time pressure.

This is also where Zod is a reasonable alternative, if your team already standardizes on it elsewhere. The tradeoff is mostly about consistency with the rest of your validation layer (DTOs and all), rather than any meaningful capability gap between the two libraries for this use case. Whichever you pick, the principle stays the same: a missing JWT_SECRET or STRIPE_SECRET_KEY should crash the app at startup with a clear message. It shouldn't surface as a confusing 500 error the first time a real request needs that value.

Environment-specific files and the twelve-factor tradeoff

envFilePath: ['.env.local', '.env'] in the example above loads multiple files, with earlier entries taking precedence over later ones. A common layout is .env checked into the repo with safe local defaults, and .env.local gitignored for anything an individual developer overrides on their machine. Some teams add .env.production or .env.staging and switch the path based on NODE_ENV, which works but starts to blur a boundary worth keeping clear.

The twelve-factor app methodology argues that config should come entirely from the environment, not from files checked in per deployment target. The reasoning holds up: a .env.production file checked into git is one accidental commit away from leaking whatever it contains, and it couples your deployment pipeline to a file that has to be kept in sync by hand across every environment.

In practice, most production NestJS deployments split the difference. .env files handle local development, where the values are non-sensitive defaults and convenience matters more than purity. The actual staging and production environments get their variables injected directly by the platform: Docker Compose's env_file or environment keys, an ECS task definition, or a Kubernetes ConfigMap and Secret. No .env file is involved at all.

# docker-compose.yml (excerpt)
services:
  api:
    build: .
    env_file:
      - .env
    environment:
      NODE_ENV: production
      DATABASE_HOST: postgres

Note that environment here overrides anything with the same key from env_file, which is exactly the precedence you want: safe local defaults in the file, environment-specific overrides set explicitly per deployment.

Secrets belong outside .env in production

.env files, even gitignored ones, are still plaintext files sitting on a filesystem, in your shell history if you ever cat one, and in every developer's clone of the deploy scripts if the file gets checked in by mistake. That's an acceptable risk for local development. It's not an acceptable way to hold a database password or a Stripe secret key in production.

For an AWS-hosted NestJS app, the two realistic options are AWS Secrets Manager and Systems Manager Parameter Store. Both let you fetch a secret at container startup instead of baking it into an image or an environment variable that's visible to anyone with read access to the task definition:

// src/config/secrets.loader.ts
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';

export async function loadDatabaseSecret(secretId: string) {
  const client = new SecretsManagerClient({});
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: secretId }),
  );

  if (!response.SecretString) {
    throw new Error(`Secret ${secretId} has no string value`);
  }

  return JSON.parse(response.SecretString) as {
    username: string;
    password: string;
    host: string;
    port: number;
  };
}

Wire this into ConfigModule through an async factory rather than a synchronous registerAs call, since fetching a secret is inherently a network operation:

// src/app.module.ts
ConfigModule.forRoot({
  isGlobal: true,
  load: [
    async () => ({
      database: await loadDatabaseSecret(process.env.DB_SECRET_ARN as string),
    }),
  ],
});

The tradeoff is a slightly slower cold start against a meaningfully smaller blast radius if a container's environment ever gets exposed. For most SaaS APIs that boot once and run for a long time, that tradeoff is worth it. For something that needs to cold-start fast, like a Lambda handler, ECS task definition secrets or SSM references injected directly into the environment before the container starts are usually the better fit. The fetch happens at the infrastructure layer instead of inside your app's boot sequence.

Don't build a custom secrets solution before you've outgrown the platform's built-in one. ECS and Kubernetes both support pulling secrets into environment variables natively, without any application code. Reach for fetching secrets from inside the app only once you have a concrete reason: cross-region replication, or a rotation schedule your platform doesn't support.

Key takeaways

  • Raw `process.env` calls scattered across a codebase have no compile-time type safety, no single source of truth, and no startup validation. `@nestjs/config` with typed `registerAs` namespaces fixes all three.
  • Validate configuration at startup with Joi or Zod so a missing or malformed variable crashes the app immediately with a clear message, instead of surfacing as a confusing runtime error the first time that value is actually used.
  • `.env` files are fine for local development but shouldn't be the source of truth in production; inject environment-specific values through your deployment platform instead.
  • Secrets, database passwords, API keys, JWT signing keys, deserve a real secrets manager in production. Start with your platform's built-in support (ECS, Kubernetes) before building custom fetch logic inside the app.
  • Group related config into namespaces with `registerAs` rather than one flat object; it keeps each module's dependency on configuration explicit and typed.

Frequently asked questions

Should I use `process.env` directly anywhere in a NestJS app?

Only inside the `registerAs` factory functions, where raw parsing and defaulting happens once. Everywhere else should consume typed config through `ConfigService` or an injected `ConfigType`.

What's the difference between `ConfigModule.forRoot` and `forRoot` with `load`?

`forRoot()` alone loads `.env` files into `process.env` and exposes `ConfigService.get()` for raw string lookups. Adding `load` with `registerAs` factories registers typed, namespaced config objects you can inject directly, worth standardizing on for anything beyond a handful of simple values.

Should validation happen with Joi, Zod, or class-validator?

Any of the three works. `@nestjs/config`'s `validationSchema` option expects Joi out of the box, but a custom `validate` function lets you swap in Zod or `class-validator` DTOs to stay consistent with how the rest of the app validates input.

Is it safe to commit a `.env.example` file to the repository?

Yes, and it's worth doing. Variable names with placeholder or non-sensitive default values document what the app needs to run without exposing anything real. Just make sure nobody fills it in with an actual secret by mistake.

How do I handle configuration differences between local development and CI?

Give CI its own environment variables set directly in the pipeline configuration rather than trying to make one `.env` file serve both. CI rarely needs the same values as local development, a test database URL instead of a local one, mocked API keys instead of real ones.

Does using Secrets Manager or Parameter Store slow down app startup meaningfully?

It adds a network call before bootstrapping finishes, usually small relative to database connection pooling and module initialization. For long-running services that cost is paid once per deploy, a reasonable tradeoff for keeping secrets out of plaintext environment variables.

Related articles

Deploying NestJS to Production — Aman Kumar Singh
Deploying a Full Stack SaaS — Aman Kumar Singh
A NestJS Production Checklist — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.