What goes wrong with the default ConfigModule setup

Every value in process.env is a string | undefined. Node does not know that PORT should be a number or that ENABLE_CACHE is meant to be a boolean, and it will not tell you that DATABASE_URL is missing. The default ConfigModule.forRoot() setup loads .env files into that same structure and exposes it through ConfigService. It adds no checks of its own.

The typing gap shows up at the call site. ConfigService.get('PORT') without a type argument is effectively untyped. Adding a generic such as get<number>('PORT') only asserts a type. Nothing converts the string "3000" into a number. The compiler is satisfied, but the runtime value is still a string. That mismatch can turn arithmetic into string concatenation or make strict equality checks fail without any error.

The bigger problem is timing. A missing or malformed variable usually fails the first time some code path reads it. That might be a connection attempt, a webhook handler or a scheduled job. By then the service has passed its health checks and is taking traffic, and the error appears as a vague stack trace in the middle of a request instead of a clear message at boot.

The pattern in this article closes both gaps with one Zod schema. That schema is the only definition of which variables exist, how each is parsed and what type it produces. You pass it into ConfigModule.forRoot through the validate hook, so invalid configuration stops the process during startup. The TypeScript types for the rest of the codebase are inferred from the same schema, which keeps them in sync with what was actually validated.

This approach is aimed at backend and platform engineers who own service configuration. That includes the people who define what a deployment must provide and who get paged when a deployment provides something else.

import { Injectable, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { z } from 'zod';

// The problem: with PORT=3000 in .env, the generic is only a type assertion.
@Injectable()
export class LooseService {
  constructor(private readonly config: ConfigService) {}

  nextPort() {
    const port = this.config.get<number>('PORT', 3000); // typed as number...
    return port + 1; // ...but the runtime value is "3000", so this returns "30001"
  }
}

// The fix: one schema defines which vars exist, how they parse, and their types.
export const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(), // required: no default, so missing means boot fails
});
export type Env = z.infer<typeof envSchema>;

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      // Runs once during bootstrap. Throwing here stops the process before it serves traffic.
      validate: (raw: Record<string, unknown>) => {
        const result = envSchema.safeParse(raw);
        if (!result.success) {
          throw new Error(`Invalid environment configuration:\n${result.error.message}`);
        }
        return result.data; // parsed values (PORT is a real number) back ConfigService
      },
    }),
  ],
})
export class AppModule {}

A ConfigService generic only asserts a type and leaves PORT as a string, while a single Zod schema passed to ConfigModule's validate hook parses the values, infers their types and fails at startup when configuration is invalid.

How validation flows from process.env to typed injection

At startup, ConfigModule.forRoot reads your .env file and merges it with the variables already present in process.env. By default, variables set in the real environment take precedence over the file. That precedence matters: a value injected by your orchestrator overrides whatever a developer left in a local .env. The result is a flat record in which every value is a string or undefined.

That record is passed once to the validate function you supply. Inside it, Zod's safeParse does two jobs together. It coerces strings into numbers, booleans, URLs, or enums, and it checks constraints such as required keys, port ranges, and allowed values. If parsing fails, you throw, the module never finishes initializing, and the process exits before it accepts traffic. If parsing succeeds, you return the parsed object, and that object is what ConfigService serves. That is why get('PORT') can return a number instead of the string "3000".

Types are a separate layer from runtime checks. ConfigService<Env, true> uses the type inferred from the schema, and the true flag tells the compiler that keys are guaranteed present, so get does not return T | undefined. registerAs factories, combined with ConfigType, give namespaced injection the same typing. However, a factory reads process.env directly, so it should not assume those values are already coerced.

Validation happens once. If you mutate process.env later, or fetch secrets from a vault after bootstrap, validate does not see those values. Run them through the schema yourself.

