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

Configuration & Environment: appsettings.json vs. .env

For .NET engineers who know: IConfiguration, appsettings.json, appsettings.{Environment}.json, user secrets, Azure Key Vault You’ll learn: How Node.js handles configuration through environment variables and .env files, how that maps to the layered provider model you already know, and how validation with Zod replaces the type safety that IConfiguration gives you for free. Time: 10-15 min read


The .NET Way (What You Already Know)

ASP.NET Core’s configuration system is layered and well-integrated into the framework. At startup, WebApplication.CreateBuilder() assembles a configuration pipeline from multiple providers in priority order:

// What the builder does internally — you don't write this, but this is what happens:
// 1. appsettings.json                 (base config, committed to source)
// 2. appsettings.{Environment}.json   (overrides per environment)
// 3. User Secrets                     (development only, ~/.microsoft/usersecrets/)
// 4. Environment variables            (highest priority, used in production)
// 5. Command-line arguments           (highest of all)

// Consuming config is strongly typed through IConfiguration or IOptions<T>:
var builder = WebApplication.CreateBuilder(args);
var dbConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
var stripeKey = builder.Configuration["Stripe:SecretKey"];

// Or with IOptions<T> for strongly typed sections:
builder.Services.Configure<StripeOptions>(
    builder.Configuration.GetSection("Stripe")
);

Your appsettings.json has hierarchy, and IOptions<T> gives you compile-time safety:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=MyApp;..."
  },
  "Stripe": {
    "SecretKey": "sk_test_...",
    "WebhookSecret": "whsec_..."
  },
  "FeatureFlags": {
    "EnableNewCheckout": false
  }
}

User secrets (dotnet user-secrets set) keep sensitive values off disk during development without polluting your committed files. Azure Key Vault handles production secrets with rotation and audit logging.

The key design property here is layering: lower-priority sources set defaults, higher-priority sources override them. Nothing from appsettings.json leaks into production unless you want it to.


The Node.js Way

Node.js has no built-in configuration system. There is no IConfiguration. The runtime gives you one thing: process.env, a flat dictionary of environment variables.

// This is the entirety of Node.js's built-in config support:
const dbUrl = process.env.DATABASE_URL;
const stripeKey = process.env.STRIPE_SECRET_KEY;

// Both are string | undefined. No hierarchy. No type safety. No validation.

That’s it. Everything else — loading from files, validation, hierarchy — is provided by libraries.

.env Files and dotenv

The .env file convention is the community’s solution for local development. You put your environment variables in a file named .env at the project root, and the dotenv library loads them into process.env at startup.

# .env — local development values, NEVER committed to source control
DATABASE_URL="postgresql://postgres:password@localhost:5432/myapp"
STRIPE_SECRET_KEY="sk_test_your_stripe_test_key_here"
STRIPE_WEBHOOK_SECRET="whsec_test_abc123"
CLERK_SECRET_KEY="sk_test_clerk_abc123"
FEATURE_FLAG_NEW_CHECKOUT="false"
NODE_ENV="development"
PORT="3000"

Notice the structure: flat key-value pairs using SCREAMING_SNAKE_CASE. The nested hierarchy you get from appsettings.json (e.g., Stripe:SecretKey) becomes a flat name with underscores.

# .env.example — committed to source control, documents required variables
# Copy this to .env and fill in your values.
DATABASE_URL=""
STRIPE_SECRET_KEY=""
STRIPE_WEBHOOK_SECRET=""
CLERK_SECRET_KEY=""
FEATURE_FLAG_NEW_CHECKOUT="false"
NODE_ENV="development"
PORT="3000"

.env.example is the Node.js equivalent of documenting your required configuration — it tells new developers what they need to fill in. It is committed. .env is not.

Install dotenv and load it as early as possible in your entry point:

// src/main.ts (NestJS) or src/index.ts
import 'dotenv/config'; // must be first import — loads .env into process.env

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

The dotenv/config import style (rather than calling dotenv.config()) ensures the load happens synchronously before any other module initialization. This matters because modules may read process.env at import time.

NestJS ConfigModule: The IConfiguration Equivalent

