Good to see you again. Last time, you coordinated concurrent Node.js work with Promise combinators and AbortSignal: promises express an outcome policy, while cancellation needs an explicit lifetime signal. Before a service starts such work—or accepts even its first HTTP request—it needs a different kind of reliability boundary: validated configuration.
For a Node.js service, environment variables arrive as external, untyped text. This lesson builds a small configuration module that parses that text once at startup, rejects invalid deployments early, and exports a typed AppConfig object to the rest of the application. Plan for about 40 minutes, including a short video and two focused readings.
1. Configuration is an executable contract
An environment variable is operational input: it may come from a local .env file, a CI runner, Docker Compose, ECS task configuration, or a secret manager. Its presence in process.env does not mean it is usable.
For example, neither TypeScript nor Node prevents these deployment-time mistakes:
PORT=not-a-numberDATABASE_URL=NODE_ENV=prodution- a required variable being omitted altogether
FEATURE_REALTIME=falsebeing treated as truthy by an incorrect conversion
Reading directly from process.env distributes this uncertainty through the codebase:
const port = Number(process.env.PORT);
const databaseUrl = process.env.DATABASE_URL;
server.listen(port);
This compiles, but it does not establish useful guarantees:
Number(undefined)isNaN.Number("not-a-number")is alsoNaN.databaseUrlremainsstring | undefined.- Code elsewhere can read the same variable differently.
- A configuration error may not surface until the first request, database connection, or background job.
A better design treats configuration as a boundary with four jobs:
- Read raw values from
process.env. - Validate required values and allowed formats.
- Transform text into useful runtime values, such as a numeric port or boolean flag.
- Export one validated, typed configuration object.