+-------------+        +----------------------------+
|  .env file  |        | process.env (real env)     |
|             |        | takes precedence by default|
+------+------+        +-------------+--------------+
       |                             |
       +--------------+--------------+
                      v
      +-------------------------------+
      | raw record (string|undefined) |
      +---------------+---------------+
                      |  once, at module init
                      v
      +-------------------------------+
      | ConfigModule.forRoot          |
      |  validate(raw) -> safeParse   |
      |  coerce types, check rules    |
      +-------+---------------+-------+
        fail  |               |  success
              v               v
   +----------------------+  +--------------------------+
   | throw: init aborts,  |  | parsed object stored     |
   | process exits early  |  | ConfigService<Env, true> |
   +----------------------+  | get('PORT') -> number    |
                             +--------------------------+
   +-----------------------------------------------------------+
   | registerAs + ConfigType: typed injection, but factory     |
   | reads process.env directly -> values are NOT coerced      |
   +-----------------------------------------------------------+
   | Late process.env edits / vault secrets: not revalidated   |
   +-----------------------------------------------------------+

The .env file and process.env merge into a raw string record that validate parses once at module init. Failure aborts boot, while success stores the coerced object behind ConfigService. registerAs factories read process.env directly, and values changed after startup are never revalidated.

Defining the env schema and wiring it into ConfigModule.forRoot

Building the schema

Start with z.object and describe only the keys your application reads. Use z.coerce.number().int() for PORT and timeouts, because every environment variable arrives as a string. Use z.enum([...]) for NODE_ENV and LOG_LEVEL so a typo like prodution fails at boot. Use z.string().url() for DATABASE_URL. Add .default() only where a fallback is safe, such as a local port, and never for credentials.

Leave the object in Zod's default mode, which strips unknown keys instead of rejecting them. process.env also holds PATH, HOME, HOSTNAME, CI variables and whatever the container runtime injects. Calling .strict() would make bootstrap depend on the shell and platform, so the app could pass locally and crash in a new deployment for reasons unrelated to your code.

The validate function

Pass a validate(config) function to ConfigModule.forRoot. It calls schema.safeParse(config). On failure, it maps error.issues to lines of the form path.join('.') followed by message, joins them, and throws a single Error. This reports every bad key in one run instead of one per restart. Never include the received value, because it may be a secret that ends up in logs. On success, return result.data, which holds the coerced values with defaults applied.

Set isGlobal: true so feature modules don't need to import ConfigModule. Set cache: true so repeated get calls skip the lookup.

Typing the service

Derive type Env = z.infer<typeof schema> and inject ConfigService<Env, true>. The second argument tells Nest that validation has already run. With it, get('PORT') returns number instead of number | undefined, so you no longer need the ! assertion, and key names are checked against Env.

import { Injectable, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { z } from 'zod';

// Describe only our keys. Default mode strips PATH, HOME, CI vars, etc.
const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  PORT: z.coerce.number().int().default(3000), // env values arrive as strings
  HTTP_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
  DATABASE_URL: z.string().url(), // no default: credentials must be explicit
});
export type Env = z.infer<typeof envSchema>;

export function validate(config: Record<string, unknown>): Env {
  const result = envSchema.safeParse(config);
  if (!result.success) {
    // Report every failing key in one throw; never echo the received value.
    const lines = result.error.issues.map((i) => `  ${i.path.join('.')}: ${i.message}`);
    throw new Error(`Invalid environment configuration:\n${lines.join('\n')}`);
  }
  return result.data; // coerced numbers, defaults applied
}

@Module({
  imports: [ConfigModule.forRoot({ validate, isGlobal: true, cache: true })],
})
export class AppModule {}

@Injectable()
export class ServerSettings {
  // `true` = validation ran, so get() drops `| undefined` from the return type.
  constructor(private readonly config: ConfigService<Env, true>) {}

  port(): number {
    return this.config.get('PORT', { infer: true }); // typed as number, no `!`
  }
}

A Zod env schema wired into ConfigModule.forRoot through a validate function that reports every failing key without values, plus a ConfigService<Env, true> consumer that reads PORT as a plain number.

