Environment Variables
Environment variables are the standard way to feed configuration into a NestJS application without hard-coding values or committing secrets. The @nestjs/config package wraps dotenv so you can load one or more .env files at boot, layer per-environment overrides, expand references between variables, and decide whether files should be read at all in production. Getting the loading rules right matters because precedence bugs are silent: the app starts, but with the wrong database URL or feature flag.
Loading env files
By default ConfigModule.forRoot() looks for a .env file in the project root and merges its contents into process.env. Register the module once, globally, so every provider can inject ConfigService without re-importing it.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
],
})
export class AppModule {}
A typical .env for local development:
# .env
NODE_ENV=development
PORT=3000
DATABASE_HOST=localhost
DATABASE_PORT=5432
Multiple env files per environment
Real projects keep a base file plus an environment-specific overlay. The envFilePath option accepts an array, and earlier entries win — once a key is set from the first file, later files do not overwrite it. A common pattern is to load .env.<environment> first and fall back to a shared .env.
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [`.env.${process.env.NODE_ENV || 'development'}`, '.env'],
});
With NODE_ENV=production, NestJS reads .env.production first, then fills in any missing keys from .env. Keep environment-neutral defaults (timeouts, feature flags) in .env and only override the few keys that differ per environment.
| File | Purpose | Committed to VCS? |
|---|---|---|
.env | Shared defaults across all environments | Yes (no secrets) |
.env.development | Local dev overrides | Yes |
.env.test | CI / test runner overrides | Yes |
.env.production | Production overrides | No |
.env.local | Personal machine overrides | No |
Add
.env.localand.env.productionto.gitignore. Commit a.env.exampledocumenting every required key so new contributors know what to set.
Variable expansion
dotenv does not expand variables that reference other variables by default. Set expandVariables: true to enable dotenv-expand, which resolves ${VAR} references — useful for composing URLs from their parts.
# .env
DATABASE_USER=app
DATABASE_PASSWORD=secret
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_URL=postgres://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/app
ConfigModule.forRoot({
isGlobal: true,
expandVariables: true,
});
// src/database/database.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class DatabaseService {
constructor(private readonly config: ConfigService) {}
printUrl(): void {
console.log(this.config.get<string>('DATABASE_URL'));
}
}
Output:
postgres://app:secret@localhost:5432/app
Expansion also resolves references to variables already present in the real process.env, so you can interpolate values injected by the host (CI, Docker, Kubernetes).
Precedence: process env vs files
This is the rule most teams trip over. Variables already set in the real process.env — for example exported in the shell, passed by Docker -e, or defined in a Kubernetes manifest — take precedence over values in .env files. dotenv never overwrites an existing process.env key. This is intentional: it lets your orchestrator override file defaults at deploy time.
# .env
PORT=3000
PORT=8080 node dist/main.js
Output:
Listening on port 8080
The shell-provided PORT=8080 wins over the file’s 3000. Among .env files themselves, order in envFilePath decides precedence (first file wins), as shown above.
ignoreEnvFile in production
In containerized or serverless deployments you usually inject every variable through the platform, and reading a file on disk is unnecessary or impossible. Set ignoreEnvFile: true to skip file loading entirely and rely solely on process.env.
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: process.env.NODE_ENV === 'production',
envFilePath: ['.env.development', '.env'],
});
In development the files load normally; in production NestJS reads only the injected environment. This keeps a single configuration path while avoiding stray .env files in your production image.
Caching lookups
ConfigService.get() reads from process.env on every call, which involves a small lookup cost. For hot paths set cache: true so resolved values are memoized after the first access.
ConfigModule.forRoot({
isGlobal: true,
cache: true,
});
With caching enabled, mutating
process.envat runtime will not be reflected byConfigService. That is fine for almost all apps — env vars should be read-only after startup — but disable caching if you genuinely need live updates.
Best practices
- Commit a
.env.examplewith every key documented; never commit real secrets or.env.production. - Order
envFilePathfrom most specific to least specific so per-environment overrides win. - Let the orchestrator (Docker, Kubernetes, your PaaS) own production values and set
ignoreEnvFile: truethere. - Use
expandVariables: trueto compose derived values like connection strings instead of duplicating data. - Enable
cache: truefor production to avoid repeatedprocess.envlookups on hot paths. - Validate variables at startup so a missing or malformed value fails fast rather than surfacing as a runtime error.
- Remember that real
process.envalways overrides file values — use this for safe deploy-time overrides.