npm — Environment Variables
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.
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.
| 1 | # .env — never commit this file |
| 2 | # Format: KEY=VALUE (no spaces around =) |
| 3 | |
| 4 | # Application |
| 5 | NODE_ENV=development |
| 6 | PORT=3000 |
| 7 | HOST=localhost |
| 8 | |
| 9 | # API |
| 10 | API_URL=http://localhost:4000/graphql |
| 11 | API_KEY=sk-dev-your-local-key |
| 12 | |
| 13 | # Feature flags |
| 14 | FEATURE_NEW_DASHBOARD=true |
| 15 | FEATURE_DARK_MODE=false |
| 16 | |
| 17 | # Database (local development) |
| 18 | DATABASE_URL=postgresql://user:pass@localhost:5432/myapp |
| 19 | |
| 20 | # Third-party services |
| 21 | SENTRY_DSN=https://key@sentry.io/project |
| 22 | STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxx |
| 1 | # Install dotenv (if not using a framework) |
| 2 | npm install dotenv |
| 3 | |
| 4 | # Usage in Node.js |
| 5 | node -r dotenv/config index.js |
| 6 | |
| 7 | # Or programmatically |
| 8 | import "dotenv/config"; |
| 9 | |
| 10 | console.log(process.env.API_URL); // http://localhost:4000/graphql |
| 11 | console.log(process.env.PORT); // 3000 |
info
Most frameworks support environment-specific .env files using a priority-based loading system. Specific environments override general ones, allowing fine-grained control without duplication.
| File | Priority | Typical Content |
|---|---|---|
| .env | Lowest (base) | Defaults shared across environments |
| .env.local | Overrides .env | Local overrides (not committed) |
| .env.development | When NODE_ENV=development | Dev-specific API keys, debug flags |
| .env.production | When NODE_ENV=production | Prod API URLs, feature flags |
| .env.test | When NODE_ENV=test | Test database URLs, mock settings |
| 1 | # .env (base — all environments) |
| 2 | API_URL=http://localhost:4000 |
| 3 | LOG_LEVEL=info |
| 4 | |
| 5 | # .env.development (overrides base when NODE_ENV=development) |
| 6 | API_URL=http://localhost:4000/graphql |
| 7 | LOG_LEVEL=debug |
| 8 | DEV_TOOLS=true |
| 9 | |
| 10 | # .env.production (overrides base when NODE_ENV=production) |
| 11 | API_URL=https://api.myapp.com/graphql |
| 12 | LOG_LEVEL=warn |
| 13 | DEV_TOOLS=false |
| 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
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.
| 1 | # Vite — expose with VITE_ prefix |
| 2 | VITE_API_URL=https://api.myapp.com |
| 3 | VITE_STRIPE_KEY=pk_live_xxxxx |
| 4 | # Internal: MY_SECRET (NOT exposed to browser) |
| 5 | |
| 6 | # Next.js — expose with NEXT_PUBLIC_ prefix |
| 7 | NEXT_PUBLIC_API_URL=https://api.myapp.com |
| 8 | NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxx |
| 9 | # Internal: DB_PASSWORD (NOT exposed to browser) |
| 10 | |
| 11 | # Create React App — expose with REACT_APP_ prefix |
| 12 | REACT_APP_API_URL=https://api.myapp.com |
| 1 | // Vite |
| 2 | const apiUrl = import.meta.env.VITE_API_URL; |
| 3 | |
| 4 | // Next.js (client components) |
| 5 | const apiUrl = process.env.NEXT_PUBLIC_API_URL; |
| 6 | |
| 7 | // Accessing without prefix (server-side only) |
| 8 | const dbUrl = process.env.DATABASE_URL; // ❌ undefined on client |
| Framework | Client Prefix | Access Pattern |
|---|---|---|
| Vite | VITE_ | import.meta.env.VITE_* |
| Next.js | NEXT_PUBLIC_ | process.env.NEXT_PUBLIC_* |
| CRA | REACT_APP_ | process.env.REACT_APP_* |
| Remix | None (loader only) | Return from loader, pass as props |
danger
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.
| 1 | # GitHub Actions — secrets from GitHub Secrets |
| 2 | name: Deploy |
| 3 | on: |
| 4 | push: |
| 5 | branches: [main] |
| 6 | |
| 7 | jobs: |
| 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
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.
| 1 | # .env.example — Template for required env vars |
| 2 | # Copy this file to .env and fill in values |
| 3 | |
| 4 | ## Server |
| 5 | NODE_ENV=development |
| 6 | PORT=3000 |
| 7 | |
| 8 | ## API |
| 9 | # The base URL for the GraphQL API |
| 10 | API_URL=http://localhost:4000/graphql |
| 11 | # API key for development (get from vault) |
| 12 | API_KEY= |
| 13 | |
| 14 | ## Database (PostgreSQL) |
| 15 | DATABASE_URL=postgresql://user:pass@localhost:5432/myapp |
| 16 | |
| 17 | ## Authentication |
| 18 | # Generate with: openssl rand -hex 32 |
| 19 | JWT_SECRET= |
| 20 | SESSION_SECRET= |
| 21 | |
| 22 | ## Optional: Feature Flags |
| 23 | FEATURE_NEW_DASHBOARD=false |
| 24 | FEATURE_DARK_MODE=false |
| 25 | |
| 26 | ## Monitoring (set in CI/CD, not local) |
| 27 | SENTRY_DSN= |
pro tip
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.
| 1 | // src/env.ts — validated environment variables |
| 2 | import { z } from "zod"; |
| 3 | |
| 4 | const 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 | |
| 19 | const parsed = envSchema.safeParse(process.env); |
| 20 | |
| 21 | if (!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 | |
| 29 | export const env = parsed.data; |
| 1 | // Using validated env in application |
| 2 | import { env } from "@/env"; |
| 3 | |
| 4 | const 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