Coercion pitfalls: booleans, empty strings and environment-specific rules

Zod's coercion helpers apply JavaScript's built-in conversions, and those conversions do not match how people write environment variables. z.coerce.boolean() calls Boolean(input), so any non-empty string is true. That includes 'false' and '0'. z.coerce.number() calls Number(input), and Number('') is 0. A line like PORT= in a .env file, or an unset variable written as ${PORT} in a compose file, therefore validates as port 0 instead of failing.

Parse booleans and numbers explicitly

For booleans, accept a closed set of strings and map them yourself. Use z.enum(['true', 'false', '1', '0']) followed by a .transform that returns true for 'true' or '1'. A typo such as ture then fails at boot instead of silently becoming true.

For numbers, reject empty input before coercing. Start with z.string().trim().min(1), then .pipe() into z.coerce.number().int().positive(). Each stage reports its own error, so the boot failure tells you whether the value was missing or malformed.

Keep conditional rules explicit

Local development and some CI jobs don't need every variable, such as SENTRY_DSN or SMTP_HOST. Marking those variables .optional() everywhere also relaxes them in production. A better approach is to model the condition directly:

  • z.discriminatedUnion('NODE_ENV', [...]): Each branch is an object schema keyed on a literal NODE_ENV. The production branch requires the variables, and the development branch omits them. The inferred type is a union, so code has to narrow on NODE_ENV before it reads production-only fields.
  • .superRefine: Use this when the condition is more complex than one discriminator, for example "SMTP_HOST is required when EMAIL_ENABLED is true." Call ctx.addIssue with the offending path so the error names the variable.
import { z } from 'zod';

// Accept only a closed set of strings, so a typo like 'ture' fails at boot instead of becoming true.
const envBool = z
  .enum(['true', 'false', '1', '0'])
  .transform((v) => v === 'true' || v === '1');

// Reject empty or whitespace-only input before coercing, so `PORT=` fails instead of becoming 0.
const envInt = z.string().trim().min(1).pipe(z.coerce.number().int().positive());

const shared = {
  PORT: envInt,
  EMAIL_ENABLED: envBool,
  SMTP_HOST: z.string().min(1).optional(),
};

// Production requires SENTRY_DSN. Development and test leave it out on purpose, not through .optional().
const envSchema = z
  .discriminatedUnion('NODE_ENV', [
    z.object({ NODE_ENV: z.literal('production'), ...shared, SENTRY_DSN: z.string().url() }),
    z.object({ NODE_ENV: z.literal('development'), ...shared }),
    z.object({ NODE_ENV: z.literal('test'), ...shared }),
  ])
  .superRefine((env, ctx) => {
    // Cross-field rule: SMTP_HOST is required only when email is enabled.
    if (env.EMAIL_ENABLED && !env.SMTP_HOST) {
      ctx.addIssue({ code: 'custom', path: ['SMTP_HOST'], message: 'SMTP_HOST is required when EMAIL_ENABLED is true' });
    }
  });

export type Env = z.infer<typeof envSchema>;

// Pass to ConfigModule.forRoot({ validate }). A ZodError thrown here stops the app before it serves traffic.
export function validate(config: Record<string, unknown>): Env {
  return envSchema.parse(config);
}

Strict boolean and integer parsers, plus a NODE_ENV discriminated union and a superRefine rule, keep the requirements explicit for each environment without falling back to .optional().

Namespaced typed config with registerAs and ConfigType

Once the root schema works, the next problem is scope. A single flat config object makes every service depend on every variable, and configService.get('DATABASE_URL') gives you no type information at the call site. Feature factories fix both.