The important distinction is this:
process.envis raw input.configis trusted application data.
Watch Lazar Nikolov’s concise walkthrough before implementing the pattern.
Validate your environment variables with Zod
In “Validate your environment variables with Zod,” Lazar Nikolov introduces a single-schema approach: define environment requirements, parse process.env, and export the parsed result. The final segment highlights a crucial TypeScript pitfall that this lesson avoids.
Watch the problem to see why custom environment variables are not automatically typed. Then watch the core pattern, focusing on the distinction between a Zod schema and its parsed output. Finish with the caveat: even when TypeScript declarations suggest otherwise, values in process.env remain strings at runtime.
The Node documentation gives the underlying reason for that caveat: process.env is a process-scoped representation of the operating-system environment, not a typed application-settings object.
Process | Node.js v25.2.1 Documentation
Read the Node.js documentation to understand why mutating process.env is not a sound configuration strategy and why conversion belongs in a separate layer.
In the process.env subsection, read from the paragraph beginning “It is possible to modify this object” through the warning about future behavior. Read the mutability caveat. Focus on two points: changes do not escape the running process, and assigned values are converted to strings rather than becoming reliably typed settings.
2. Define one schema and export parsed configuration
Install Zod if it is not already in the project:
npm install zod
Create src/config/env.ts. The schema below is realistic for the project-management capstone without prematurely adding every future feature.
import { z } from "zod";
const booleanFromEnv = z
.enum(["true", "false"])
.optional()
.transform((value) => value === "true");
const envSchema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
PORT: z
.coerce
.number()
.int()
.min(1)
.max(65_535)
.default(3000),
DATABASE_URL: z
.string()
.url("DATABASE_URL must be a valid URL"),
CORS_ORIGIN: z
.string()
.url("CORS_ORIGIN must be a valid URL"),
LOG_LEVEL: z
.enum(["debug", "info", "warn", "error"])
.default("info"),
FEATURE_REALTIME: booleanFromEnv,
});
export type AppConfig = Readonly<z.output<typeof envSchema>>;
export function loadConfig(
rawEnv: NodeJS.ProcessEnv = process.env,
): AppConfig {
const result = envSchema.safeParse(rawEnv);
if (!result.success) {
const details = result.error.issues
.map((issue) => {
const key = issue.path.join(".") || "environment";
return ` ${key}: ${issue.message}`;
})
.join("\n");
throw new Error(`Invalid environment configuration:\n${details}`);
}
return Object.freeze(result.data);
}
This module is deliberately small, but it captures several senior-level choices.
| Raw variable | Validation and transformation | Parsed AppConfig type |
|---|---|---|
NODE_ENV | Must be one of three deployment modes; defaults locally | "development" | "test" | "production" |
PORT | Coerced from text, then checked as an integer in the usable port range | number |
DATABASE_URL | Required and checked as a URL | string |
CORS_ORIGIN | Required and checked as a URL | string |
LOG_LEVEL | Restricted to defined logging levels | union of valid levels |
FEATURE_REALTIME | Only literal "true" or "false" accepted; omitted means disabled | boolean |
Why z.coerce.number() is appropriate for PORT
Environment variables are strings. With:
PORT=3000
Node receives "3000", but your HTTP server expects a number. z.coerce.number() performs the conversion, then the rest of the chain validates the converted value.
This is safer than a type assertion:
const port = process.env.PORT as unknown as number;
That assertion only changes what TypeScript allows you to write. It does not turn "3000" into 3000, and it does not catch "not-a-number".
Why the boolean parser is explicit
Avoid this common pattern:
const featureEnabled = Boolean(process.env.FEATURE_REALTIME);
Every non-empty string is truthy in JavaScript, including "false". Likewise, generic boolean coercion can produce surprising behavior because it follows JavaScript truthiness rules rather than a deployment contract.
The explicit schema accepts exactly "true" and "false". Its transform makes the application-level value a real boolean:
config.FEATURE_REALTIME; // boolean
Why safeParse() rather than parse()?
Both choices validate at runtime:
parse()returns parsed data or throws a Zod error immediately.safeParse()returns a discriminated result object: either{ success: true, data }or{ success: false, error }.
Here, safeParse() lets the configuration layer produce a concise startup error naming the invalid keys and rules. Crucially, it does not include the raw values. That protects secrets that may appear in variables such as DATABASE_URL, API keys, or session secrets.
For a configuration schema, reject unknown values only if you have a specific operational reason to do so. process.env includes many unrelated variables supplied by Node, shells, CI systems, and deployment platforms. The schema should select and validate the variables your service owns.
3. Start the service only after configuration succeeds
Validation provides value only if it happens before server startup and before dependencies use configuration. Keep the lifetime decision at the composition root, commonly src/main.ts.
import { createHttpServer } from "./http/create-http-server.js";
import { createDependencies } from "./infrastructure/create-dependencies.js";
import { loadConfig } from "./config/env.js";
async function main(): Promise<void> {
const config = loadConfig();
const dependencies = await createDependencies(config);
const server = createHttpServer({
config,
dependencies,
});
server.listen(config.PORT, () => {
console.info(`Server listening on port ${config.PORT}`);
});
}
void main().catch((error: unknown) => {
console.error("Service failed to start", error);
process.exitCode = 1;
});
The startup policy is now clear:
- Read and validate deployment inputs.
- Construct infrastructure using trusted settings.
- Build the HTTP application with explicit dependencies.
- Listen only after successful initialization.
If DATABASE_URL is absent or PORT is invalid, loadConfig() throws. main() handles that failure, the process receives a non-zero exit code, and the service never binds a port. In a container platform, this is preferable to reporting itself as alive while being unable to serve correct requests.
process.exitCode = 1 is often preferable to calling process.exit(1) directly. Setting the exit code lets pending standard-output writes and normal cleanup complete. Later in the course, graceful shutdown will add deliberate cleanup for open servers and database pools.
Inject AppConfig; do not reread environment variables
Pass config into the components that require it:
import type { AppConfig } from "../config/env.js";
type CreateServerOptions = {
config: AppConfig;
dependencies: Dependencies;
};
export function createHttpServer({
config,
dependencies,
}: CreateServerOptions) {
// Use config.LOG_LEVEL, config.CORS_ORIGIN, and dependencies here.
}
Avoid this inside application code:
const databaseUrl = process.env.DATABASE_URL;
It silently reintroduces string | undefined, bypasses validation, and makes behavior harder to test. It also means a developer must search the whole repository to understand the service’s configuration surface.
Passing an AppConfig object makes dependencies visible in function signatures and lets tests create an isolated configuration without changing global process state.
For example, a test can validate parsing directly:
const baseEnv = {
NODE_ENV: "test",
PORT: "3001",
DATABASE_URL: "postgresql://user:password@localhost:5432/app_test",
CORS_ORIGIN: "http://localhost:5173",
LOG_LEVEL: "warn",
FEATURE_REALTIME: "true",
};
const config = loadConfig(baseEnv);
console.assert(config.PORT === 3001);
console.assert(config.FEATURE_REALTIME === true);
And it can assert that bad input is rejected:
const invalidEnv = {
...baseEnv,
PORT: "not-a-port",
};
console.assert(
(() => {
try {
loadConfig(invalidEnv);
return false;
} catch {
return true;
}
})(),
);
You will use more idiomatic test tooling in the testing module. The immediate point is architectural: loadConfig(rawEnv) is deterministic and testable because it accepts input rather than reaching for global state unconditionally.
4. Load local variables safely; deploy real secrets safely
Node can load a local .env file without adding a separate package:
node --env-file=.env dist/main.js
For programmatic loading, modern Node versions also provide process.loadEnvFile(). In most application services, prefer the command-line --env-file option for local development because the environment is populated before application modules begin running.
Create a local .env file:
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://app:app@localhost:5432/project_manager
CORS_ORIGIN=http://localhost:5173
LOG_LEVEL=debug
FEATURE_REALTIME=false
Then commit a sanitized .env.example:
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DATABASE
CORS_ORIGIN=http://localhost:5173
LOG_LEVEL=info
FEATURE_REALTIME=false
Your repository should include this in .gitignore:
.env
.env.*
!.env.example
The exception preserves the onboarding template while preventing local credentials and deployment secrets from being committed.
Read environment variables in a Node.js application
Read the closing best-practices section for the repository and deployment hygiene that accompanies runtime validation.
In “Best practices with environment variables,” read the best practices. Focus on the roles of .gitignore, .env.example, documented variable dependencies, and deployment-specific secret management.
A useful division of responsibility is:
| Context | How values usually arrive | What belongs in source control |
|---|---|---|
| Local development | .env loaded with --env-file | .env.example, never the actual .env |
| CI | CI secret and variable configuration | Pipeline definitions, never secret values |
| Containers and cloud | Runtime environment configuration or a secret manager | Infrastructure configuration references, not plaintext secrets |
A schema is not a secret-management system. It can ensure a secret exists and meets a minimum format, but it cannot prevent accidental logging, guarantee rotation, or control access to the deployment platform. Those are separate operational concerns.
Configuration implementation checklist
Before considering this layer complete, verify that your service:
- has one schema defining the variables it owns;
- parses each raw environment value exactly once at startup;
- exports an application-level object with parsed types;
- fails before opening its HTTP port when required values are absent or malformed;
- never types transformed values directly on
ProcessEnv; - never logs raw configuration or secrets on validation failure;
- documents local keys in
.env.example; - injects
AppConfigrather than repeatedly accessingprocess.env.
Key takeaways
Environment variables are external text input, not application configuration. A typed configuration layer uses a runtime schema to validate that input, transform values such as ports and booleans, and expose a trusted AppConfig object.
The design to carry forward is:
- Keep
process.envat the outer boundary. - Define requirements once with a Zod schema.
- Use parsed config values rather than type assertions or global
ProcessEnvaugmentation. - Validate before infrastructure initialization and before the HTTP server listens.
- Pass validated configuration explicitly to the components that need it.
- Keep local secret files out of version control while maintaining a safe
.env.example.
Next, you will use this explicit configuration and composition-root style to define a clean TypeScript module boundary between transport, application, and infrastructure code.
Can't find a good explanation? Sign up and we'll make it for you
Sign up