Raw process.env is untyped and unvalidated. NestJS provides @nestjs/config to give you a structured, validated, injectable configuration service — the closest thing to IConfiguration in the NestJS world.

First, define a validation schema using Zod (our stack’s choice) or class-validator:

// src/config/env.schema.ts
import { z } from 'zod';

export const envSchema = z.object({
  // Server
  NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),

  // Database
  DATABASE_URL: z.string().url(),

  // Stripe
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),

  // Clerk
  CLERK_SECRET_KEY: z.string().startsWith('sk_'),

  // Feature flags — coerce from string to boolean
  FEATURE_FLAG_NEW_CHECKOUT: z
    .string()
    .transform((val) => val === 'true')
    .default('false'),
});

// Infer the TypeScript type from the schema
export type Env = z.infer<typeof envSchema>;

Notice z.coerce.number() for PORT: environment variables are always strings, so you need explicit coercion for non-string types. This is a fundamental difference from appsettings.json where the JSON parser handles type conversion for you.

Now register ConfigModule in your app module with validation:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { envSchema } from './config/env.schema';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,           // No need to import ConfigModule in every feature module
      envFilePath: '.env',       // Path to your .env file
      validate: (config) => {
        const result = envSchema.safeParse(config);
        if (!result.success) {
          // Log the specific validation errors and crash at startup
          console.error('Invalid environment configuration:');
          console.error(result.error.format());
          throw new Error('Configuration validation failed');
        }
        return result.data;
      },
    }),
  ],
})
export class AppModule {}

Failing at startup with a clear error message is the right behavior. It is far better to crash immediately with “STRIPE_SECRET_KEY is required” than to start successfully and fail on the first Stripe API call.

Inject ConfigService wherever you need configuration values:

// src/payments/payments.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Env } from '../config/env.schema';
import Stripe from 'stripe';

@Injectable()
export class PaymentsService {
  private readonly stripe: Stripe;

  constructor(private readonly config: ConfigService<Env, true>) {
    // The second generic argument `true` makes get() return a non-nullable type
    this.stripe = new Stripe(this.config.get('STRIPE_SECRET_KEY'));
  }

  async createPaymentIntent(amount: number): Promise<Stripe.PaymentIntent> {
    return this.stripe.paymentIntents.create({ amount, currency: 'usd' });
  }
}

The ConfigService<Env, true> generic gives you type-safe access: this.config.get('STRIPE_SECRET_KEY') returns string, not string | undefined. If you try to get a key that doesn’t exist in Env, TypeScript will complain at compile time.

Next.js Built-in Environment Handling

Next.js has its own configuration system built on top of the same .env convention, with one critical addition: the NEXT_PUBLIC_ prefix.

# .env.local (Next.js project)

# Server-only variables — never exposed to the browser
DATABASE_URL="postgresql://postgres:password@localhost:5432/myapp"
STRIPE_SECRET_KEY="sk_test_..."
CLERK_SECRET_KEY="sk_test_..."

# Client-side variables — prefixed with NEXT_PUBLIC_, bundled into the browser JS
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
NEXT_PUBLIC_APP_URL="http://localhost:3000"

The NEXT_PUBLIC_ prefix is Next.js’s build-time mechanism for safely exposing variables to client-side code. At build time, Next.js replaces references to process.env.NEXT_PUBLIC_* with their literal values in the browser bundle. Variables without the prefix are only available in Server Components, API routes, and middleware.

This distinction maps to a .NET concept you know: server-side values versus configuration exposed in Blazor WebAssembly or bundled JavaScript. The difference is that Next.js enforces the boundary at build time through naming convention rather than through a framework mechanism.

// app/page.tsx — React Server Component (runs on server)
// Both server and public vars are accessible here
const dbUrl = process.env.DATABASE_URL;                        // works
const stripeKey = process.env.STRIPE_SECRET_KEY;              // works
const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; // works

// components/checkout-form.tsx — Client Component ('use client')
// Only NEXT_PUBLIC_ vars are available at runtime
const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; // works
const secretKey = process.env.STRIPE_SECRET_KEY; // undefined — never reaches browser

