Environment Variables Done Right: Secrets, Config, and Everything In Between
A practical guide to environment variable management — the difference between config and secrets, validation at startup, local development patterns, and production secret injection.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Environment Variables Done Right: Secrets, Config, and Everything In Between
Environment variables are the informal convention that everyone uses and almost nobody thinks about carefully. They accumulate in .env files that grow to 40 lines, get duplicated inconsistently across environments, and occasionally end up committed to git because someone typed git add . without thinking. The result is configuration that is brittle, inconsistent, and occasionally a security incident.
Let me describe how I manage configuration and secrets across environments in a way that is actually maintainable.
Configuration vs. Secrets: They Are Different Things
This distinction matters and most developers conflate them. Configuration is values that change between environments but are not sensitive. NODE_ENV, API_BASE_URL, LOG_LEVEL, CORS_ORIGIN — none of these are secrets. They can be committed to your repository in environment-specific configuration files without any security concern.
Secrets are values that grant access to protected resources. Database passwords, API keys, JWT signing secrets, OAuth client secrets, Stripe keys. These must never appear in your repository, must be managed with access controls, and should be rotated on a schedule.
Treating them identically — everything goes in .env — means you either commit secrets (bad) or you treat non-sensitive config like secrets (cumbersome). Separate them.
Validating Environment Variables at Startup
Your application should fail fast with a clear error message if a required environment variable is missing or malformed. The worst outcome is a deployed application that silently uses undefined values and produces incorrect behavior hours later.
Use Zod to define and validate your environment schema at startup:
import { z } from "zod";
const envSchema = z.object({
// Required with specific types
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
// Optional with defaults
LOG_LEVEL: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
CORS_ORIGIN: z.string().url().optional(),
// Required only in production
SENTRY_DSN: z.string().url().optional(),
});
function validateEnv() {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const errors = parsed.error.flatten().fieldErrors;
console.error("Invalid environment variables:", JSON.stringify(errors, null, 2));
process.exit(1);
}
return parsed.data;
}
export const env = validateEnv();
Call this at application startup, before any other initialization. If validation fails, the application exits with a clear error showing exactly which variables are missing or invalid. This is infinitely better than an application that starts, appears healthy, then crashes on the first request that hits the missing configuration path.
Export the validated env object and import it everywhere you need configuration. Do not access process.env directly throughout your codebase — this bypasses validation and produces untyped string values.
Local Development with .env Files
For local development, .env.local files are the standard approach. The file lives in your project root, is gitignored, and contains values for your local environment.
# .env.local (never committed)
DATABASE_URL=postgres://postgres:password@localhost:5432/myapp_dev
JWT_SECRET=dev-jwt-secret-not-for-production-minimum-32-chars
LOG_LEVEL=debug
Your .env.example file is what you do commit — it documents the required variables with placeholder or example values:
# .env.example (committed)
DATABASE_URL=postgres://user:password@host:5432/dbname
JWT_SECRET=your-jwt-secret-minimum-32-characters
LOG_LEVEL=info
PORT=3000
Every developer clones the repository, copies .env.example to .env.local, fills in their values, and is running. The .env.example file is the authoritative documentation of required configuration.
When you add a new environment variable, update .env.example immediately. Make it part of your definition of done: the PR that adds a new environment variable also updates .env.example and the startup validation schema.
Syncing Team Configuration
The .env.local copy-and-fill approach breaks down as teams grow. Values drift. New variables get added and someone spends an hour debugging "why is this not working" before realizing they never set NEW_REQUIRED_VAR.
Doppler is the tool I recommend for teams. It is a secrets manager with a local development workflow built in. You store all your environment variables (config and secrets) in Doppler, mapped to environments (dev, staging, production). Developers run doppler run -- npm run dev instead of npm run dev. Doppler injects the environment variables at process startup from the remote store.
Every developer always has current values. Adding a new variable is done once in the Doppler dashboard and is immediately available to everyone. The .env.local file disappears from your workflow entirely.
Doppler has a free tier that is sufficient for small teams. The alternatives are HashiCorp Vault for self-hosted, 1Password's op run for teams using 1Password for secrets management, and Infisical for the open-source option.
Production Secret Injection
In production, never use .env files. Use your platform's native secrets mechanism.
For Kubernetes: Kubernetes Secrets, mounted as environment variables or files. Seal secrets at rest with Sealed Secrets or External Secrets Operator pulling from AWS Secrets Manager or Vault.
For Docker on a VPS: inject environment variables through your deployment configuration. If using Docker Compose in production (not recommended for production, but it happens), use the env_file directive pointing to a file that is never in your repository and is placed on the server through a deployment process.
For serverless platforms (Vercel, Cloudflare Workers, AWS Lambda): use the platform's built-in environment variable storage. These values are encrypted at rest and injected at runtime. Never pass secrets through container images or built artifacts.
For CI/CD pipelines: store secrets in your CI platform's secret store (GitHub Actions Secrets, GitLab CI Variables, CircleCI Environment Variables). Reference them as ${{ secrets.MY_SECRET }}. They are masked in logs automatically.
The Secrets You Should Be Rotating
Rotation is the practice of periodically changing secret values, ideally automatically. If a secret is compromised, rotation limits the window of exposure. If your system rotates secrets automatically, a compromised secret that you do not know about has limited useful life for an attacker.
Database passwords: rotate quarterly or on suspicion of compromise. Most ORMs and connection pools support seamless reconnection with new credentials if you handle the rotation carefully (update secret, keep old password valid briefly, update running application configuration, retire old password).
JWT signing secrets: rotate annually or when a security incident suggests it. Rotation invalidates all existing JWT sessions — acceptable for most applications, document the behavior for users.
API keys for third-party services: rotate whenever a team member with access leaves. Use service accounts with limited permissions rather than personal API keys where the service supports it.
Internal service-to-service secrets: rotate on a schedule using an automated rotation mechanism. Manual rotation at this level is not scalable.
The Checklist
Before shipping a new application:
- All environment variables documented in
.env.example - Startup validation schema covers all required variables
- No environment variables hardcoded in source code
- Secrets stored in your platform's secret management (not in repository)
- Production environment variables scoped per environment (not shared between staging and production)
- At least one person on the team knows how to rotate every secret in production
Get this right at the start and you avoid a class of debugging sessions and security incidents that should not happen.
Need help establishing a solid configuration management strategy for your team or application? Book a session at https://calendly.com/jamesrossjr.