Hardcoded config values are the fastest way to ship broken code to the wrong environment. Here is how to manage configuration properly.
The Config Module Pattern
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "staging", "production"]).default("development"),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
const parsed = envSchema.safeParse(process.env);
if (\!parsed.success) {
console.error("Invalid environment variables:", parsed.error.flatten());
process.exit(1);
}
export const config = parsed.data;
export type Config = z.infer<typeof envSchema>;
Why Validation at Startup
Without validation, a missing DATABASE_URL crashes your app at the first query, not at startup. By then your health check passed, load balancer routed traffic, and users see 500 errors. Validate early, fail fast.
Environment-Specific Overrides
.env # Shared defaults (committed)
.env.local # Local overrides (gitignored)
.env.production # Production values (gitignored)
.env.test # Test values (committed)
Loading priority: platform env vars > .env.{NODE_ENV}.local > .env.local > .env.{NODE_ENV} > .env
Common Mistakes
Committing .env.local with real secrets. Add it to .gitignore immediately.
Using process.env directly everywhere. Centralize in one config module.
No type coercion. PORT comes as a string. Parse it to a number.
Boolean gotcha:FEATURE_FLAG=falseis still truthy as a string. Parse explicitly.
Production: Use Your Platform
In production, never use .env files. Use:
Docker: env_file or environment in docker-compose, K8s ConfigMap/Secrets
Cloud: AWS Parameter Store/Secrets Manager, GCP Secret Manager, Azure Key Vault
CI/CD: GitHub Actions secrets, GitLab CI variables
The .env file is for local development only.
Part of my Production Backend Patterns series. Follow for more practical backend engineering.
SOCIAL SHARE CARD GENERATOR