What config validation is and why a NestJS service should fail fast
Environment configuration is input, and it deserves the same suspicion as a request body. Values in process.env and .env files come from deploy manifests, secret stores, CI variables, and the occasional hand edit. Any of them can be missing, misspelled, or the wrong type. Every environment variable arrives as a string or undefined, so PORT=80a0 passes through unless something checks it.
Config validation means checking that input once, at a single boundary, before any module uses it. If DATABASE_URL is absent or PORT is not a number, the process should throw during bootstrap and exit. A container that exits during boot never passes its readiness check, so the orchestrator or deploy pipeline reports a failed rollout and the previous version keeps serving traffic. If the process does not fail at boot, the same mistake appears minutes later as a connection error deep in a repository class, during real requests, with a stack trace that has nothing to do with configuration.
process.env ─┐
├──▶ ConfigModule validate hook ──▶ zod schema
.env files ──┘ │
┌─────────────┴─────────────┐
valid invalid
│ │
typed ConfigService throw → boot aborted
injected into feature (failed rollout,
modules old version stays)
Backend and platform engineers who run the same service across local, CI, staging, and production benefit most, because each environment is a separate chance for drift. The approach fits any service that cannot do useful work without its configuration. Section 6 covers the cases where it doesn't.
+-------------------+ +----------------------+
| process.env | | .env files |
| (manifests, CI, | | (local, per-env) |
| secret stores) | | |
+---------+---------+ +----------+-----------+
| |
+-----------+------------+
| strings or undefined
v
+-----------------------------+
| ConfigModule.forRoot |
| validate hook |
| +-------------------+ |
| | zod schema parse | |
| +-------------------+ |
+--------------+--------------+
|
+------------+-------------+
| valid | invalid
v v
+---------------------+ +-------------------------+
| typed ConfigService | | throw during bootstrap |
| injected into | | -> process exits |
| feature modules | | -> readiness never OK |
+---------------------+ | -> failed rollout, |
| old version serves |
+-------------------------+
Raw environment input from process.env and .env files passes through the ConfigModule validate hook and a zod schema; valid input yields a typed ConfigService, while invalid input throws at bootstrap so the rollout fails and the previous version keeps serving.
How ConfigModule's validate hook works
ConfigModule.forRoot accepts a validate option: a function that receives the raw config record, meaning the merged result of process.env and any loaded .env files, as plain strings. It must do one of two things. It can return the validated object, which then backs ConfigService, so coerced values such as numbers and booleans replace the original strings. Or it can throw. A throw happens during module initialization, so the application never finishes bootstrapping and never binds a port.
The module also offers validationSchema, which takes a Joi schema and applies it internally. That works if Joi is already in your stack. The validate hook is library-agnostic, though, and this article uses it with zod for two reasons. A zod schema doubles as the source of your TypeScript types. It also lets you control the error message format before throwing.
A minimal setup passes isGlobal: true, cache: true, envFilePath: ['.env.local', '.env'], and validate: validateEnv.
isGlobal: truemakesConfigServiceinjectable everywhere without re-importing the module in each feature module.cache: truememoizes reads. This is safe because validated config should not change at runtime.envFilePathordering is first-match-wins. When a key appears in both files,.env.localtakes precedence over.env.
Variables already present in the real environment override every file. A value exported in your shell, or set by a container runtime, will silently shadow the one you just edited in .env. When a change seems to have no effect, check process.env first.
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
// One schema is the source of truth for both runtime checks and types.
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000), // '3000' -> 3000
DATABASE_URL: z.string().url(),
});
export type Env = z.infer<typeof envSchema>;
// Receives the merged raw record (strings). Return the parsed object or throw.
export function validateEnv(config: Record<string, unknown>): Env {
const result = envSchema.safeParse(config);
if (!result.success) {
const details = result.error.issues
.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`)
.join('\n');
// Throwing here aborts bootstrap before the app binds a port.
throw new Error(`Invalid environment configuration:\n${details}`);
}
return result.data; // Coerced values now back ConfigService.
}
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Inject ConfigService anywhere without re-importing.
cache: true, // Safe: validated config does not change at runtime.
// First match wins: .env.local beats .env for duplicate keys.
// Variables already in the real environment beat both files.
envFilePath: ['.env.local', '.env'],
validate: validateEnv,
}),
],
})
export class AppModule {}
A minimal ConfigModule.forRoot setup that validates the merged environment with a zod schema, returns coerced values, and throws a readable error that stops bootstrap when a variable is missing or invalid.
Writing the zod schema for environment variables
check the process environment before you check the file.
Strings in, typed values out
Every value in process.env is a string, so the schema's first job is conversion. Ports and timeouts use z.coerce.number() followed by .int().positive(). Booleans need an explicit transform that accepts only "true" or "false". z.coerce.boolean() calls Boolean(), which turns the string "false" into true.
Blank .env lines such as PORT= produce an empty string rather than an absent key. Coercing "" gives 0, which passes a plain number check. Preprocess empty strings to undefined so that .default() applies or the key is reported as missing.
| Variable | Schema approach | Why |
|---|---|---|
PORT, HTTP_TIMEOUT_MS |
coerce to number, .int().positive() |
Rejects abc and blank values |
TLS_ENABLED |
explicit "true"/"false" transform |
Avoids Boolean("false") === true |
NODE_ENV, LOG_LEVEL |
z.enum([...]) |
Catches typos like prod |
DATABASE_URL |
string with .url() |
Rejects malformed connection strings |
LOG_LEVEL (optional) |
.default("info") |
Use a default only where it is safe |
Use superRefine for rules that span fields. For example, when TLS_ENABLED is true, TLS_CERT_PATH must be present. Attach the issue to the cert path key so the error names the variable the operator needs to set.
Call safeParse and collect every issue into one error, with one line per key giving its path and message. Do not serialize the issue objects. Some issue types carry the received input, and that can put a secret into CI output or your log aggregator. Throw the error from validate, and make sure it is written to stderr and not held in a buffered logger. That way it shows up in container logs before the process exits with a non-zero status.
import { z } from "zod";
// Blank .env lines (PORT=) arrive as "": treat them as absent so defaults apply.
const opt = <T extends z.ZodTypeAny>(s: T) =>
z.preprocess((v) => (v === "" ? undefined : v), s);
const envSchema = z
.object({
NODE_ENV: opt(z.enum(["development", "test", "production"])),
PORT: opt(z.coerce.number().int().positive().default(3000)),
HTTP_TIMEOUT_MS: opt(z.coerce.number().int().positive().default(5000)),
LOG_LEVEL: opt(z.enum(["debug", "info", "warn", "error"]).default("info")),
DATABASE_URL: opt(z.string().url()),
// Accept only "true"/"false"; z.coerce.boolean() would turn "false" into true.
TLS_ENABLED: opt(z.enum(["true", "false"]).default("false").transform((v) => v === "true")),
TLS_CERT_PATH: opt(z.string().optional()),
})
.superRefine((env, ctx) => {
// Attach the issue to the key the operator actually has to set.
if (env.TLS_ENABLED && !env.TLS_CERT_PATH) {
ctx.addIssue({ code: "custom", path: ["TLS_CERT_PATH"], message: "required when TLS_ENABLED=true" });
}
});
// Used as ConfigModule.forRoot({ validate }).
export function validate(config: Record<string, unknown>) {
const result = envSchema.safeParse(config);
if (!result.success) {
// Key and message only. Never serialize issue objects: some carry the received input.
// Keep enums for non-secret keys only, since some enum messages can echo the received value.
const lines = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`);
const msg = `Invalid environment configuration:\n${lines.join("\n")}`;
process.stderr.write(msg + "\n"); // Direct to stderr so it reaches container logs before exit.
throw new Error(msg);
}
return result.data;
}
A zod environment schema for NestJS. It treats blank values as missing, coerces numbers, parses booleans explicitly, enforces a TLS cross-field rule, and reports every invalid key in one error written to stderr without the received values.
Typed access: z.infer, ConfigService, and namespaced config
Once the schema exists, derive the type from it rather than writing a parallel interface: type Env = z.infer<typeof envSchema>. The schema becomes the single source of truth, and a renamed key breaks compilation instead of surfacing at runtime.
Inject ConfigService<Env, true>. The second type argument tells the service that validation already ran, so get no longer returns T | undefined. Combined with get('PORT', { infer: true }), the return type comes from Env, so PORT is the coerced number and not a string. One caveat: if validate returns the parsed object, ConfigService holds coerced values. If it returns the raw input, the types are wrong.
To keep feature modules from reaching for process.env, expose a small provider, such as an AppConfig class with readonly getters, that wraps ConfigService. Features depend on that class. A lint rule that bans process.env outside the config folder enforces the boundary.
Root schema versus namespaces
Keep process-wide settings in the root schema. These are things like port, log level and the node environment. Feature settings can live in registerAs('db', factory) namespaces, consumed through ConfigType<typeof dbConfig> and @Inject(dbConfig.KEY). The catch is that the root validate hook checks only the environment. It does not check what a factory returns. Parse inside each factory with its own schema, such as dbSchema.parse(process.env), so a namespace cannot become an unchecked back door.
There is a trade-off between the two layouts. One giant schema couples every team to a single file and lets one feature's requirement block unrelated deployments. Many namespaces spread the requirements out, so nobody can see the full set a service needs in one place. Shared helpers for building schemas and one test that loads every namespace keep both costs manageable.
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService, ConfigType, registerAs } from '@nestjs/config';
import { z } from 'zod';
// Root schema: process-wide settings only.
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().int().default(3000),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export type Env = z.infer<typeof envSchema>;
// Return the parsed object so ConfigService holds coerced values.
export const validate = (raw: Record<string, unknown>) => envSchema.parse(raw);
// Feature namespace with its own schema; root validate never sees this.
const dbSchema = z.object({
DB_URL: z.string().url(), // e.g. postgres://app:changeme@db.internal:5432/app
DB_POOL_MAX: z.coerce.number().int().positive().default(10),
});
export const dbConfig = registerAs('db', () => {
const env = dbSchema.parse(process.env); // throws at bootstrap if invalid
return { url: env.DB_URL, poolMax: env.DB_POOL_MAX };
});
// Wrapper so feature modules never touch process.env or raw keys.
@Injectable()
export class AppConfig {
constructor(
private readonly config: ConfigService<Env, true>,
@Inject(dbConfig.KEY) private readonly db: ConfigType<typeof dbConfig>,
) {}
get port(): number {
return this.config.get('PORT', { infer: true }); // number, not string
}
get database() {
return this.db; // { url: string; poolMax: number }
}
}
A root Zod schema typed through ConfigService<Env, true>, a registerAs namespace that validates its own slice of process.env, and an AppConfig wrapper that features inject instead of reading the environment.
Testing the boundary so the guarantee does not regress
The schema is the contract, so test it directly before involving Nest. Call the exported schema's safeParse on plain objects: one complete valid record, then variants that each break exactly one thing. Assert that success is false and that the issue's path names the key you broke. Checking the path, and not only the failure, catches a schema that rejects input for the wrong reason.
Two cases deserve their own tests. Booleans: z.coerce.boolean() passes the value through JavaScript truthiness, so the string "false" becomes true. Pin down whatever explicit parsing you chose by testing that "false" and "0" produce false. Empty strings: a variable declared but left blank, as in API_KEY=, arrives as "", which satisfies a bare z.string(). If blank should count as missing, a test should prove it is rejected.
Next, add one boot test that exercises the actual wiring. Build a testing module that imports ConfigModule.forRoot with your validate function and ignoreEnvFile: true, so a developer's local .env cannot mask the failure. Delete one required variable from process.env and assert that compiling the module rejects with an error whose message contains that key name. This confirms the hook is registered and that its message is readable by whoever is on call.
Finally, keep .env.example honest. Parse the file's keys and compare them to the keys of the schema's .shape in both directions. A missing key fails the test, and so does an extra one. Apply the same comparison to your deployment manifests' variable lists if they are stored in the repository.
When to avoid strict boot-time validation, and its limits
Strict validation turns every required key into a deploy blocker. Suppose a rarely used export feature needs SFTP_HOST and that key is marked required. A developer who never touches exports can't start the service locally. A value missing from staging blocks an unrelated hotfix. Decide each field's mode on purpose instead of defaulting to "required".
| Mode | Use when | Risk |
|---|---|---|
| Required | The service can't do its core job without it (DATABASE_URL) |
Blocks boot in every environment, by design |
| Optional with default | A value is safe in every environment (PORT, LOG_LEVEL) |
The default silently applies in production |
| Feature-flag-gated | Only one feature needs it | You must enforce the dependency yourself, e.g. a superRefine that requires SFTP_HOST when EXPORT_ENABLED is true |
The middle row hides the main trap. A default that is convenient locally can reach production unnoticed. Common examples are a CORS_ORIGIN of *, a placeholder JWT_SECRET, or a database URL pointing at localhost. Keep production-sensitive values required. If local ergonomics matter, apply the default only when NODE_ENV is development or test, so production still fails when the value is missing.
Validation also has limits. It checks presence and shape, not reachability. A well-formed DATABASE_URL with the wrong host or expired credentials passes the schema. Detecting that is the job of readiness and health checks, which run against live dependencies.
The validate hook only sees environment variables and .env contents. Secrets fetched asynchronously from a secret store arrive later, through an async config factory. That factory needs its own path: parse the fetched payload with a dedicated zod schema and throw on failure. The service then still refuses to start with incomplete configuration.
Alternatives and impact on cost and engineering time
The other ways to guard configuration differ mainly in how much typing and error detail they give you for the effort.
| Approach | Static typing | Extra dependencies | Error quality | Coercion of string env values |
|---|---|---|---|---|
zod via validate |
Inferred from schema | zod | Per-field paths and messages | Explicit (z.coerce, transforms) |
Joi via validationSchema |
Separate interface you maintain by hand | joi | Per-field messages | Converts by default |
| class-validator + class-transformer | Class doubles as type | Two libraries, decorators | Per-property constraints | Requires implicit conversion or @Type |
Manual process.env checks |
Whatever you cast | None | Whatever you write | Hand-rolled and easy to miss |
| No validation | None | None | Runtime failure far from cause | None |
Joi works well if your team already uses it. Its weakness is that the type and the schema can drift apart. class-validator suits codebases built around DTOs, but it adds decorator overhead to a flat set of key-value pairs.
The costs of the zod approach are one runtime dependency, a schema that must change whenever a variable is added, and keeping .env.example in sync with that schema. The schema also documents the configuration, so new engineers learn what a service needs by reading it instead of reading through stack traces.
Illustrative estimate (assumed numbers, not a benchmark)
net hours/year = (incidents avoided × hours per incident) − (setup hours + maintenance hours)
Assume 4 misconfiguration incidents avoided per year at 3 hours each, 4 hours of setup, and 4 hours of maintenance: (4 × 3) − (4 + 4) = 4 hours saved.
This estimate counts only debugging time. A failed rollout that stops before it receives traffic avoids the cost of an outage, and that cost is usually larger than the hours in the formula.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nestjs config validation, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.