Environment Variables and Secrets Management: A Complete Guide for Web Developers
I once pushed a database password to a public GitHub repository. It was 2019, I was junior, and the repo was a personal project with 3 stars. Within 14 minutes — fourteen minutes — someone had used those credentials to connect to my database and drop every table. Automated bots scan GitHub for secrets continuously. That experience taught me a lesson I have never forgotten.
Environment variables and secrets management seem straightforward, but the gap between "works on my machine" and "secure in production" is enormous. Here is everything I wish someone had told me earlier.
The Basics: What Goes Where
Configuration values fall into three categories:
Public configuration — values that are not secret and can be committed to version control. Feature flags, API base URLs for public services, application name, default locale. These can live in your codebase directly or in .env files that are committed.
Private configuration — values that differ between environments but are not secret. Database hostnames (when behind a VPC), internal service URLs, logging levels, cache TTLs. These live in environment variables, set per environment.
Secrets — values that grant access to resources. API keys, database passwords, JWT signing keys, OAuth client secrets, encryption keys. These must NEVER be in version control and must be stored in a secrets manager in production.
Development: The .env Pattern
For local development, .env files are the standard. Most frameworks (Next.js, Rails, Django, Spring) load .env automatically. The conventions:
.env — default values, safe to commit. Contains non-secret defaults and documentation of what variables the application expects.
.env.local — local overrides with actual secrets, NEVER committed. Add to .gitignore immediately.
.env.development — development-specific overrides, can be committed if no secrets.
.env.production — production values set through your deployment platform, NEVER committed.
The most important rule: .env.local must be in .gitignore BEFORE you create it. If you create the file, add secrets to it, then add it to .gitignore later, the secrets are already in git history. Even if you delete the file and commit the deletion, the secrets remain in history. You would need to rewrite git history (force push) to truly remove them.
The NEXT_PUBLIC_ Prefix (and Equivalents)
In Next.js, environment variables are only available server-side by default. Variables prefixed with NEXT_PUBLIC_ are inlined into the client-side JavaScript bundle during build time. This is an intentional security boundary:
NEXT_PUBLIC_API_URL=https://api.example.com — safe, this is a public URL
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... — safe, publishable keys are designed to be public
DATABASE_URL=postgres://... — server only, never reaches the client
STRIPE_SECRET_KEY=sk_live_... — server only, would be catastrophic if leaked
Other frameworks have equivalent patterns: VITE_ prefix in Vite, REACT_APP_ in Create React App. The key principle is the same: anything prefixed for client exposure is visible to all users. Never put secrets in client-prefixed variables.
A common mistake: putting an API key in NEXT_PUBLIC_ because "the client needs to call the API directly." Instead, create a server-side API route that adds the key and proxies the request. The client calls your route; your route calls the external API with the secret key.
Production: Secrets Managers
In production, environment variables should come from a secrets manager, not from a .env file on a server. Secrets managers provide:
Access control — who and what can read each secret, with audit logs.
Rotation — automatic key rotation without redeploying applications.
Encryption at rest — secrets are encrypted in storage.
Versioning — history of secret values for rollback.
Options by platform:
AWS: Secrets Manager or Parameter Store (SSM). Secrets Manager is more featured (automatic rotation, cross-account sharing) but costs $0.40/secret/month. Parameter Store is free for standard parameters.
Vercel: built-in environment variables with per-environment scoping. Good for simple applications. No automatic rotation.
Railway/Fly.io: built-in environment variables with encrypted storage.
HashiCorp Vault: self-hosted, maximum flexibility. Best for organizations with strict compliance requirements.
Doppler/Infisical: developer-friendly SaaS secrets managers. Good for teams that want a dedicated solution without self-hosting.
Common Mistakes and How to Avoid Them
Mistake 1: Secrets in Docker Images
If you copy a .env file into your Docker image or set environment variables in a Dockerfile with ENV, the secrets are baked into the image layers. Anyone who pulls the image can extract them.
Fix: pass secrets at runtime via docker run --env-file or container orchestration secrets (Kubernetes Secrets, ECS task definition secrets). The image should not contain any secrets.
Mistake 2: Secrets in CI/CD Logs
Many CI systems print environment variables in debug mode. A single console.log(process.env) or env command in your CI script leaks every secret to the build logs.
Fix: use your CI platform's secrets feature (GitHub Actions secrets, GitLab CI variables). These are automatically masked in logs. Never echo or log environment variables in CI.
Mistake 3: Shared Secrets Across Environments
Using the same database password for development, staging, and production. When the development password leaks (and development passwords always leak because developers are less careful), production is compromised.
Fix: unique secrets per environment. Generate a different key for every environment and every service. Secrets managers make this manageable.
Mistake 4: Long-Lived Secrets Without Rotation
An API key created three years ago, shared by email, stored in a wiki, and never rotated. Everyone who has ever worked at the company has access.
Fix: rotate secrets on a schedule (90 days for most secrets, 30 days for highly sensitive ones). Use secrets managers with automatic rotation support. When an employee leaves, rotate all secrets they had access to.
Mistake 5: Secrets in Error Messages
Throwing an error that includes the connection string: "Failed to connect to postgres://user:password@host:5432/db". The error message leaks to logs, error tracking services, and potentially to users.
Fix: sanitize connection strings before including them in errors. Log the host and database name, never the credentials.
Validation at Startup
Validate that all required environment variables are set when your application starts, not when they are first used. A missing variable should crash the application immediately with a clear error message listing which variables are missing.
In TypeScript, use a validation library like zod to parse and validate environment variables at startup. Define a schema that specifies which variables are required, their types, and default values. Export the validated config object and use it throughout the application instead of accessing process.env directly.
This catches configuration errors at deploy time (the application fails to start and deployment rolls back) rather than at runtime (a user hits a code path that needs the variable and gets a 500 error).
The .env.example Pattern
Maintain a .env.example file in your repository that lists every environment variable the application needs, with dummy values and comments explaining each one. This serves as documentation for new developers and as a template for setting up new environments.
Update .env.example in the same PR that adds a new environment variable. This ensures the documentation stays in sync with the code.
Key Rotation Strategy
When you need to rotate a secret (scheduled rotation, employee departure, suspected compromise):
The dual-read period prevents downtime during rotation. For database passwords, create a new database user with the new password, update the application, then delete the old user.
Our Approach at AIDToolStack
We use Vercel environment variables for production secrets (database URL, Clerk keys, Stripe keys, Sentry DSN). For local development, .env.local with local-only credentials (SQLite path, test Clerk keys).
Our application validates all environment variables at startup using a Zod schema. Missing variables produce a clear error message listing exactly what needs to be set. This has prevented multiple deployment issues where a new variable was added in code but not configured in the deployment platform.
The .env.example file in our repository lists every variable with descriptions. New contributors can set up the project in minutes by copying .env.example to .env.local and filling in their own values.
Security is not glamorous work, but every minute spent on proper secrets management saves hours of incident response when things go wrong. And things always go wrong eventually.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.