Next.js also provides environment-specific file loading with a clear priority order:

.env.local          (highest priority, always gitignored)
.env.development    (loaded when NODE_ENV=development)
.env.production     (loaded when NODE_ENV=production)
.env                (lowest priority, can be committed for non-secret defaults)

This is the layered model you recognize from appsettings.json — base config at the bottom, environment-specific overrides above it, local overrides at the top.

For type safety in Next.js, you can use the same Zod validation pattern at the module level:

// src/lib/env.ts (Next.js project)
import { z } from 'zod';

const serverEnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  CLERK_SECRET_KEY: z.string(),
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
});

const clientEnvSchema = z.object({
  NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
  NEXT_PUBLIC_APP_URL: z.string().url(),
});

// Validate server env only when running on the server
export const serverEnv = typeof window === 'undefined'
  ? serverEnvSchema.parse(process.env)
  : ({} as z.infer<typeof serverEnvSchema>); // never accessed client-side

// Validate client env everywhere
export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});

The typeof window === 'undefined' check guards server-only validation from running in the browser. Libraries like t3-env (from the T3 stack) provide a more ergonomic wrapper around this pattern if you find yourself repeating it.

How Render Handles Environment Variables

On Render, environment variables are set through the dashboard or the render.yaml blueprint file. There are no .env files in production — the platform injects variables directly into process.env at runtime.

# render.yaml — Infrastructure as code for Render
services:
  - type: web
    name: my-api
    env: node
    buildCommand: pnpm install && pnpm build
    startCommand: pnpm start
    envVars:
      - key: NODE_ENV
        value: production
      - key: DATABASE_URL
        fromDatabase:
          name: my-postgres
          property: connectionString
      - key: STRIPE_SECRET_KEY
        sync: false   # Must be set manually in the Render dashboard — not stored in yaml
      - key: CLERK_SECRET_KEY
        sync: false   # Same — sensitive values are never in source control

sync: false tells Render that the value is not in the yaml file and must be configured manually in the dashboard. This is the equivalent of Azure Key Vault references in your ARM templates — a pointer without the secret itself.

Render provides a feature called Secret Files for configuration that must be a file (e.g., service account JSON for Google APIs). This is less common but useful to know.


Key Differences

Concept.NET (ASP.NET Core)Node.js / Next.js / NestJS
Base configurationappsettings.json (JSON, hierarchical).env file (flat key=value pairs)
Environment overridesappsettings.{Environment}.json.env.development, .env.production
Local dev secretsdotnet user-secrets (outside project dir).env.local or .env (gitignored)
Production secretsAzure Key Vault / environment variablesRender env vars / secrets manager
Access mechanismIConfiguration / IOptions<T> (DI)process.env (global) or ConfigService (DI)
Type safetyCompile-time via IOptions<T>Runtime via Zod schema validation
Validation failureStartup exceptionStartup crash (with Zod + ConfigModule)
Hierarchy supportYes — layered providersMinimal — file priority order only
Client vs. server separationFramework-level (Blazor, Razor)NEXT_PUBLIC_ prefix convention (Next.js)
Injection patternIOptions<StripeOptions> in constructorConfigService<Env, true> in constructor
Config hierarchy separator: (e.g., Stripe:SecretKey)__ or just flatten (e.g., STRIPE_SECRET_KEY)

