|$ curl https://forge-ai.dev/api/markdown?path=docs/npm/env
$cat docs/npm-—-environment-variables.md
updated Recently·20 min read·published

npm — Environment Variables

npmConfigurationIntermediate
Introduction

Environment variables separate configuration from code. They allow your application to behave differently across development, staging, and production environments without changing source files. In Node.js and frontend applications, environment variables control API endpoints, feature flags, authentication secrets, and deployment-specific settings.

This guide covers file-based environment management with .env files, framework-specific exposure strategies, security best practices for secrets, and runtime validation using Zod and Envsafe.

.env Files & dotenv

The dotenv package loads environment variables from a .env file into process.env. This file is never committed to version control — it contains secrets and machine-specific configuration. Most frameworks (Next.js, Vite, Create React App) include dotenv support out of the box.

.env
Bash
1# .env — never commit this file
2# Format: KEY=VALUE (no spaces around =)
3
4# Application
5NODE_ENV=development
6PORT=3000
7HOST=localhost
8
9# API
10API_URL=http://localhost:4000/graphql
11API_KEY=sk-dev-your-local-key
12
13# Feature flags
14FEATURE_NEW_DASHBOARD=true
15FEATURE_DARK_MODE=false
16
17# Database (local development)
18DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
19
20# Third-party services
21SENTRY_DSN=https://key@sentry.io/project
22STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxx
terminal
Bash
1# Install dotenv (if not using a framework)
2npm install dotenv
3
4# Usage in Node.js
5node -r dotenv/config index.js
6
7# Or programmatically
8import "dotenv/config";
9
10console.log(process.env.API_URL); // http://localhost:4000/graphql
11console.log(process.env.PORT); // 3000

info

The dotenv package loads .env files synchronously when imported. Always call dotenv.config() at the very top of your entry point, before any other imports that might access process.env. The -r dotenv/config flag ensures this ordering at the CLI level.
Environment-Specific Files

Most frameworks support environment-specific .env files using a priority-based loading system. Specific environments override general ones, allowing fine-grained control without duplication.

FilePriorityTypical Content
.envLowest (base)Defaults shared across environments
.env.localOverrides .envLocal overrides (not committed)
.env.developmentWhen NODE_ENV=developmentDev-specific API keys, debug flags
.env.productionWhen NODE_ENV=productionProd API URLs, feature flags
.env.testWhen NODE_ENV=testTest database URLs, mock settings
terminal
Bash
1# .env (base — all environments)
2API_URL=http://localhost:4000
3LOG_LEVEL=info
4
5# .env.development (overrides base when NODE_ENV=development)
6API_URL=http://localhost:4000/graphql
7LOG_LEVEL=debug
8DEV_TOOLS=true
9
10# .env.production (overrides base when NODE_ENV=production)
11API_URL=https://api.myapp.com/graphql
12LOG_LEVEL=warn
13DEV_TOOLS=false
priority-order.js
JavaScript
1// Framework loading order (Next.js example):
2// 1. .env.{environment}.local (highest priority)
3// 2. .env.local (only for development)
4// 3. .env.{environment}
5// 4. .env (lowest priority)
6
7// Each level overrides keys from lower levels.
8// .env.local files are NEVER committed.

warning

Never commit .env files that contain real secrets. Use .env.example as a template with placeholder values. Add .env, .env.local, and .env.*.local to your .gitignore. Environment-specific files without .local suffix CAN be committed if they only contain non-sensitive defaults.
Exposing to the Browser

Environment variables on the server are private by default. To expose them to client-side code, frameworks require explicit prefixes. This prevents accidental leakage of server-side secrets to the browser bundle.

.env
Bash
1# Vite — expose with VITE_ prefix
2VITE_API_URL=https://api.myapp.com
3VITE_STRIPE_KEY=pk_live_xxxxx
4# Internal: MY_SECRET (NOT exposed to browser)
5
6# Next.js — expose with NEXT_PUBLIC_ prefix
7NEXT_PUBLIC_API_URL=https://api.myapp.com
8NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxx
9# Internal: DB_PASSWORD (NOT exposed to browser)
10
11# Create React App — expose with REACT_APP_ prefix
12REACT_APP_API_URL=https://api.myapp.com
usage.ts
TypeScript
1// Vite
2const apiUrl = import.meta.env.VITE_API_URL;
3
4// Next.js (client components)
5const apiUrl = process.env.NEXT_PUBLIC_API_URL;
6
7// Accessing without prefix (server-side only)
8const dbUrl = process.env.DATABASE_URL; // ❌ undefined on client
FrameworkClient PrefixAccess Pattern
ViteVITE_import.meta.env.VITE_*
Next.jsNEXT_PUBLIC_process.env.NEXT_PUBLIC_*
CRAREACT_APP_process.env.REACT_APP_*
RemixNone (loader only)Return from loader, pass as props

