BytePane

Environment Variables: Best Practices for Dev, Staging & Production

DevOps13 min read

Why Environment Variables Matter

Environment variables separate configuration from code, following the third factor of the Twelve-Factor App methodology. They allow the same application code to run in development, staging, and production with different database URLs, API keys, and feature flags without changing a single line of source code.

Hardcoding configuration leads to three problems: secrets get committed to version control and leaked, you need different code branches for different environments, and changing a configuration value requires a code change and redeployment. Environment variables solve all three.

// BAD: Hardcoded configuration
const db = connect("postgres://admin:[email protected]:5432/myapp")
const apiKey = "sk_live_abc123xyz789"

// GOOD: Environment variables
const db = connect(process.env.DATABASE_URL)
const apiKey = process.env.STRIPE_API_KEY

// The same code works everywhere:
// Dev:  DATABASE_URL=postgres://localhost:5432/myapp_dev
// Staging: DATABASE_URL=postgres://staging-db:5432/myapp_staging
// Prod: DATABASE_URL=postgres://prod-db:5432/myapp_prod

The .env File Ecosystem

The dotenv pattern popularized by Ruby and Node.js loads environment variables from files into process.env. Most modern frameworks (Next.js, Vite, Create React App, Laravel, Django) support this natively with a specific loading order.

# .env — Base defaults (committed to Git)
APP_NAME=MyApp
LOG_LEVEL=info
API_TIMEOUT=30000

# .env.local — Local overrides (NEVER committed)
DATABASE_URL=postgres://localhost:5432/myapp_dev
STRIPE_API_KEY=sk_test_local123
SECRET_KEY=dev-secret-not-for-prod

# .env.production — Production overrides (may be committed)
LOG_LEVEL=warn
API_TIMEOUT=10000
NEXT_PUBLIC_API_URL=https://api.myapp.com

# .env.production.local — Prod secrets (NEVER committed)
DATABASE_URL=postgres://prod-db:5432/myapp
STRIPE_API_KEY=sk_live_prod789

# Loading order (later files override earlier):
# .env → .env.local → .env.[NODE_ENV] → .env.[NODE_ENV].local

Always create a .env.example file with placeholder values and commit it to your repository. This documents which variables are required without exposing real values.

Framework-Specific Patterns

Each framework has its own conventions for environment variable access. Understanding these patterns prevents common mistakes like exposing secrets to the browser.

// Next.js: NEXT_PUBLIC_ prefix for client-side access
// Server-only (API routes, Server Components):
process.env.DATABASE_URL        // ✓ Available
process.env.STRIPE_SECRET_KEY   // ✓ Available

// Client-side (browser):
process.env.NEXT_PUBLIC_API_URL    // ✓ Available (has prefix)
process.env.DATABASE_URL           // ✗ undefined (no prefix)

// Vite: VITE_ prefix for client-side access
import.meta.env.VITE_API_URL      // ✓ Available
import.meta.env.DB_PASSWORD        // ✗ undefined

// Create React App: REACT_APP_ prefix
process.env.REACT_APP_API_URL     // ✓ Available
process.env.SECRET_KEY             // ✗ undefined

When working with JSON configuration files alongside environment variables, use our JSON Formatter to validate the structure of your config objects before deployment.

Environment Variables in Docker

Docker provides multiple ways to pass environment variables to containers. The right approach depends on whether the variable is a build-time setting or a runtime secret.

# Dockerfile: Build-time variables (NOT for secrets!)
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}

# docker run: Runtime variables
docker run -e DATABASE_URL=postgres://db:5432/app myapp
docker run --env-file .env.production myapp

# docker-compose.yml: Multiple approaches
services:
  api:
    image: myapp
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=warn
    env_file:
      - .env.production
    # Interpolate host env vars
    environment:
      - DATABASE_URL=${DATABASE_URL}

# NEVER do this — secrets become part of the image!
# Dockerfile
ENV DATABASE_URL=postgres://admin:password@db/app  # BAD
# Anyone who pulls the image can see this:
# docker history myapp --no-trunc

For Docker best practices including multi-stage builds and image security, see our Docker container best practices guide.

CI/CD Pipeline Configuration

