Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 .env files 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:

FileCommit?Purpose
.envNeverLocal development secrets
.env.localNeverLocal developer overrides
.env.exampleAlwaysDocuments all required variables
.env.testUsuallyNon-secret test values (can commit if no real creds)
.env.productionNeverDo 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:

TypeBehaviorUse For
Environment VariableStored in Render, injected at runtimeNon-secret config values
Secret FileStored encrypted, mounted as a fileRarely 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:

  1. All variables in .env.example become environment variables in Render
  2. Values for staging come from staging service accounts (Stripe test keys, Clerk test instance, etc.)
  3. Values for production come from production service accounts
  4. Secret values are never put in render.yaml (Render’s infrastructure-as-code file) — render.yaml is 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:

  1. Generate the new secret value
  2. Add it to Render as a new environment variable (e.g., JWT_SECRET_NEW)
  3. Update the application to accept both old and new values during transition
  4. Deploy the application
  5. Remove the old variable from Render
  6. 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.NETNode.js / Our StackNotes
Local secrets storedotnet user-secrets (outside project dir).env file (in project dir, gitignored)Both keep secrets out of git
Secret formatJSON key hierarchyFlat KEY=VALUE pairsNode.js uses __ for nesting: DB__HOST
Config validationRuntime (first use)Startup (Zod validation)Zod validation is explicitly better
Production secretsAzure Key VaultRender environment variablesPlatform-managed in both cases
Config providerIConfigurationConfigService (NestJS) / process.envSimilar API
Secret hierarchyJSON hierarchy with : separatorFlat with __ for nestingConfiguration["Stripe:SecretKey"]STRIPE__SECRET_KEY
Frontend secretsN/A (server-only)VITE_ prefix = public bundle, no prefix = build-onlyPublic keys only — no private keys in frontend bundles
Accidental commit recoverygit filter-branch + secret rotationSame process — rotation is mandatoryRotation 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:SecretKeySTRIPE__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.

  1. Create .env.example documenting every environment variable your application uses. Include a comment for each variable explaining where to get the value.

  2. If you do not have a .env file, create one from .env.example and fill in real local development values. Verify .env is in .gitignore. Run git status to confirm it is not tracked.

  3. Add Zod env validation. Create src/config/env.validation.ts using the pattern from this article. Wire it into ConfigModule.forRoot({ validate: validateEnv }). Remove a required variable from your .env and verify the application refuses to start with a clear error message. Restore the variable.

  4. Search your codebase for direct process.env access: grep -r "process\.env\." src/. For each occurrence, determine if it should go through ConfigService instead.

  5. Search for any hardcoded credentials or secrets: run semgrep --config=p/secrets . and review all findings.

  6. 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.

  7. Set up a pre-commit hook that prevents committing .env files: add the Husky configuration from this article and test it by attempting git add .env.

Quick Reference

TaskCommand / Config
Create .env from examplecp .env.example .env
Install NestJS configpnpm add @nestjs/config
Install dotenv (standalone)pnpm add dotenv
Load .env in NestJSConfigModule.forRoot({ isGlobal: true })
Access config valueconfigService.getOrThrow<string>('KEY')
Validate env at startupConfigModule.forRoot({ validate: validateEnvFn })
Find env usage in codegrep -r "process\.env\." src/
Generate random secretopenssl rand -base64 32
Check for secrets in codesemgrep --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

PrefixVisible InUse For
VITE_Browser bundlePublic API keys (Clerk publishable, Stripe publishable)
NEXT_PUBLIC_Browser bundleSame as above, for Next.js
(no prefix)Server onlySecret keys, database URLs, private API keys

Further Reading