Environment Variable Management Done Right
Stop scattering process.env calls across your codebase — validate, type, and centralize environment configuration for safer deployments.

Environment variables are the standard way to configure applications across environments. The problem isn't the concept — it's how most codebases use them. Scattered process.env calls, missing validation, and silent runtime failures when a variable is undefined. A single missing DATABASE_URL in production shouldn't cause a cryptic error three minutes into startup.
The Scattered Access Problem
When environment variables are accessed directly wherever they're needed, you get undefined behavior scattered across the codebase.
// ❌ Direct access — no validation, no types, no single source of truth
// In database.ts
const client = new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL } },
});
// In email.ts
const apiKey = process.env.SENDGRID_API_KEY; // undefined in staging?
// In auth.ts
const secret = process.env.JWT_SECRET!; // non-null assertion hiding a bug
const expiresIn = process.env.JWT_EXPIRES_IN || "1h"; // is "1h" correct for prod?If SENDGRID_API_KEY is missing, you don't find out until someone triggers an email — possibly hours after deployment.
Validate at Startup
Load and validate all environment variables once, at application startup. If anything is missing or invalid, crash immediately with a clear error message.
// ✅ Centralized, validated, typed configuration
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default("24h"),
SENDGRID_API_KEY: z.string().startsWith("SG."),
ALLOWED_ORIGINS: z
.string()
.transform((s) => s.split(","))
.pipe(z.array(z.string().url())),
});
export type Env = z.infer<typeof envSchema>;
function loadEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error("Invalid environment variables:");
console.error(result.error.flatten().fieldErrors);
process.exit(1);
}
return result.data;
}
export const env = loadEnv();Now every import of env gets typed, validated values. Missing variables crash the app at startup — not at 3 AM when a user triggers a code path.
Type Safety Throughout
Once you have a validated env object, use it everywhere instead of process.env.
// ❌ Type-unsafe — process.env values are always string | undefined
const port = parseInt(process.env.PORT || "3000"); // manual parsing
const isProduction = process.env.NODE_ENV === "production"; // string comparison
// ✅ Type-safe — env.PORT is already a number, env.NODE_ENV is a union type
import { env } from "./config";
const port = env.PORT; // number
const isProduction = env.NODE_ENV === "production"; // TypeScript narrows correctly// Lint rule to ban direct process.env access
// eslint.config.mjs
export default [
{
rules: {
"no-restricted-syntax": [
"error",
{
selector: "MemberExpression[object.name='process'][property.name='env']",
message: "Use the validated `env` object from ./config instead.",
},
],
},
},
];.env Files for Development
Use .env files for local development, never in production. Production environments should inject variables through the deployment platform.
# .env.example — committed to git, documents required variables
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
JWT_SECRET=dev-secret-at-least-32-characters-long
SENDGRID_API_KEY=SG.dev-key
ALLOWED_ORIGINS=http://localhost:3000# .env — git-ignored, contains real local values
# .env.production — NEVER exists. Production vars come from the platform.# .gitignore
.env
.env.local
.env.*.localAlways commit a .env.example with placeholder values. New developers should be able to cp .env.example .env, fill in real values, and start working.
Environment-Specific Configuration
Some values change between environments. Handle this in the validation layer, not scattered through business logic.
const envSchema = z
.object({
NODE_ENV: z.enum(["development", "production", "test"]),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
ENABLE_EMAIL: z.coerce.boolean().default(true),
SENTRY_DSN: z.string().url().optional(),
})
.refine(
(env) => {
// Sentry DSN required in production
if (env.NODE_ENV === "production" && !env.SENTRY_DSN) return false;
return true;
},
{ message: "SENTRY_DSN is required in production" },
);This makes environment requirements explicit. A staging deployment without SENTRY_DSN is fine; a production deployment without it fails at startup.
Secrets Management
Environment variables work for configuration, but secrets deserve additional protection.
| Approach | When to use | Security level |
|---|---|---|
.env file | Local development only | Low — never in CI/prod |
| Platform env vars (Vercel, Railway) | Simple deployments | Medium — encrypted at rest |
| Secrets manager (AWS SSM, Vault) | Regulated or high-security | High — audit trails, rotation |
// For secrets managers, load at startup alongside env vars
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
async function loadSecrets() {
const ssm = new SSMClient({});
const dbPassword = await ssm.send(
new GetParameterCommand({
Name: "/myapp/prod/database-password",
WithDecryption: true,
}),
);
return { DATABASE_PASSWORD: dbPassword.Parameter?.Value };
}Never commit secrets to git. Even in private repositories, secrets in git history persist through force-pushes and branch deletions.
Key Takeaways
- Validate all environment variables at startup — crash early with a clear error, not late with a cryptic one
- Centralize configuration in a single typed module — ban direct
process.envaccess - Use Zod or similar for runtime validation with automatic type inference
- Commit
.env.example, never.env— new developers need documentation, not your secrets - Use secrets managers for production credentials — environment variables alone aren't enough for regulated environments


