Stackvora.tech

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 that NODE_ENV must be one of the enumerated strings. If missing, it defaults to "development".
  • DATABASE_URL: z.string().url("Invalid database URL format."): Requires DATABASE_URL to 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 from process.env into a number. This is crucial as all process.env values are strings.
    • .int(): Ensures the number is an integer.
    • .positive(): Ensures the number is positive.
    • .default(3000): Provides a default value if PORT is 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 to NODE_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(): If CDN_URL is 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:

  1. Never commit .env files to version control: Add .env* to your .gitignore to prevent accidental exposure of sensitive data.
  2. 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). dotenv is primarily for local development.
  3. Least privilege: Only provide your application with the environment variables it absolutely needs.
  4. 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/config not imported early enough: If you import your env validation module before dotenv/config (or dotenv.config()) runs, process.env might not contain the variables from your .env file. Always put the import "dotenv/config"; statement at the very top of your application's entry point file.
  • Misconfigured .env file path: If dotenv.config() is used explicitly, ensure the path option correctly points to your .env file. Relative paths can be tricky; use path.resolve() for robustness.
  • Forgetting .coerce for numbers/booleans: Remember that all values in process.env are strings. If you expect a number or a boolean, you must use z.coerce.number() or z.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 as undefined. Ensure your application logic handles undefined values gracefully where appropriate.
  • ZodError not caught: If you don't wrap envSchema.parse(process.env) in a try...catch block, 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 .env values are not being picked up, check if the same variable is defined in your shell environment, as shell variables take precedence.

Back to the knowledge base · Ask the AI assistant