CI/CD platforms provide secure ways to store and inject environment variables during builds and deployments. Never store secrets in pipeline configuration files; use the platform's secret management features.

# GitHub Actions: Use repository secrets
# Settings → Secrets and Variables → Actions → New secret

# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      NODE_ENV: production
    steps:
      - uses: actions/checkout@v4
      - name: Build
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
        run: npm run build

      - name: Deploy
        env:
          DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
        run: ./deploy.sh

# GitLab CI: Settings → CI/CD → Variables
# .gitlab-ci.yml
deploy:
  script:
    - echo "DB is $DATABASE_URL"  # Masked in logs
  variables:
    NODE_ENV: production
  # $DATABASE_URL comes from GitLab CI/CD Variables

Validation and Type Safety

Environment variables are always strings. Without validation, missing or malformed variables cause silent failures at runtime. Validate and parse environment variables at application startup to fail fast with clear error messages.

// config.ts — Validate env vars at startup
import { z } from 'zod'

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'staging', 'production']),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url().optional(),
  PORT: z.coerce.number().default(3000),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  JWT_SECRET: z.string().min(32),
  CORS_ORIGINS: z.string().transform(s => s.split(',')),
})

// Throws immediately if any variable is missing or invalid
export const config = envSchema.parse(process.env)

// TypeScript now knows the exact types:
// config.PORT is number (not string!)
// config.CORS_ORIGINS is string[]
// config.REDIS_URL is string | undefined

Libraries like zod, envalid, and @t3-oss/env-nextjs provide schema validation for environment variables. This catches configuration errors at startup instead of in production at 3 AM.

Secrets Management in Production

For production systems, .env files are insufficient. Use dedicated secrets management services that provide encryption at rest, access control, audit logging, and automatic rotation.

ServiceBest ForKey Feature
AWS Secrets ManagerAWS-native appsAutomatic rotation
HashiCorp VaultMulti-cloudDynamic secrets
Google Secret ManagerGCP appsIAM integration
Vercel Env VariablesVercel deploymentsPer-branch secrets
DopplerTeam collaborationUniversal sync

Naming Conventions and Organization

Consistent naming makes environment variables self-documenting and prevents collisions between services. Follow these conventions for maintainable configuration.

# Use SCREAMING_SNAKE_CASE (universal convention)
DATABASE_URL=postgres://localhost:5432/myapp
REDIS_URL=redis://localhost:6379

# Prefix with service name for clarity
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
SENDGRID_API_KEY=SG.xxx
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...

# Group related variables with common prefixes
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_USER=admin
DB_PASSWORD=secret

# Boolean conventions (pick one and be consistent)
FEATURE_NEW_UI=true        # "true"/"false" strings
ENABLE_CACHING=1           # "1"/"0"
DEBUG=                     # empty = falsy

# Never use spaces around = in .env files
# WRONG: DATABASE_URL = postgres://localhost
# RIGHT: DATABASE_URL=postgres://localhost

Security Checklist

A single leaked environment variable can compromise your entire infrastructure. Follow this checklist to prevent credential exposure.

  1. Add .env* to .gitignore -- Except .env.example. Check git log to verify secrets were never committed.
  2. Use framework prefixes correctly -- Only prefix variables with NEXT_PUBLIC_, VITE_, or REACT_APP_ if they are safe to expose in browser JavaScript.
  3. Rotate leaked secrets immediately -- If a secret was ever committed to Git, consider it compromised even after removing it. Regenerate the credential.
  4. Use different secrets per environment -- Dev, staging, and production should never share API keys or database credentials.
  5. Audit access regularly -- Review who has access to production secrets in your CI/CD platform and cloud provider.
  6. Never log environment variables -- Mask secrets in log output. Libraries like pino support redaction patterns.

Tools like EyeSift can help detect AI-generated phishing attempts that target developers to steal credentials, adding another layer of security awareness to your workflow.

Validate Your Configuration with BytePane

Format and validate your JSON config files with our JSON Formatter. Convert between YAML and JSON configuration formats with the YAML to JSON Converter. Generate secure passwords for your environment secrets with the Password Generator.

Open JSON Formatter

Related Articles