Node.js — Read Env Vars Safely
Proper management and validation of environment variables are critical for robust Node.js applications, especially in production environments. Unvalidated…
Proper management and validation of environment variables are critical for robust Node.js applications, especially in production environments. Unvalidated or missing environment variables can lead to application crashes, security vulnerabilities, or unpredictable behavior. This article details a systematic approach to reading, parsing, and validating environment variables using dotenv and Zod, ensuring type safety and comprehensive error handling.
We'll cover the setup, schema definition, validation process, and common practices for handling different types of environment variables, including sensitive data and development-specific configurations. The techniques presented are applicable from small utilities to large-scale microservices.
Setting up the Environment
Before diving into validation, we need to ensure our application can access environment variables. For development environments, this often means loading variables from a .env file. The dotenv package is the de-facto standard for this.
Installation
First, install the necessary packages:
npm install dotenv zod
Or using Yarn:
yarn add dotenv zod
Loading .env files
To load variables from a .env file, typically located in the project root, you can either import dotenv/config directly at the entry point of your application or call dotenv.config() explicitly. The former is often cleaner for simpler setups.
// src/index.ts or app.js
import "dotenv/config";
// Now process.env contains variables from .env file (if it exists)
// ... rest of your application code ...
If you need more control, such as specifying a different path for the .env file or handling different environments (e.g., .env.development, .env.production), use dotenv.config():
// For different environments
import * as dotenv from "dotenv";
import path from "path";
const envPath = process.env.NODE_ENV === "production"
? path.resolve(__dirname, "../.env.production")
: path.resolve(__dirname, "../.env.development");
dotenv.config({ path: envPath });
Remember that environment variables explicitly set in the shell (e.g., DATABASE_URL="postgres://..." node app.js) will always take precedence over those in the .env file, which is desired behavior for production deployments.
Defining the Validation Schema with Zod
Zod is a TypeScript-first schema declaration and validation library. It allows you to define the expected shape and types of your environment variables, providing compile-time type safety and runtime validation.
Basic Schema Definition
Let's define a schema for common environment variables like a database URL and a port number.
// src/config/env.ts
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
DATABASE_URL: z.string().url("Invalid database URL format."),
PORT: z.coerce.number().int().positive().default(3000),
API_KEY: z.string().min(16, "API_KEY must be at least 16 characters long."),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
type EnvSchema = z.infer; // Derive TypeScript type from schema
export const env: EnvSchema = envSchema.parse(process.env);
Let's break down the schema:
z.object({...}): Defines an object shape for our environment variables.NODE_ENV: z.enum(["development", "production", "test"]).default("development"): Specifies thatNODE_ENVmust be one of the enumerated strings. If missing, it defaults to"development".DATABASE_URL: z.string().url("Invalid database URL format."): RequiresDATABASE_URLto be a string and validates it as a URL. A custom error message is provided.PORT: z.coerce.number().int().positive().default(3000):.coerce.number(): Attempts to convert the string value fromprocess.envinto a number. This is crucial as allprocess.envvalues are strings..int(): Ensures the number is an integer..positive(): Ensures the number is positive..default(3000): Provides a default value ifPORTis not set.
API_KEY: z.string().min(16, "API_KEY must be at least 16 characters long."): Requires a string with a minimum length. This is a simple example for sensitive data validation.LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"): Similar toNODE_ENV, for log verbosity.
Handling Optional Variables and Transformations
Zod also allows for optional variables and more complex transformations:
// src/config/env.ts (extended)
import { z } from "zod";
const envSchema = z.object({
// ... previous definitions ...
CDN_URL: z.string().url().optional(), // CDN_URL is optional, if present, must be a URL
FEATURE_TOGGLE_ENABLED: z.coerce.boolean().default(false), // Coerces "true"/"false" strings to booleans
ALLOWED_ORIGINS: z.string().transform(val => val.split(',').map(s => s.trim())).default(""), // Transforms a comma-separated string into an array of strings
});
type EnvSchema = z.infer;
export const env: EnvSchema = envSchema.parse(process.env);
CDN_URL: z.string().url().optional(): IfCDN_URLis present, it must be a valid URL; otherwise, it can be undefined.FEATURE_TOGGLE_ENABLED: z.coerce.boolean().default(false): Converts string values like "true", "false", "1", "0" into actual boolean types.ALLOWED_ORIGINS: z.string().transform(...): This is powerful for more complex types. It takes a string (e.g., "http://localhost:3000,http://example.com") and transforms it into an array of strings. The.default("")ensures it always returns an array, even if the variable is missing (an empty array in this case).
Integrating and Accessing Environment Variables
Once your schema is defined and exported, you can import and use the validated environment variables throughout your application.
// src/index.ts or app.js
import "dotenv/config"; // Ensure .env is loaded first
import { env } from "./config/env"; // Import your validated environment object
// Example usage
console.log(`Application running in ${env.NODE_ENV} mode.`);
console.log(`Listening on port ${env.PORT}.`);
console.log(`Connecting to database: ${env.DATABASE_URL.substring(0, 20)}...`); // Don't print full URL for security
if (env.CDN_URL) {
console.log(`Serving assets from CDN: ${env.CDN_URL}`);
}
console.log(`Feature toggle status: ${env.FEATURE_TOGGLE_ENABLED}`);
console.log(`Allowed origins: ${env.ALLOWED_ORIGINS.join(', ')}`);
// Start your server or application logic
// ...
The env object is now fully type-safe, meaning your IDE will provide autocompletion and type checking for all properties defined in your Zod schema.
Handling Validation Errors
A significant advantage of Zod is its robust error reporting. If any required environment variable is missing or fails validation, envSchema.parse(process.env) will throw a ZodError. It's crucial to catch and handle these errors gracefully, typically by exiting the application with a clear message.
// src/config/env.ts
import { z, ZodError } from "zod";
import "dotenv/config"; // Make sure this is at the very top for .env loading
const envSchema = z.object({
DATABASE_URL: z.string().url("Invalid DATABASE_URL. Must be a valid URL."),
PORT: z.coerce.number().int().positive().default(3000),
// ... other variables
});
type EnvSchema = z.infer;
let validatedEnv: EnvSchema;
try {
validatedEnv = envSchema.parse(process.env);
} catch (error) {
if (error instanceof ZodError) {
console.error("❌ Invalid environment variables:");
for (const issue of error.issues) {
console.error(` - ${issue.path.join(".")} : ${issue.message}`);
}
process.exit(1); // Exit the application with a failure code
} else {
console.error("An unexpected error occurred during environment validation:", error);
process.exit(1);
}
}
export const env = validatedEnv;
This error handling block ensures that your application fails fast and explicitly if the environment configuration is incorrect. The output will clearly indicate which variables are problematic and why, simplifying debugging.
Example output for a missing DATABASE_URL:
❌ Invalid environment variables:
- DATABASE_URL : Invalid DATABASE_URL. Must be a valid URL.
Security Considerations
When dealing with environment variables, especially sensitive ones, consider the following:
- Never commit
.envfiles to version control: Add.env*to your.gitignoreto prevent accidental exposure of sensitive data. - Use secrets management in production: For production, environment variables should be injected by your deployment platform (e.g., Kubernetes Secrets, AWS Secrets Manager, Heroku Config Vars, Vercel Environment Variables).
dotenvis primarily for local development. - Least privilege: Only provide your application with the environment variables it absolutely needs.
- No sensitive defaults: Avoid setting default values for sensitive variables (like API keys or database credentials) unless they are for public/testing purposes. If a sensitive variable is missing, it should explicitly fail validation.
Common Pitfalls and Troubleshooting
dotenv/confignot imported early enough: If you import your env validation module beforedotenv/config(ordotenv.config()) runs,process.envmight not contain the variables from your.envfile. Always put theimport "dotenv/config";statement at the very top of your application's entry point file.- Misconfigured
.envfile path: Ifdotenv.config()is used explicitly, ensure thepathoption correctly points to your.envfile. Relative paths can be tricky; usepath.resolve()for robustness. - Forgetting
.coercefor numbers/booleans: Remember that all values inprocess.envare strings. If you expect a number or a boolean, you must usez.coerce.number()orz.coerce.boolean(), or apply a.transform(). Otherwise, Zod's type checking will fail. - Silent failure of optional variables: If a variable is
.optional()and not present, Zod will treat it asundefined. Ensure your application logic handlesundefinedvalues gracefully where appropriate. - ZodError not caught: If you don't wrap
envSchema.parse(process.env)in atry...catchblock, a validation failure will crash your application without a clean error message, making debugging harder. - Production variables overriding local ones: This is expected behavior. If you are testing locally and find your
.envvalues are not being picked up, check if the same variable is defined in your shell environment, as shell variables take precedence.