The biggest structural difference: .NET’s IConfiguration gives you type safety through the type system (C# classes bound to configuration sections). In Node.js, you earn that type safety at runtime through schema validation with Zod. The end result — crash on misconfiguration, typed access in code — is the same, but the mechanism is different.


Gotchas for .NET Engineers

1. process.env is always strings, and there is no automatic type conversion.

In appsettings.json, you write "EnableNewCheckout": false and IOptions<FeatureFlags> gives you a bool. In process.env, everything is a string. process.env.FEATURE_FLAG_NEW_CHECKOUT is "false" — the string — not the boolean. This bites .NET engineers constantly:

// Wrong — this will ALWAYS be truthy because non-empty strings are truthy in JS
if (process.env.FEATURE_FLAG_NEW_CHECKOUT) {
  // This runs even when the value is "false"
}

// Correct — explicit comparison or Zod coercion
if (process.env.FEATURE_FLAG_NEW_CHECKOUT === 'true') { ... }

// Best — use Zod to coerce at the boundary so the rest of your code gets a boolean
z.string().transform((val) => val === 'true')

The same applies to numbers. process.env.PORT is "3000", not 3000. process.env.PORT + 1 is "30001", not 3001.

2. dotenv does NOT override existing environment variables.

If DATABASE_URL is already set in the shell environment before your app starts, dotenv will not overwrite it with the value from your .env file. This is intentional and correct behavior for production (where real env vars should win), but it catches developers off guard locally:

# If you have DATABASE_URL set in your shell profile or CI environment:
export DATABASE_URL="postgresql://prod-server/myapp"

# And your .env has:
DATABASE_URL="postgresql://localhost/myapp_dev"

# dotenv will NOT overwrite — your app connects to prod. This is a bad day.

You can force an override with dotenv.config({ override: true }), but do this with caution and only in development contexts.

3. The .env file must never be committed, and recovery requires more than .gitignore.

In .NET, user secrets live in ~/.microsoft/usersecrets/ — outside the project directory entirely, so it’s physically impossible to commit them. .env files live in your project root, next to .gitignore. If someone adds .env to .gitignore after committing it, the file is still in git history.

# If .env was ever committed, gitignore is not enough. You must:
git filter-branch --force --index-filter \
  'git rm --cached --ignore-unmatch .env' \
  --prune-empty --tag-name-filter cat -- --all

# And immediately rotate every secret that was in that file.

Prevention is straightforward: add .env to your global gitignore (~/.gitignore_global) so it is never committed in any project:

echo ".env" >> ~/.gitignore_global
git config --global core.excludesFile ~/.gitignore_global

Many teams also add a pre-commit hook that scans for .env files or common secret patterns using secretlint or detect-secrets. This is the equivalent of what Azure DevOps’ secret scanning does automatically.

4. There is no equivalent of IOptions<T> reload on file change without extra setup.

ASP.NET Core’s IOptionsMonitor<T> reloads configuration when appsettings.json changes on disk. process.env is populated once at startup and does not re-read the .env file at runtime. If you change a .env value, you must restart the process. This is rarely a problem in practice — but if you’re building something that needs live config reloads, you’ll need to implement it explicitly.

5. NestJS ConfigModule’s validate function receives raw process.env, including all system variables.

When you call envSchema.parse(config) in the validate callback, config is the entire process.env merged with your .env file — hundreds of variables including PATH, HOME, USER, etc. Use z.object().strip() (the Zod default) rather than z.object().strict(), or your validation will fail on system variables you didn’t declare:

// This will fail — strict() rejects unknown keys, and there are many
const envSchema = z.object({ DATABASE_URL: z.string() }).strict();

// This is correct — strip() (default) ignores undeclared keys
const envSchema = z.object({ DATABASE_URL: z.string() });

6. Environment variable naming conventions collide with .NET’s hierarchy separator.

IConfiguration uses : to navigate hierarchy: Stripe:SecretKey. When environment variables override appsettings.json values in .NET, they use __ as the hierarchy separator: Stripe__SecretKey. In Node.js, there is no hierarchy — you just flatten everything: STRIPE_SECRET_KEY. This isn’t a bug, but engineers porting configuration from .NET to Node.js sometimes create confusing double-underscore variable names that don’t mean anything in the new context.


Hands-On Exercise

Convert the following appsettings.json structure to a fully validated .env + Zod setup for a NestJS application. The goal is a ConfigService that provides typed access to every value, with the application crashing at startup if any required variable is missing or malformed.

Starting point — appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Database=SchoolVision;User Id=sa;Password=..."
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Clerk": {
    "SecretKey": "sk_test_...",
    "PublishableKey": "pk_test_..."
  },
  "Stripe": {
    "SecretKey": "sk_test_...",
    "WebhookSecret": "whsec_...",
    "PriceIds": {
      "MonthlyPlan": "price_...",
      "AnnualPlan": "price_..."
    }
  },
  "Storage": {
    "BucketName": "my-bucket",
    "Region": "us-east-1",
    "AccessKeyId": "AKIA...",
    "SecretAccessKey": "..."
  }
}

