Secrets Management: From User Secrets to .env and Render
For .NET engineers who know:
dotnet user-secrets,appsettings.json,IConfiguration, Azure Key Vault references, and environment-specific config transforms You’ll learn: How the Node.js ecosystem handles secrets at each stage (local dev, CI, production), how to validate env vars at startup with Zod, and our team’s conventions for.envfiles and Render secret management Time: 10-15 min read
The .NET Way (What You Already Know)
The .NET configuration system is layered. appsettings.json holds non-sensitive defaults. appsettings.Development.json holds dev overrides and is excluded from production deployments. dotnet user-secrets stores sensitive values outside the project directory (in ~/.microsoft/usersecrets/) so they never touch the filesystem that git tracks. In production, Azure Key Vault references in appsettings.json tell the app to read the actual value from Key Vault at runtime.
// Program.cs — configuration layer setup (mostly automatic)
var builder = WebApplication.CreateBuilder(args);
// Layer order (later layers override earlier ones):
// 1. appsettings.json
// 2. appsettings.{Environment}.json
// 3. User Secrets (Development only)
// 4. Environment variables
// 5. Command-line args
// User Secrets — stored in ~/.microsoft/usersecrets/{project-guid}/secrets.json
// Activated by: dotnet user-secrets set "ConnectionStrings:Default" "Server=..."
# .NET User Secrets workflow
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;Database=mydb;"
dotnet user-secrets set "Stripe:SecretKey" "sk_test_..."
dotnet user-secrets list
The key insight is that secrets never live in the repository. The repository contains the configuration schema (key names without values), and the actual values are stored separately per environment.
The Node.js Way
Node.js uses .env files for local development and environment variables for every other environment. The concept is identical to .NET; the tooling is different.
The dotenv package (or its Vite/Next.js built-in equivalent) reads a .env file and populates process.env at startup — the equivalent of IConfiguration with User Secrets as the source.
The .env File System
# .env — local development values
# This file NEVER gets committed to git
DATABASE_URL="postgresql://postgres:password@localhost:5432/myapp_dev"
STRIPE_SECRET_KEY="sk_test_51..."
CLERK_SECRET_KEY="sk_test_..."
JWT_SECRET="a-long-random-string-for-local-development-only"
SENTRY_DSN="https://abc123@sentry.io/456"
NODE_ENV="development"
PORT="3000"
ALLOWED_ORIGINS="http://localhost:5173,http://localhost:3000"
# .env.example — committed to git, documents all required variables
# Contains placeholder values or descriptions, never real secrets
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
STRIPE_SECRET_KEY="sk_test_..." # Get from Stripe dashboard → Developers → API Keys
CLERK_SECRET_KEY="sk_test_..." # Get from Clerk dashboard → API Keys
JWT_SECRET="" # Generate: openssl rand -base64 32
SENTRY_DSN="" # Get from Sentry project settings → Client Keys
NODE_ENV="development"
PORT="3000"
ALLOWED_ORIGINS="http://localhost:3000"
.env.example is the Node.js equivalent of appsettings.json as a schema document — it tells every developer exactly which variables the application needs, without containing any real values.
Setting Up dotenv
In NestJS, @nestjs/config wraps dotenv with a clean API:
pnpm add @nestjs/config
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Available in every module without importing ConfigModule again
envFilePath: '.env',
// In production, environment variables come from the platform (Render, Vercel, etc.)
// and the .env file does not exist — that is correct behavior
}),
],
})
export class AppModule {}
// Anywhere in the app — use ConfigService instead of process.env directly
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class PaymentService {
private readonly stripeKey: string;
constructor(private readonly config: ConfigService) {
this.stripeKey = this.config.getOrThrow<string>('STRIPE_SECRET_KEY');
// getOrThrow() fails at construction time if the variable is missing
// This is the equivalent of IConfiguration's null-propagation-safe access
}
}
For Vite-based frontends (Vue, React without Next.js), use the built-in env variable system:
# .env — frontend variables MUST be prefixed with VITE_ to be accessible in the browser
VITE_API_URL="http://localhost:3000/api"
VITE_CLERK_PUBLISHABLE_KEY="pk_test_..."
VITE_SENTRY_DSN="https://..."
# Non-prefixed variables are only available during the build process (build scripts)
# They are NOT embedded in the browser bundle
INTERNAL_BUILD_VARIABLE="value" # Not accessible via import.meta.env in browser code
// Accessing frontend env variables
const apiUrl = import.meta.env.VITE_API_URL; // Correct — Vite-specific syntax
const apiUrl = process.env.VITE_API_URL; // Wrong — does not work in Vite
For Next.js:
# .env.local — the Next.js equivalent of .env for local development
DATABASE_URL="postgresql://..."
STRIPE_SECRET_KEY="sk_test_..."
# Variables prefixed with NEXT_PUBLIC_ are embedded in the browser bundle
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..."
NEXT_PUBLIC_API_URL="http://localhost:3000"
Validating Environment Variables at Startup with Zod
This is the most valuable pattern in this article. In .NET, you typically discover missing configuration at runtime when the first request fails with a null reference. Zod validation at startup fails immediately with a clear error listing every missing variable.
// src/config/env.validation.ts
import { z } from 'zod';
const EnvSchema = z.object({
// Required in all environments
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().int().positive().default(3000),
// Database
DATABASE_URL: z.string().url(),
// Authentication (Clerk)
CLERK_SECRET_KEY: z.string().startsWith('sk_'),
CLERK_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
// Payments
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_').optional(),
// Error tracking
SENTRY_DSN: z.string().url().optional(),
SENTRY_RELEASE: z.string().optional(),
// CORS
ALLOWED_ORIGINS: z
.string()
.transform((val) => val.split(',').map((s) => s.trim())),
});
// Infer TypeScript type from schema
export type Env = z.infer<typeof EnvSchema>;
// Validate at module load time — throws immediately if invalid
export function validateEnv(config: Record<string, unknown>): Env {
const result = EnvSchema.safeParse(config);
if (!result.success) {
console.error('Invalid environment configuration:');
console.error(result.error.format());
process.exit(1); // Hard fail — do not start the app with missing config
}
return result.data;
}
// src/app.module.ts — wire Zod validation into NestJS config
import { ConfigModule } from '@nestjs/config';
import { validateEnv } from './config/env.validation';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv, // Called by ConfigModule on startup
}),
],
})
export class AppModule {}
When a variable is missing, startup fails with:
Invalid environment configuration:
{
DATABASE_URL: { _errors: ['Required'] },
STRIPE_SECRET_KEY: { _errors: ['Required'] }
}
This is dramatically better than discovering a missing STRIPE_SECRET_KEY when the first payment request arrives in production.
The .gitignore Rule
# .gitignore
# NEVER commit .env files containing real values
.env
.env.local
.env.*.local
.env.development.local
.env.test.local
.env.production.local
# DO commit:
# .env.example — documents required variables without values
# .env.test — test-specific values that are safe to commit (non-secret, test credentials)
The correct files to commit:
| File | Commit? | Purpose |
|---|---|---|
.env | Never | Local development secrets |
.env.local | Never | Local developer overrides |
.env.example | Always | Documents all required variables |
.env.test | Usually | Non-secret test values (can commit if no real creds) |
.env.production | Never | Do not create this file — use platform env vars |
Recovering from an Accidental .env Commit
If a .env file with real secrets is committed to git:
# IMMEDIATELY rotate every secret in the file — before anything else.
# The secret is compromised from the moment it was pushed.
# Rotation cannot wait.
# Remove from git history (but the secret is already compromised)
git filter-repo --path .env --invert-paths
# Or for a single commit: git rm --cached .env && git commit -m "Remove accidentally committed .env"
# Force push (you must -- this is one of the few legitimate cases)
git push origin main --force
# Notify the team — anyone who cloned after the commit has the secret locally
The rotation is the critical step. Do not spend time removing the file from history before rotating the secrets — assume the secrets are already compromised the moment they hit GitHub.
Add a Semgrep rule or pre-commit hook to prevent this:
# Install pre-commit hooks with Husky
pnpm add -D husky lint-staged
# Add to package.json
{
"scripts": {
"prepare": "husky install"
}
}
# .husky/pre-commit
#!/bin/sh
# Prevent committing .env files with real-looking secrets
if git diff --cached --name-only | grep -E '^\.env$|^\.env\.' | grep -v '\.example$'; then
echo "ERROR: Attempting to commit an .env file."
echo "If this is .env.example with no secrets, rename it and try again."
exit 1
fi
Alternatively, configure git-secrets or Gitleaks as a pre-commit hook for broader secret pattern detection.
Render: Environment Variables in Production
Our staging and production deployments run on Render. Environment variables in Render are configured per-service in the Render dashboard under Service → Environment.
Render provides two variable types:
| Type | Behavior | Use For |
|---|---|---|
| Environment Variable | Stored in Render, injected at runtime | Non-secret config values |
| Secret File | Stored encrypted, mounted as a file | Rarely needed; prefer environment variables |
For sensitive values, Render’s environment variables are encrypted at rest and only visible to service administrators — this is the production equivalent of Azure Key Vault or User Secrets.
Our workflow:
- All variables in
.env.examplebecome environment variables in Render - Values for staging come from staging service accounts (Stripe test keys, Clerk test instance, etc.)
- Values for production come from production service accounts
- Secret values are never put in
render.yaml(Render’s infrastructure-as-code file) —render.yamlis committed to git
# render.yaml — infrastructure as code for Render
# This IS committed to git — never put secrets here
services:
- type: web
name: my-api
runtime: node
buildCommand: pnpm install --frozen-lockfile && pnpm build
startCommand: node dist/main.js
envVars:
- key: NODE_ENV
value: production # Safe to commit — not a secret
- key: PORT
value: 3000
- key: DATABASE_URL
fromDatabase:
name: my-postgres-db
property: connectionString # Render injects this from the managed DB
# Secrets are added in the Render dashboard, not here:
# STRIPE_SECRET_KEY — set in Render dashboard
# CLERK_SECRET_KEY — set in Render dashboard
# SENTRY_DSN — set in Render dashboard
Rotation Strategy
Secrets should be rotatable without downtime. The operational pattern:
- Generate the new secret value
- Add it to Render as a new environment variable (e.g.,
JWT_SECRET_NEW) - Update the application to accept both old and new values during transition
- Deploy the application
- Remove the old variable from Render
- Deploy again to remove dual-acceptance logic
For most secrets (API keys, DSNs), rotation does not require dual-acceptance — the new key replaces the old one with a single deployment and a brief window where in-flight requests using the old key may fail. Plan rotations during low-traffic periods.
For JWT signing keys specifically, dual-acceptance matters — a user’s existing token was signed with the old key, and you cannot invalidate all sessions instantly. Implement JWKS (JSON Web Key Sets) with key versioning, or accept short-lived tokens so natural expiry handles the transition.
Clerk handles JWT key rotation automatically — this is one of the benefits of delegating authentication.
Key Differences
| Concern | .NET | Node.js / Our Stack | Notes |
|---|---|---|---|
| Local secrets store | dotnet user-secrets (outside project dir) | .env file (in project dir, gitignored) | Both keep secrets out of git |
| Secret format | JSON key hierarchy | Flat KEY=VALUE pairs | Node.js uses __ for nesting: DB__HOST |
| Config validation | Runtime (first use) | Startup (Zod validation) | Zod validation is explicitly better |
| Production secrets | Azure Key Vault | Render environment variables | Platform-managed in both cases |
| Config provider | IConfiguration | ConfigService (NestJS) / process.env | Similar API |
| Secret hierarchy | JSON hierarchy with : separator | Flat with __ for nesting | Configuration["Stripe:SecretKey"] → STRIPE__SECRET_KEY |
| Frontend secrets | N/A (server-only) | VITE_ prefix = public bundle, no prefix = build-only | Public keys only — no private keys in frontend bundles |
| Accidental commit recovery | git filter-branch + secret rotation | Same process — rotation is mandatory | Rotation always takes priority over history rewriting |
Gotchas for .NET Engineers
Gotcha 1: .env Files Are Not Hierarchical — Everything Is Flat
appsettings.json is hierarchical: { "Stripe": { "SecretKey": "...", "WebhookSecret": "..." } }. .env files are flat: STRIPE_SECRET_KEY=... and STRIPE_WEBHOOK_SECRET=....
When mapping .NET configuration keys to env variable names:
- Replace
:with__(double underscore) for hierarchy:Stripe:SecretKey→STRIPE__SECRET_KEY - Conventionally, all env var names are
UPPER_SNAKE_CASE
@nestjs/config handles this automatically — it parses __ as a hierarchy separator when you use ConfigService.get('stripe.secretKey') with a custom configuration factory.
Gotcha 2: VITE_ and NEXT_PUBLIC_ Prefixes Embed Values in the Browser Bundle
Variables prefixed with VITE_ (Vite) or NEXT_PUBLIC_ (Next.js) are embedded in the compiled JavaScript bundle that ships to users’ browsers. Anyone can read them with browser developer tools or by decompiling the bundle.
This is intentional and correct for public keys (Clerk publishable key, Stripe publishable key, Sentry DSN, public API URL). It is catastrophically wrong for secret keys.
The rule: if a variable name starts with VITE_ or NEXT_PUBLIC_, it is public. Secret keys — Stripe secret key, Clerk secret key, database connection strings — must never have these prefixes. They belong on the server only.
# SAFE — publishable key is designed to be public
VITE_CLERK_PUBLISHABLE_KEY="pk_live_..."
# CATASTROPHIC — secret key exposed in browser bundle
VITE_CLERK_SECRET_KEY="sk_live_..." # Never do this
VITE_DATABASE_URL="postgresql://..." # Never do this
Gotcha 3: Missing Variables Are Silent Without Validation
Without Zod validation, process.env.STRIPE_SECRET_KEY returns undefined if the variable is not set. TypeScript types process.env as NodeJS.ProcessEnv, where every property is string | undefined. If you access it without checking:
// TypeScript is wrong here — it should require you to handle undefined
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' });
// process.env.STRIPE_SECRET_KEY is undefined in CI — Stripe constructor fails at runtime
The Zod validation pattern at startup converts this from a runtime failure (discovered during a payment attempt) to a startup failure (discovered immediately when the service boots). Always validate env vars at startup.
Gotcha 4: Never Use process.env Directly in Application Code — Go Through ConfigService
Accessing process.env directly throughout your code makes it impossible to test (you cannot inject mock values) and makes it hard to find all places where a variable is used.
// WRONG — scattered process.env access
@Injectable()
export class StripeService {
createPaymentIntent() {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // Hard to test
}
}
// CORRECT — inject ConfigService, testable with a mock
@Injectable()
export class StripeService {
private readonly stripe: Stripe;
constructor(private readonly config: ConfigService) {
this.stripe = new Stripe(this.config.getOrThrow('STRIPE_SECRET_KEY'));
}
}
In tests, provide a ConfigService mock that returns test values, avoiding any need for .env files during testing.
Hands-On Exercise
Establish complete secrets management for your project.
-
Create
.env.exampledocumenting every environment variable your application uses. Include a comment for each variable explaining where to get the value. -
If you do not have a
.envfile, create one from.env.exampleand fill in real local development values. Verify.envis in.gitignore. Rungit statusto confirm it is not tracked. -
Add Zod env validation. Create
src/config/env.validation.tsusing the pattern from this article. Wire it intoConfigModule.forRoot({ validate: validateEnv }). Remove a required variable from your.envand verify the application refuses to start with a clear error message. Restore the variable. -
Search your codebase for direct
process.envaccess:grep -r "process\.env\." src/. For each occurrence, determine if it should go throughConfigServiceinstead. -
Search for any hardcoded credentials or secrets: run
semgrep --config=p/secrets .and review all findings. -
In Render (or your deployment platform), add all required environment variables from
.env.example. Verify that the staging deployment reads them correctly by checking your Sentry/logging output on startup. -
Set up a pre-commit hook that prevents committing
.envfiles: add the Husky configuration from this article and test it by attemptinggit add .env.
Quick Reference
| Task | Command / Config |
|---|---|
| Create .env from example | cp .env.example .env |
| Install NestJS config | pnpm add @nestjs/config |
| Install dotenv (standalone) | pnpm add dotenv |
| Load .env in NestJS | ConfigModule.forRoot({ isGlobal: true }) |
| Access config value | configService.getOrThrow<string>('KEY') |
| Validate env at startup | ConfigModule.forRoot({ validate: validateEnvFn }) |
| Find env usage in code | grep -r "process\.env\." src/ |
| Generate random secret | openssl rand -base64 32 |
| Check for secrets in code | semgrep --config=p/secrets . |
.env.example Template
# Application
NODE_ENV=development
PORT=3000
# Database (PostgreSQL)
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Authentication (Clerk — https://dashboard.clerk.com)
CLERK_SECRET_KEY=sk_test_...
CLERK_PUBLISHABLE_KEY=pk_test_...
# Payments (Stripe — https://dashboard.stripe.com/test/apikeys)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Error tracking (Sentry — https://sentry.io/settings/your-org/projects/your-project/keys/)
SENTRY_DSN=
SENTRY_RELEASE=
# CORS — comma-separated list of allowed frontend origins
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
Zod Env Validation Starter
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'staging', 'production']),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
// Add your variables here
});
export type Env = z.infer<typeof EnvSchema>;
export function validateEnv(config: Record<string, unknown>): Env {
const result = EnvSchema.safeParse(config);
if (!result.success) {
console.error('Invalid environment:', result.error.format());
process.exit(1);
}
return result.data;
}
Env Variable Prefix Rules
| Prefix | Visible In | Use For |
|---|---|---|
VITE_ | Browser bundle | Public API keys (Clerk publishable, Stripe publishable) |
NEXT_PUBLIC_ | Browser bundle | Same as above, for Next.js |
| (no prefix) | Server only | Secret keys, database URLs, private API keys |
Further Reading
- NestJS Configuration Documentation — ConfigModule, validation, typed config, and namespaced configuration
- Vite Env Variables — VITE_ prefix rules, .env file loading order, and mode-specific files
- Render Environment Variables — Secret files, sync groups, and per-service configuration
- Zod Validation — Schema definition, safeParse, and coercion for environment variable types