DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Secrets Management in Production: Beyond .env Files (2026)

Every developer knows .env files. Most developers use them in production. Most developers are doing it wrong.

.env files work fine for local development. In production, they're a security liability: no rotation, no audit trail, no access control, and a single git add . accident from leaking your database credentials to GitHub.

This guide covers how professional teams actually manage secrets in production — from the simplest setup that's already safer than a .env file, to the full enterprise approach with rotation and audit logging.


What's Wrong With .env in Production

1. Accidental commits

# This happens more than you think
git add .
git commit -m "fix: update config"
git push  # 🔥 .env just went to GitHub
Enter fullscreen mode Exit fullscreen mode

Even if you delete the file in the next commit, it's in the git history. If it's a public repo, it's been scraped by secret-scanning bots within minutes.

2. No rotation mechanism

When a secret leaks — and eventually one will — how do you rotate it? With .env files: update the file, redeploy. Every environment. Manually. Under pressure. During an incident.

3. No audit trail

Who added this database password? When? Who has access to it right now? With .env files: no idea.

4. Flat access control

Every developer who can SSH into the server can read every secret. There's no way to say "frontend devs can access the Stripe publishable key but not the database root password."

5. Environment drift

Production .env diverges from staging. Staging diverges from local. Three months later, you have no idea what environment has what version of which secret.


The Spectrum: From Simple to Serious

.env in production          ← stop doing this
   ↓
Platform env vars           ← minimum viable for any serious project
   ↓
Doppler / Infisical         ← team standard, sync across environments
   ↓
AWS Secrets Manager         ← enterprise, full rotation + audit
   ↓
HashiCorp Vault             ← maximum control, self-hosted
Enter fullscreen mode Exit fullscreen mode

Most projects land between platform env vars and Doppler. Let's cover each.


Level 1: Platform Environment Variables

The simplest upgrade from .env. Every major deployment platform has built-in env var management.

Vercel

# CLI: add a secret to production
vercel env add DATABASE_URL production

# Or via dashboard: Project → Settings → Environment Variables
Enter fullscreen mode Exit fullscreen mode

Vercel env vars are encrypted at rest, never exposed in build logs, scoped to environment, and accessible to team members based on project access.

This alone eliminates the accidental commit risk.

// In your Next.js app — same as before, nothing changes in the code
const db = new Client({
  connectionString: process.env.DATABASE_URL,
})
Enter fullscreen mode Exit fullscreen mode

Railway, Render, Fly.io

# Railway CLI
railway variables set DATABASE_URL="postgresql://..."

# Fly.io
fly secrets set DATABASE_URL="postgresql://..."
Enter fullscreen mode Exit fullscreen mode

When to use: solo projects, small teams, simple deployments where you don't need to sync secrets across multiple services.


Level 2: Doppler — The Team Standard

Doppler is what most professional dev teams settle on. It's a secrets manager that sits in front of your deployment platform — you manage all secrets in Doppler, and Doppler syncs them to Vercel, Railway, AWS, or wherever you deploy.

Why Doppler over platform env vars:

  • Single source of truth across all environments and platforms
  • Version history — you can see every change and who made it
  • Access control — developers can have read access to staging but not production
  • Automatic sync to your deployment platform
  • Free tier for small teams (up to 5 users)

Setup for Next.js

npm install -g @dopplerhq/cli
doppler login
doppler setup
Enter fullscreen mode Exit fullscreen mode

This creates a .doppler.yaml in your project:

setup:
  project: my-saas
  config: dev
Enter fullscreen mode Exit fullscreen mode

Now instead of .env, you run local dev with:

doppler run -- npm run dev
# Doppler injects all secrets as env vars before starting the dev server
Enter fullscreen mode Exit fullscreen mode

Syncing to Vercel

doppler integrations vercel
Enter fullscreen mode Exit fullscreen mode

Now when you change a secret in Doppler, it automatically pushes to Vercel. Change DATABASE_URL in Doppler → it updates in Vercel production, preview, and development automatically.

Access control in Doppler

Production config  → only lead devs + CI/CD
Staging config     → all developers (read), lead devs (write)
Development config → all developers (read + write)
Enter fullscreen mode Exit fullscreen mode

A new developer onboards: give them Doppler access to staging and dev, they have everything they need immediately. No sending passwords over Slack.


Level 3: AWS Secrets Manager

When you're on AWS and need the full enterprise feature set: automatic rotation, fine-grained IAM access control, CloudTrail audit logging.

Storing a secret

aws secretsmanager create-secret \
  --name "myapp/production/database" \
  --secret-string '{"url":"postgresql://...","password":"..."}'
Enter fullscreen mode Exit fullscreen mode

Reading in your application

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'

const client = new SecretsManagerClient({ region: 'us-east-1' })

async function getDatabaseUrl(): Promise<string> {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: 'myapp/production/database' })
  )
  const secret = JSON.parse(response.SecretString!)
  return secret.url
}
Enter fullscreen mode Exit fullscreen mode

Cache this at startup — don't call Secrets Manager on every request:

let cachedSecrets: Record<string, string> | null = null

export async function getSecrets() {
  if (cachedSecrets) return cachedSecrets
  cachedSecrets = await loadFromSecretsManager()
  return cachedSecrets
}
Enter fullscreen mode Exit fullscreen mode

Automatic rotation

aws secretsmanager rotate-secret \
  --secret-id "myapp/production/database" \
  --rotation-rules AutomaticallyAfterDays=30
Enter fullscreen mode Exit fullscreen mode

AWS handles generating the new password, updating the database, and updating the secret — all without you doing anything.


Practical Patterns for Any Setup

Pattern 1: Validate secrets at startup

Don't let your app start with missing secrets. Fail loudly at startup rather than cryptically at runtime:

import { z } from 'zod'

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(['development', 'test', 'production']),
})

export const env = envSchema.parse(process.env)
Enter fullscreen mode Exit fullscreen mode

This gives you type-safe environment variables throughout your codebase, with validation that runs at startup.

Pattern 2: Never log secrets

// ❌ Logs the entire request including auth headers
console.log('Incoming request:', req)

// ✅ Log only what you need
console.log('Incoming request:', {
  method: req.method,
  url: req.url,
  userId: req.user?.id,
})
Enter fullscreen mode Exit fullscreen mode

Set up your logger to scrub sensitive fields:

import pino from 'pino'

const logger = pino({
  redact: {
    paths: ['req.headers.authorization', 'body.password', 'body.token'],
    censor: '[REDACTED]',
  },
})
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Separate secrets by environment

Never share secrets between production and staging.

myapp/production/stripe    → live Stripe keys
myapp/staging/stripe       → test Stripe keys
myapp/development/stripe   → test Stripe keys
Enter fullscreen mode Exit fullscreen mode

This prevents the classic mistake of accidentally running a test against the production Stripe account.

Pattern 4: Rotate after team member departure

Every time a developer with production access leaves the team, rotate all secrets they had access to. This is non-negotiable.

With Doppler: revoke their access in the dashboard → rotate affected secrets → Doppler syncs automatically.


The .env File You Should Keep

Keep a .env.example file in your repo (committed) with all the variables your app needs, but with placeholder values:

# .env.example — commit this
DATABASE_URL=postgresql://localhost:5432/myapp
STRIPE_SECRET_KEY=sk_test_...
JWT_SECRET=your-secret-here-minimum-32-chars
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
REDIS_URL=redis://localhost:6379
Enter fullscreen mode Exit fullscreen mode

And add .env* to .gitignore:

# .gitignore
.env
.env.local
.env.*.local
# Do NOT ignore .env.example
Enter fullscreen mode Exit fullscreen mode

The Minimum Viable Setup for a Production App

If you're launching soon and need the simplest reasonable setup:

  1. Delete your .env from the server if it's there
  2. Move all secrets to your platform (Vercel, Railway, Fly.io)
  3. Add .env* to .gitignore and verify it's not tracked
  4. Add startup validation to catch missing variables immediately
  5. Keep a .env.example for documentation

That's it. You're already in a significantly better position than most production apps.


Quick Reference: When to Use What

Situation Recommendation
Solo project, one environment Platform env vars (Vercel/Railway)
Small team, multiple environments Doppler (free tier)
Multiple platforms Doppler with platform integrations
AWS-native infrastructure AWS Secrets Manager
Enterprise, compliance requirements AWS Secrets Manager + CloudTrail
Self-hosted, maximum control HashiCorp Vault

Full guide at stacknotice.com/blog/secrets-management-production-2026

Top comments (0)