Environment variables are the standard way to configure applications without hardcoding values. They seem simple — key-value pairs, what could go wrong? Quite a lot, actually. This guide covers the practices that keep your config clean, your secrets safe, and your team productive.
Naming Conventions
Use SCREAMING_SNAKE_CASE. No exceptions. This is the universal convention across languages and platforms, and it makes environment variables immediately distinguishable from regular code variables.
# Good
DATABASE_URL=postgres://...
STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_APP_URL=https://myapp.com
# Bad
databaseUrl=postgres://...
stripe-secret-key=sk_live_...
appUrl=https://myapp.com
Use prefixes for namespacing. When your app has dozens of variables, prefixes group them logically and prevent collisions:
# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
# AWS
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
# App-specific
APP_LOG_LEVEL=info
APP_PORT=3000
Prefixes also help with tooling. You can grep for DB_ to find all database config, or use framework-specific prefixes like NEXT_PUBLIC_ or VITE_ to control what gets exposed to the client.
Never Commit Secrets
This is rule number one. Secrets in git history are effectively public — even if you delete them in a later commit, they're recoverable.
Your .gitignore should include:
.env.local
.env*.local
.env.development.local
.env.production.local
But .gitignore isn't enough. Mistakes happen. Add a safety net with pre-commit hooks:
# Install git-secrets (AWS Labs)
brew install git-secrets
# Register common secret patterns
git secrets --register-aws
git secrets --install
# Add custom patterns
git secrets --add 'sk_live_[a-zA-Z0-9]+'
git secrets --add 'PRIVATE KEY'
Now any commit containing a pattern that looks like a secret will be rejected before it reaches your history. You can also use truffleHog or gitleaks as part of CI to scan for secrets that slipped through.
Validate at Startup
Don't let your app start with missing or malformed config. Fail fast, fail loudly.
With Zod:
// lib/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
PORT: z.coerce.number().default(3000),
})
export const env = envSchema.parse(process.env)
If DATABASE_URL is missing or isn't a valid URL, your app crashes immediately with a clear error — not five minutes later with a cryptic connection timeout.
For Next.js, use @t3-oss/env-nextjs:
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().min(1),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
})
This gives you TypeScript autocompletion, build-time validation, and runtime protection against accessing server variables on the client. See our Next.js environment variables guide for a deep dive.
Runtime vs Build-time Variables
Not all environment variables behave the same. The distinction between runtime and build-time variables matters more than most developers realize.
Next.js: Variables prefixed with NEXT_PUBLIC_ are inlined at build time. They get string-replaced into your JavaScript bundle:
// Your code
const url = process.env.NEXT_PUBLIC_API_URL
// After build (in the browser bundle)
const url = "https://api.example.com"
You can't change NEXT_PUBLIC_ values without rebuilding. Server-only variables (no prefix) are read at runtime and can change with a restart.
Vite: Same concept, different prefix. VITE_ variables are exposed to the client:
// Accessible in browser code
const apiUrl = import.meta.env.VITE_API_URL
// Server-only (not available in browser)
const dbUrl = process.env.DATABASE_URL
The rule: if a variable contains a secret, it must be server-only and read at runtime. Public variables that are inlined at build time are visible to anyone who opens browser dev tools.
Per-Environment Configuration
Most apps run in at least three environments: development, staging, and production. Each needs different config.
The .env file hierarchy (Next.js example):
.env # Shared defaults (committed)
.env.local # Local secrets (git-ignored)
.env.development # Dev defaults (committed)
.env.development.local # Dev secrets (git-ignored)
.env.production # Prod defaults (committed)
.env.production.local # Prod secrets (git-ignored)
Key principles:
- Committed files contain non-sensitive defaults: URLs for local services, log levels, feature flags.
.localfiles contain secrets and personal overrides. Always git-ignored.- Production secrets live in your hosting platform or secrets manager — never in files.
Always maintain a .env.example that documents every required variable:
# .env.example (committed to git)
# Copy to .env.local and fill in your values
# Required
DATABASE_URL=
STRIPE_SECRET_KEY=
# Optional (defaults shown)
LOG_LEVEL=info
PORT=3000
This is your onboarding documentation. New developers should be able to cp .env.example .env.local, fill in the blanks, and start working. If that process is painful, read about the developer onboarding secrets problem.
Secret Rotation
Secrets aren't forever. You need a rotation strategy for three scenarios:
When a team member leaves. Rotate every secret they had access to. If you can't enumerate what they accessed, rotate everything. This is the strongest argument for proper access controls — the blast radius of a departure should be small.
After a suspected compromise. If a secret might have leaked — through a log, a screenshot, a commit — rotate it immediately. Don't wait to confirm the breach.
On a regular cadence. Even without incidents, rotate secrets quarterly or semi-annually. Automate it where possible. AWS IAM access keys, database passwords, and API tokens should all be rotatable without downtime.
The practical challenge is that rotation is painful if your secrets are scattered across .env.local files, Slack DMs, and hosting dashboards. A single source of truth — whether that's a secrets manager or a platform integration — makes rotation a one-step process instead of a scavenger hunt.
Team Sharing
How does a new developer get the secrets they need? The answer reveals a lot about your team's security posture.
The Slack anti-pattern: Someone pastes the .env file in a DM. The secrets sit in Slack's chat history forever. There's no access control, no audit trail, and no way to rotate them out of that message. Don't do this.
Better approaches, in order of maturity:
-
Encrypted files in git — tools like
dotenvxorgit-cryptencrypt your.envfiles so they can be committed safely. Simple, but key management gets messy. -
Password managers — 1Password or Bitwarden shared vaults. Familiar UX, but syncing is manual and there's no CI/CD integration.
-
Secrets managers — purpose-built tools like Doppler, Infisical, or Keyway that provide access control, audit logs, and integrations with hosting platforms and CI/CD pipelines. A developer runs a single pull command and has everything they need.
The right choice depends on your team size. Solo developer? .env.example and a password manager are fine. Team of five or more? A secrets manager pays for itself in saved time and reduced risk.
CI/CD Best Practices
Your CI/CD pipeline needs secrets but must handle them carefully.
GitHub Actions secrets:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: npm run build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXT_PUBLIC_APP_URL: https://myapp.com
Use OIDC instead of long-lived credentials. For cloud providers, federated identity eliminates stored secrets entirely:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1
Never echo secrets in logs. GitHub Actions masks secrets automatically, but custom scripts can accidentally leak them:
# WRONG — prints the secret to build logs
echo "Connecting to $DATABASE_URL"
# RIGHT — reference without printing
npm run db:migrate
Also watch for error messages. A failed database connection might log the full connection string including the password. Configure your logging to redact sensitive patterns.
AI Coding Agents and Your Secrets
AI coding tools like Claude Code, Cursor, and Copilot read your project files for context — including .env files. Your secrets end up in the AI's context window, processed by external servers.
.gitignore doesn't help here. The file is on disk, and the AI can read it.
The fix is a zero-disk approach: don't store secrets in files at all. Inject them into processes at runtime from a secrets manager, so there's nothing on your filesystem for an AI tool (or any other process) to read.
For a detailed breakdown of how each AI tool handles your secrets and what to do about it, read AI Coding Agents Are Reading Your Secrets.
Security Checklist
Use this before every production deployment:
- All secrets use server-only variables (no
NEXT_PUBLIC_, noVITE_prefix) -
.env.localand.env*.localare in.gitignore - Pre-commit hooks scan for secret patterns
- Environment variables are validated at startup (Zod, t3-env, or equivalent)
-
.env.exampledocuments all required variables (without real values) - Different secrets exist for dev, staging, and production
- Secrets are rotated when team members leave
- CI/CD uses platform secrets or OIDC — no hardcoded credentials
- Logs and error messages don't contain secret values
- AI coding tools can't access secrets on disk
For a full breakdown of our security approach, see our security overview.
Further Reading
- AI Coding Agents Are Reading Your Secrets — how Claude Code, Cursor, and Copilot handle your
.envfiles - Environment Variables in Next.js: The Complete Guide — deep dive into the Next.js-specific patterns
- The Developer Onboarding Secrets Problem — why getting new teammates set up is harder than it should be
- Keyway Security Overview — how Keyway keeps secrets off disk and out of AI context