Skip to content

Environment Variable Management Done Right

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

3 min read
TypeScript configuration module validating environment variables at startup

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.

tstypescript
// ❌ 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.

tstypescript
// ✅ 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.

tstypescript
// ❌ 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
tstypescript
// 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.

shbash
# .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
shbash
# .env — git-ignored, contains real local values
# .env.production — NEVER exists. Production vars come from the platform.
shbash
# .gitignore
.env
.env.local
.env.*.local

Always 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.

tstypescript
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.

ApproachWhen to useSecurity level
.env fileLocal development onlyLow — never in CI/prod
Platform env vars (Vercel, Railway)Simple deploymentsMedium — encrypted at rest
Secrets manager (AWS SSM, Vault)Regulated or high-securityHigh — audit trails, rotation
tstypescript
// 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

  1. Validate all environment variables at startup — crash early with a clear error, not late with a cryptic one
  2. Centralize configuration in a single typed module — ban direct process.env access
  3. Use Zod or similar for runtime validation with automatic type inference
  4. Commit .env.example, never .env — new developers need documentation, not your secrets
  5. Use secrets managers for production credentials — environment variables alone aren't enough for regulated environments
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX