Environment Variables in NestJS: The Right Way with @nestjs/config

Learn how to manage environment variables in NestJS properly — from basic setup to validation, typed config, and multiple environments — so your app never breaks because of a missing secret.

Environment Variables in NestJS: The Right Way with @nestjs/config

Every developer has done this at least once.

You push to production, the app crashes immediately, and after ten minutes of confusion you realize someone — maybe you — hardcoded a database password that only exists on your local machine. Or forgot to set an environment variable on the server. Or accidentally committed a .env file with real credentials to GitHub.

It’s one of those mistakes that feels embarrassing but happens constantly. And the reason it keeps happening is that most tutorials show you process.env.SOMETHING and move on without explaining how to manage this properly as your project grows.

NestJS has a solid solution for this through @nestjs/config. Once it’s set up properly, your app validates its own configuration on startup, fails with a clear error if something is missing, and gives you typed access to config values anywhere in your codebase.

This is the setup I use on every NestJS project now. We’ve been referencing ConfigService throughout this series — in the database setup article and the migrations article — so if you’ve been following along, this will fill in the gaps.

📋 What you'll learn

  • How to set up @nestjs/config properly in a NestJS project
  • How to validate your environment variables on startup so missing values fail loudly
  • How to create typed config so you get autocomplete and no runtime surprises
  • How to organize config across multiple environments
  • How to use ConfigService anywhere in your app without importing dotenv manually

The Problem With process.env Everywhere

Before getting into the solution, let’s be clear about what goes wrong when you just use process.env directly throughout your app.

No validation. If JWT_SECRET isn’t set, process.env.JWT_SECRET is undefined. Your app starts fine. Then the first user tries to log in and gets a cryptic error because jsonwebtoken received undefined as the secret. You just found out the hard way that a required config value was missing.

No types. process.env returns string | undefined for everything. If you need a port number, you have to parse it manually every single time you use it. If you forget, you’re passing a string where a number is expected.

Scattered reads. When every file reads from process.env directly, figuring out which environment variables your app needs requires searching through the entire codebase. There’s no single source of truth.

@nestjs/config solves all three of these. Here’s how.

Basic Setup

Install the package:

npm install @nestjs/config

Then add ConfigModule to your AppModule:

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

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true, // makes ConfigService available everywhere without re-importing
    }),
  ],
})
export class AppModule {}

isGlobal: true is the key option here. Without it, you’d have to import ConfigModule into every single feature module that needs config values. With it, you import once in AppModule and ConfigService is available everywhere.

NestJS will now automatically read your .env file when the app starts. Make sure .env is in your .gitignore:

# .gitignore
.env
.env.local
.env.production

Seriously. If you’ve never accidentally committed credentials to a public repo, you probably know someone who has. Add this before anything else.

Using ConfigService

Once ConfigModule is set up globally, inject ConfigService wherever you need config values:

// any service or module
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AuthService {
  constructor(private readonly config: ConfigService) {}

  getJwtSecret(): string {
    return this.config.get<string>('JWT_SECRET');
  }
}

The get<string>('JWT_SECRET') call reads the value from your environment and returns it typed. No process.env, no manual parsing.

For modules that need config at setup time — like TypeORM or the JWT module — use forRootAsync with ConfigService injected via the factory function. You’ve seen this pattern in earlier articles:

JwtModule.registerAsync({
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    secret: config.get<string>('JWT_SECRET'),
    signOptions: {
      expiresIn: config.get<string>('JWT_EXPIRES_IN', '7d'), // second arg is the default
    },
  }),
}),

The second argument to config.get() is a default value. If JWT_EXPIRES_IN isn’t set, it falls back to '7d' instead of returning undefined. Useful for optional config values that have sensible defaults.

Validating Environment Variables on Startup

This is the part most tutorials skip — and it’s the most important part.

Right now if you forget to set DB_PASSWORD, your app starts without complaint. The database connection fails later, at runtime, when the first query runs. That’s the worst time to find out about a missing config value.

The fix is validation. You define what your app needs, and if anything is missing or wrong, the app refuses to start and tells you exactly what’s missing.

NestJS recommends using Joi for this. Install it:

npm install joi

Then add a validation schema to your ConfigModule:

// app.module.ts
import * as Joi from 'joi';

ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: Joi.object({
    NODE_ENV: Joi.string()
      .valid('development', 'production', 'test')
      .default('development'),

    PORT: Joi.number().default(3000),

    DB_HOST: Joi.string().required(),
    DB_PORT: Joi.number().default(5432),
    DB_USERNAME: Joi.string().required(),
    DB_PASSWORD: Joi.string().required(),
    DB_DATABASE: Joi.string().required(),

    JWT_SECRET: Joi.string().required().min(32),
    JWT_EXPIRES_IN: Joi.string().default('7d'),
  }),
}),

Now if you start the app without DB_PASSWORD set, you get this instead of a cryptic runtime error:

Error: Config validation error: "DB_PASSWORD" is required

Immediately. At startup. Before your app accepts a single request. That’s the behavior you want.

The min(32) on JWT_SECRET is worth noting. Short JWT secrets are a real security vulnerability — they can be brute-forced. Enforcing a minimum length at startup means you catch weak secrets in development before they ever reach production.

Organizing Config with Namespaces

As your app grows, dumping every config value into one flat ConfigModule gets messy. NestJS supports config namespaces — you split config into logical groups and access them with dot notation.

Create a config file for each concern:

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

export default registerAs('database', () => ({
  host: process.env.DB_HOST,
  port: parseInt(process.env.DB_PORT, 10) || 5432,
  username: process.env.DB_USERNAME,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_DATABASE,
}));
// src/config/jwt.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('jwt', () => ({
  secret: process.env.JWT_SECRET,
  expiresIn: process.env.JWT_EXPIRES_IN || '7d',
}));

Register them in ConfigModule:

import databaseConfig from './config/database.config';
import jwtConfig from './config/jwt.config';

ConfigModule.forRoot({
  isGlobal: true,
  load: [databaseConfig, jwtConfig],
  validationSchema: Joi.object({ /* ... */ }),
}),

Now access them with dot notation:

// in any service
const dbHost = this.config.get<string>('database.host');
const jwtSecret = this.config.get<string>('jwt.secret');

Your database config lives in one file. Your JWT config lives in another. When a new developer joins the project and needs to understand how the app is configured, they have clear files to look at instead of a scattered collection of process.env calls.

Multiple Environment Files

By default @nestjs/config reads from .env. But you probably want different values for development, staging, and production.

You can specify which file to load based on NODE_ENV:

ConfigModule.forRoot({
  isGlobal: true,
  envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
}),

This loads .env.development in development and .env.production in production. Your file structure looks like:

.env.development      ← local dev values, safe to be less strict
.env.test             ← test database, shorter JWT expiry
.env.production       ← real credentials, loaded from server environment

In actual production though, you don’t typically have a .env.production file on the server. You set environment variables directly in your hosting platform — Render, Railway, Fly.io, whatever you’re using — and they’re available via process.env automatically. The .env file is a local development convenience, not a production artifact.

A Sample .env File for This Series

Based on everything we’ve built in this series, here’s what your .env file should look like by now:

# Application
NODE_ENV=development
PORT=3000

# Database (from the PostgreSQL + TypeORM article)
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=nestuser
DB_PASSWORD=your-strong-password-here
DB_DATABASE=nestdb

# JWT (from the authentication article)
JWT_SECRET=this-needs-to-be-at-least-32-characters-long-please
JWT_EXPIRES_IN=7d

Keep this file. Any time you add a new config value to your app, add it here too with a safe placeholder value. When someone else clones your project, they copy this file, fill in their values, and they’re running.

One Thing Worth Getting Right Early

Don’t use ConfigService in entity files or anything that gets imported before the NestJS module system is fully initialized.

Entities, for example, are plain TypeScript classes. If you try to inject ConfigService into an entity or use it in a TypeORM column decorator, it won’t work — those decorators run at class definition time, before NestJS has started.

Config in entity files should come from environment variables directly (process.env) or from the TypeORM module configuration, not from ConfigService.

Everything else — services, guards, interceptors, modules — can use ConfigService without issues.

What You Have Now

Your app now validates its own configuration on startup. Missing values fail loudly with a clear error. Config is organized by concern instead of scattered across the codebase. And ConfigService gives you typed access to everything without manually calling process.env in every file.

Combined with the database setup, migrations, and error handling from the previous articles, your NestJS project is starting to look like something you could actually ship.

Next up is the deployment article — deploying NestJS to Render — where we take everything we’ve built and get it running on a real server. That’s where environment variables really matter, because Render’s environment variable dashboard is where your production config lives.

If anything in this article isn’t working the way you expect, drop a comment below.

Continue the series →

Next up: NestJS Modules, Controllers and Services — the architecture finally explained.

Read next article →

Related Posts