danger

Never prefix a secret with VITE_, NEXT_PUBLIC_, or REACT_APP_. Any variable with these prefixes is inlined into the client JavaScript bundle at build time and visible in the browser. API keys, database credentials, and authentication tokens must be server-side only.
Secrets in CI/CD

CI/CD pipelines inject environment variables at runtime through their built-in secrets management. Never hardcode secrets in repository files — use your CI provider's encrypted environment variable store.

deploy.yml
YAML
1# GitHub Actions — secrets from GitHub Secrets
2name: Deploy
3on:
4 push:
5 branches: [main]
6
7jobs:
8 deploy:
9 runs-on: ubuntu-latest
10 env:
11 NODE_ENV: production
12 API_URL: ${{ vars.API_URL }}
13 steps:
14 - uses: actions/checkout@v4
15 - uses: actions/setup-node@v4
16 - run: npm ci
17 - run: npm run build
18 env:
19 SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
20 NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
21 - run: npm run deploy
22 env:
23 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

best practice

Use your CI provider's encrypted secrets for sensitive values (API keys, tokens, passwords). Use environment-specific configuration variables (GitHub vars, GitLab CI's variables) for non-sensitive differences like API URLs. Never paste secrets into YAML files directly.
.env.example — Documentation

The .env.example file is a template that documents every environment variable your application needs. It is committed to version control and serves as both documentation and onboarding guide for new developers.

.env.example
Bash
1# .env.example — Template for required env vars
2# Copy this file to .env and fill in values
3
4## Server
5NODE_ENV=development
6PORT=3000
7
8## API
9# The base URL for the GraphQL API
10API_URL=http://localhost:4000/graphql
11# API key for development (get from vault)
12API_KEY=
13
14## Database (PostgreSQL)
15DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
16
17## Authentication
18# Generate with: openssl rand -hex 32
19JWT_SECRET=
20SESSION_SECRET=
21
22## Optional: Feature Flags
23FEATURE_NEW_DASHBOARD=false
24FEATURE_DARK_MODE=false
25
26## Monitoring (set in CI/CD, not local)
27SENTRY_DSN=
🔥

pro tip

Keep .env.example in sync with your actual environment variables. Run a CI check that validates every key in .env.example is either present in .env or has a safe default. This prevents runtime errors caused by missing configuration.
Validation with Zod & Envsafe

Runtime validation ensures your application fails fast when environment configuration is missing or invalid. Zod provides schema-based validation, while envsafe builds on Zod with a simplified API specifically for environment variables.

src/env.ts
TypeScript
1// src/env.ts — validated environment variables
2import { z } from "zod";
3
4const envSchema = z.object({
5 NODE_ENV: z
6 .enum(["development", "production", "test"])
7 .default("development"),
8 PORT: z.coerce.number().positive().default(3000),
9 API_URL: z.string().url(),
10 DATABASE_URL: z.string().url(),
11 JWT_SECRET: z.string().min(32),
12 FEATURE_NEW_DASHBOARD: z
13 .string()
14 .transform((v) => v === "true")
15 .default("false"),
16 SENTRY_DSN: z.string().url().optional(),
17});
18
19const parsed = envSchema.safeParse(process.env);
20
21if (!parsed.success) {
22 console.error("Invalid environment variables:");
23 for (const issue of parsed.error.issues) {
24 console.error(" -", issue.path.join("."), issue.message);
25 }
26 process.exit(1);
27}
28
29export const env = parsed.data;
src/app.ts
TypeScript
1// Using validated env in application
2import { env } from "@/env";
3
4const app = createApp({
5 port: env.PORT,
6 apiUrl: env.API_URL,
7 features: {
8 newDashboard: env.FEATURE_NEW_DASHBOARD,
9 },
10});
11
12// TypeScript knows the shape:
13// env.PORT is number (coerced)
14// env.API_URL is string (validated URL)
15// env.FEATURE_NEW_DASHBOARD is boolean (transformed)

best practice

Validate environment variables at application startup, not lazily when they are first accessed. Fail fast with a clear error message listing all missing or invalid variables. This prevents cryptic runtime errors in production. Use z.coerce.number() for numeric values and .transform() for string-to-boolean conversions.
$Blueprint — Engineering Documentation·Section ID: NPM-04·Revision: 1.0