Create one factory per concern (database, http, auth) with registerAs. Inside each factory, parse process.env with a slice schema that covers only that feature's variables, then return the shape you want consumers to see, such as { url, poolSize, ssl } instead of raw DATABASE_* keys. Register the factories through load in forRoot or with ConfigModule.forFeature in the owning module. Consumers inject the slice with @Inject(databaseConfig.KEY) and type it as ConfigType<typeof databaseConfig>. The type is inferred from the factory's return value, so the schema, the factory and the consumer stay in sync without hand-written interfaces.

The risk is duplication. If forRoot validates DATABASE_POOL_SIZE with one rule and the factory validates it with another, the two drift apart and one of them silently stops mattering. Choose one of these approaches:

  • Validate in one place. Validate only in the factories, or only at the root.
  • Compose the root schema from the slices. Build the root schema with .merge or .extend so each rule is defined exactly once. Plain object schemas compose cleanly. Schemas wrapped in superRefine do not, so apply cross-field refinements after composition.

None of this protects code that reads process.env directly or calls get without a type argument. Those reads compile, run and skip validation entirely. A lint rule that bans process.env outside config factories is cheap insurance.

Testing schemas and overriding config in integration tests

Unit-testing the schema directly

The schema is a plain Zod object, so you can test it without booting Nest. Keep a minimal valid fixture as a plain object and derive invalid cases from it: drop a required key, set PORT to "abc", pass "false" where a boolean is expected. Call safeParse and assert on error.issues. Check each issue's path and message, not only success === false. A test that only checks for failure still passes if the schema rejects the input for the wrong reason, such as a typo in a key name. Asserting on path: ['DATABASE_URL'] confirms that the rule you meant to test is the one that fired. These tests take milliseconds, and they are the right place to cover the coercion edge cases from the previous section.

Integration tests without a local .env

By default, ConfigModule.forRoot reads .env from the working directory. That file differs between developers and usually does not exist in CI. A test that passes on one laptop and fails on another is often reading a value nobody committed. Pass ignoreEnvFile: true and supply values through load: [() => ({ ... })]. The test then declares every input it depends on.

ignoreEnvFile does not clear process.env, though. If your validate function reads process.env, variables exported in a developer's shell or set by the CI runner still reach it. For tests that only need one namespace, skip ConfigModule entirely. Provide the registerAs token directly with { provide: databaseConfig.KEY, useValue: { ... } }, or use overrideProvider(databaseConfig.KEY) on the testing module. Either way, the injected object is exactly what the test defines, regardless of which machine runs it.

Alternatives, when to avoid Zod, and impact on cost and engineering time

The same boot-time guarantee is available without Zod. The options differ mainly in where the TypeScript type comes from and how strings from process.env become numbers and booleans.

Approach Type inference Coercion Extra dependencies Fit with NestJS conventions
Zod via validate Inferred from the schema Explicit and per field Zod Neutral
class-validator + class-transformer The class is the type Decorators, or implicit conversion Two packages Matches DTO validation
Joi via validationSchema None; types are maintained separately Converts by default Joi Documented forRoot option
Unvalidated ConfigService.get Whatever generic you assert None None Default behaviour

When to avoid Zod

Skip Zod if your team already validates DTOs with class-validator and does not want two validation libraries with different error formats. A config class with decorators keeps everything in one idiom.

Also skip it for very small services with two or three variables. In that case, a hand-written check in validate is enough.

Cost and engineering time

Zod adds one runtime dependency and a one-time setup effort: writing the schema and the registerAs factories. In return:

  • Fewer late failures. A missing variable stops the process at boot instead of breaking the first request that reads it.
  • Faster diagnosis. One aggregated error lists every bad key, so you fix them all in a single redeploy rather than one at a time.
  • A documented contract. The schema tells platform teams exactly which variables a service needs.

Operational rules

  • Fail fast at boot. Do not fall back silently to defaults for required keys.
  • Report the key and the problem, such as "DATABASE_URL: required" or "PORT: expected number". Never include the value.
  • Never log the full raw or parsed config object. It contains secrets, and logs are kept longer and read more widely than the environment they came from.

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 zod, 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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.