What to produce:

  1. A .env.example file with all required variable names (empty values)
  2. A .env file with local development values (filled in, not committed)
  3. A src/config/env.schema.ts with a Zod schema validating every variable
  4. The ConfigModule.forRoot() registration in AppModule using your schema
  5. One example service that injects ConfigService<Env, true> and uses a typed value

Verify your work by:

  • Renaming .env to .env.bak and starting the app — it should crash with a clear error listing missing variables
  • Setting PORT=abc and starting the app — it should crash with “Expected number, received nan” or similar
  • Running tsc --noEmit — there should be no type errors on config.get(...) calls

Migration guide — flattening the hierarchy:

# appsettings.json key              → .env variable name
ConnectionStrings:DefaultConnection → DATABASE_URL
Clerk:SecretKey                     → CLERK_SECRET_KEY
Clerk:PublishableKey                → NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY  (if Next.js)
Stripe:SecretKey                    → STRIPE_SECRET_KEY
Stripe:WebhookSecret                → STRIPE_WEBHOOK_SECRET
Stripe:PriceIds:MonthlyPlan         → STRIPE_PRICE_MONTHLY
Stripe:PriceIds:AnnualPlan          → STRIPE_PRICE_ANNUAL
Storage:BucketName                  → S3_BUCKET_NAME
Storage:Region                      → AWS_REGION
Storage:AccessKeyId                 → AWS_ACCESS_KEY_ID
Storage:SecretAccessKey             → AWS_SECRET_ACCESS_KEY
Logging:LogLevel:Default            → LOG_LEVEL  (default: "info")

Quick Reference

.NET conceptNode.js / NestJS equivalent
appsettings.json.env file (loaded by dotenv)
appsettings.Production.json.env.production (Next.js) or Render env vars
appsettings.Development.json.env.development or .env.local
dotnet user-secrets setEdit .env locally (it’s gitignored)
Azure Key VaultRender env vars marked sync: false
IConfigurationprocess.env (raw) or ConfigService (DI)
IOptions<StripeOptions>ConfigService<Env, true> (typed)
IOptionsMonitor<T> (live reload)No equivalent — restart required
Data Annotations on configZod schema in envSchema
builder.Configuration.GetConnectionString("DefaultConnection")configService.get('DATABASE_URL')
Stripe:SecretKey (hierarchy separator :)STRIPE_SECRET_KEY (flat, underscore)
Stripe__SecretKey (env var override separator)STRIPE_SECRET_KEY (same format either way)
Crash on missing required configenvSchema.parse() throws at startup
ASPNETCORE_ENVIRONMENTNODE_ENV
bool / int config valuesAlways string in .env — coerce with Zod
[Required] on config propertyz.string() (no .optional())
Allowed config file committed.env.example (empty values, documents requirements)
Config file that must NOT be committed.env (add to .gitignore globally)

dotenv and NestJS ConfigModule commands:

# Install dependencies
pnpm add @nestjs/config dotenv zod

# Verify a variable is loaded
node -e "require('dotenv').config(); console.log(process.env.DATABASE_URL)"

# Check which .env file Next.js is loading
NEXT_PUBLIC_DEBUG=1 pnpm dev

# Audit your .env.example vs .env for missing keys
diff <(grep -v '^#' .env.example | cut -d= -f1 | sort) \
     <(grep -v '^#' .env | cut -d= -f1 | sort)

Further Reading

  • NestJS Configuration documentation — the official guide for ConfigModule, ConfigService, and custom configuration namespaces
  • Next.js Environment Variables — covers .env file priority, NEXT_PUBLIC_ prefix, and runtime vs. build-time availability
  • Zod documentation — the schema library used throughout this stack for validation; the “coercion” section is particularly relevant for environment variable parsing
  • t3-env — a thin wrapper around Zod for Next.js and server-side environment validation; worth reading as a reference implementation of the patterns in this article