Per-Environment Configuration
Ship one image to every environment and inject what differs at start, keeping secrets out of the build entirely.
ConfigModule.forRoot({ envFilePath?: string | string[], ignoreEnvFile?: boolean, override?: boolean })The artifact you tested should be the artifact you deploy. That holds only if nothing about an environment is compiled into it: the image is identical in development, staging and production, and what differs arrives as environment variables when the process starts.
Choosing files
envFilePath takes a path or an array of paths; a single string is the same as a one-element array. Files are merged, and the first file that defines a key wins, so list the most specific one first. These files are a local-development convenience. In production set ignoreEnvFile: true and let the platform supply process.env.
override
override defaults to false, meaning a variable already present in process.env is never replaced by a value from a file. The option was added in @nestjs/config 12; set it to true when you want the file to win instead.
What must never be baked in
No secrets, hostnames or endpoints in the image, and no .env file copied into it — add it to .dockerignore. Secrets come from the orchestrator: a Kubernetes Secret, a task-definition secret, or a secrets manager read at boot.
What trips people up
Because this all resolves at bootstrap, changing a variable needs a restart. There is no reload; a rolling restart is how new configuration reaches the running fleet.
Example
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
const nodeEnv = process.env.NODE_ENV ?? 'development';
Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
// Merged in order, and the FIRST file to define a key wins:
// machine-local overrides beat the committed per-environment file,
// which beats the shared defaults.
envFilePath: [`.env.${nodeEnv}.local`, `.env.${nodeEnv}`, '.env'],
// In production nothing is read from disk at all; the platform
// (Docker, Kubernetes, a task definition) supplies process.env.
ignoreEnvFile: nodeEnv === 'production',
// Default since @nestjs/config 12. Leaving it false means a stray
// .env inside the image cannot shadow injected values.
override: false,
cache: true,
}),
],
})
export class AppModule {}
When to use it
- Promote one built image from staging to production with no rebuild for the new settings.
- Keep a local .env for development while production reads only injected variables.
- Run an end-to-end suite against a separate database by pointing it at .env.test.
- Enable a tracing or seeding module outside production without an if statement around the import.
More examples
Injecting the values the image does not contain
This is the other half of the configuration story: Nest reads process.env, and this is what fills it. Pair it with ignoreEnvFile: true so the app cannot accidentally fall back to a file that happened to ship in the image, and with a validation schema so a missing secretKeyRef crashes the pod at startup instead of serving errors.
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
# The same tag runs in staging and production. Nothing in the
# image knows which one it is in.
image: registry.example.com/api:1.8.3
env:
- name: NODE_ENV
value: production
- name: PORT
value: "3000"
# Non-secret, environment-specific values.
- name: PUBLIC_BASE_URL
valueFrom:
configMapKeyRef:
name: api-config
key: publicBaseUrl
# Secrets are mounted from a Secret, never from a .env file
# and never from a build argument, which is recorded in the
# image history.
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: api-secrets
key: databaseUrl
Registering a module only in some environments
Reach for this instead of an if statement around an import when a whole feature should not exist in some environments: a seeding module, a debug dashboard, a tracing exporter. Note the string form's default-on behaviour, which surprises people who expect an unset variable to mean off.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConditionalModule, ConfigModule } from '@nestjs/config';
import { DevToolsModule } from './dev-tools/dev-tools.module.js';
import { TracingModule } from './tracing/tracing.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// Shorthand: a string condition. The module is registered unless the
// variable's value lowercases to exactly 'false' - so unset counts as on.
ConditionalModule.registerWhen(TracingModule, 'TRACING_ENABLED'),
// Longhand: the predicate the string above desugars to.
ConditionalModule.registerWhen(
TracingModule,
(env) => env.TRACING_ENABLED?.toLowerCase() !== 'false',
),
// A predicate can express anything, which is what you want for
// 'only outside production'. The optional third argument takes
// { timeout, debug }; timeout defaults to 5000 ms and is how long it
// waits for ConfigModule to finish loading before throwing.
ConditionalModule.registerWhen(
DevToolsModule,
(env) => env.NODE_ENV !== 'production',
{ timeout: 5000, debug: true },
),
],
})
export class AppModule {}
Pointing the test environment at its own file
Use this so an end-to-end suite talks to a throwaway database instead of whatever .env happens to point at. The ignoreEnvFile line is what lets the same spec run locally from a file and in CI from injected variables, with no branch in the test body.
// test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { INestApplication } from '@nestjs/common';
import { AppModule } from '../src/app.module.js';
let app: INestApplication;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [
// envFilePath: '.env.test' is the same as envFilePath: ['.env.test'];
// the module normalises a lone string into a one-element array.
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env.test',
// Values the CI runner already exported stay authoritative,
// because override defaults to false.
ignoreEnvFile: process.env.CI === 'true',
}),
AppModule,
],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
Discussion