DevOpsbeginner

Environment Variables & Secrets

Learn environment variables and secrets management: .env files, dotenv, dev/staging/production configs, secret rotation, CI/CD injection, and scanning tools.

12 min read·Published May 9, 2026
devopsenvironment-variablessecretsconfiguration

Why Environment Variables?

Environment variables separate configuration from code. They let you run the same codebase in different environments (development, staging, production) with different settings -- without changing a single line of code.

Hardcoding configuration is dangerous:

// NEVER do this
const DATABASE_URL = 'postgres://admin:[email protected]:5432/myapp';
const STRIPE_KEY = 'sk_live_abc123xyz';
const JWT_SECRET = 'my-super-secret-jwt-key';

This is in your source code. Anyone with repo access sees your production credentials. If the repo is public, the entire internet does.

The fix: environment variables.

// Always do this
const DATABASE_URL = process.env.DATABASE_URL;
const STRIPE_KEY = process.env.STRIPE_KEY;
const JWT_SECRET = process.env.JWT_SECRET;

The values come from the environment, not the code. Different environments provide different values. Secrets never enter version control.

How Environment Variables Work

Environment variables are key-value pairs available to any process running in the operating system.

# Set a variable in the shell
export API_URL=https://api.example.com

# Access it in the same shell
echo $API_URL
# Output: https://api.example.com

# Pass it to a specific command
DATABASE_URL=postgres://localhost/mydb node server.js

# List all environment variables
env
printenv

In Node.js, access them via process.env:

// Access environment variables
const port = process.env.PORT || 3000;
const nodeEnv = process.env.NODE_ENV || 'development';
const dbUrl = process.env.DATABASE_URL;

// They're always strings
console.log(typeof process.env.PORT); // "string"

// Convert when needed
const maxRetries = parseInt(process.env.MAX_RETRIES || '3', 10);
const enableCache = process.env.ENABLE_CACHE === 'true';

In the browser (frontend), environment variables are handled differently. Build tools inject them at build time:

// Next.js: NEXT_PUBLIC_ prefix makes it available in the browser
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

// Vite: VITE_ prefix
const apiUrl = import.meta.env.VITE_API_URL;

// Create React App: REACT_APP_ prefix
const apiUrl = process.env.REACT_APP_API_URL;

.env Files and dotenv

Typing export KEY=value for 20 variables every time you start development is impractical. .env files solve this.

Basic .env File

# .env.local (development -- NEVER commit this file)

# Server
PORT=3000
NODE_ENV=development

# Database
DATABASE_URL=postgres://dev:devpass@localhost:5432/myapp_dev

# Authentication
JWT_SECRET=local-dev-jwt-secret-not-for-production
JWT_EXPIRES_IN=7d

# External APIs
STRIPE_SECRET_KEY=sk_test_abc123
STRIPE_WEBHOOK_SECRET=whsec_test_xyz789

# Email
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=
SMTP_PASS=

# Feature Flags
ENABLE_NEW_CHECKOUT=true
ENABLE_ANALYTICS=false

# Frontend (Next.js)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_abc123

Loading .env Files

# Install dotenv
npm install dotenv
// Load at the very start of your application
// src/server.ts
import 'dotenv/config';
// or
import dotenv from 'dotenv';
dotenv.config();

// Now process.env has all values from .env
console.log(process.env.DATABASE_URL);

Most modern frameworks load .env files automatically:

Next.js:   Loads .env, .env.local, .env.development, .env.production automatically
Vite:      Loads .env, .env.local, .env.development, .env.production automatically
Remix:     Requires dotenv or similar manual setup
Express:   Requires dotenv manual setup

.env File Priority

Next.js and Vite load files in this order (later files override earlier):

.env                  Base defaults (committed to repo)
.env.local            Local overrides (NOT committed)
.env.development      Development-specific (committed)
.env.development.local  Local dev overrides (NOT committed)
.env.production       Production defaults (committed)
.env.production.local Production local overrides (NOT committed)
Committed to git:           NOT committed to git:
.env                        .env.local
.env.development            .env.development.local
.env.production             .env.production.local

Dev, Staging, and Production Configs

Different environments need different configurations. Here's a practical setup.

Environment Configuration Map

+--------------+---------------------+-----------------------------+
| Variable     | Development         | Staging        | Production |
+--------------+---------------------+-----------------------------+
| NODE_ENV     | development         | staging        | production |
| DATABASE_URL | localhost/myapp_dev | rds.../staging | rds.../prod|
| LOG_LEVEL    | debug               | debug          | info       |
| STRIPE_KEY   | sk_test_...         | sk_test_...    | sk_live_...|
| API_URL      | localhost:3000      | staging.app.io | api.app.io |
| ENABLE_DEBUG | true                | true           | false      |
| SENTRY_DSN   | (empty)             | dsn://staging  | dsn://prod |
+--------------+---------------------+-----------------------------+

Configuration Validation

Don't let your app start with missing config. Validate at startup.

// src/config.ts
type Config = {
  port: number;
  nodeEnv: string;
  databaseUrl: string;
  jwtSecret: string;
  stripeSecretKey: string;
  smtpHost: string;
  smtpPort: number;
};

function getRequiredEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
  return value;
}

function getOptionalEnv(key: string, defaultValue: string): string {
  return process.env[key] || defaultValue;
}

export const config: Config = {
  port: parseInt(getOptionalEnv('PORT', '3000'), 10),
  nodeEnv: getOptionalEnv('NODE_ENV', 'development'),
  databaseUrl: getRequiredEnv('DATABASE_URL'),
  jwtSecret: getRequiredEnv('JWT_SECRET'),
  stripeSecretKey: getRequiredEnv('STRIPE_SECRET_KEY'),
  smtpHost: getOptionalEnv('SMTP_HOST', 'localhost'),
  smtpPort: parseInt(getOptionalEnv('SMTP_PORT', '587'), 10),
};

// Validation with Zod (more robust)
import { z } from 'zod';

const envSchema = z.object({
  PORT: z.string().default('3000').transform(Number),
  NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});

export const env = envSchema.parse(process.env);
// Output on missing variable:
// Error: Missing required environment variable: DATABASE_URL
// The app crashes immediately with a clear message, instead of
// failing later with a cryptic "Cannot read properties of undefined"

Secrets Management

Secrets are a special category of environment variables: API keys, database passwords, JWT secrets, encryption keys. They require extra care.

The Cardinal Rule

Never commit secrets to version control. Ever.

Not "just for now." Not "it's a private repo." Not "I'll remove it later." Git history is permanent. Once a secret is in a commit, it's compromised.

What Counts as a Secret

Definitely secrets:
  - Database passwords
  - API keys (Stripe, AWS, SendGrid)
  - JWT signing keys
  - Encryption keys
  - OAuth client secrets
  - SMTP credentials
  - SSH private keys
  - Service account credentials

NOT secrets (but still environment-specific):
  - Port numbers
  - Feature flags
  - API base URLs
  - Log levels
  - Public keys (publishable Stripe key, OAuth client ID)

.gitignore for Secrets

# .gitignore -- MUST include these
.env
.env.local
.env.*.local
*.pem
*.key
credentials.json
service-account.json

Where to Store Secrets

MethodBest ForSecurity Level
.env.local (gitignored)Local developmentLow (plaintext on disk)
CI/CD secrets (GitHub, GitLab)Build/deploy pipelinesMedium
Cloud secret managers (AWS SSM, GCP Secret Manager)ProductionHigh
HashiCorp VaultEnterprise, multi-cloudVery High
1Password / Bitwarden (team)Sharing secrets among devsMedium-High

Cloud Secret Managers

// AWS Systems Manager Parameter Store
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';

const ssm = new SSMClient({ region: 'us-east-1' });

async function getSecret(name: string): Promise<string> {
  const command = new GetParameterCommand({
    Name: name,
    WithDecryption: true,
  });
  const response = await ssm.send(command);
  return response.Parameter?.Value || '';
}

// Usage
const dbPassword = await getSecret('/myapp/production/DATABASE_PASSWORD');
// Google Cloud Secret Manager
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';

const client = new SecretManagerServiceClient();

async function getSecret(name: string): Promise<string> {
  const [version] = await client.accessSecretVersion({
    name: `projects/my-project/secrets/${name}/versions/latest`,
  });
  return version.payload?.data?.toString() || '';
}

Secret Rotation

Secrets should be rotated (changed) regularly. If a secret is compromised, rotation limits the damage window.

Rotation Strategy

+------------------+-------------------+
| Secret Type      | Rotation Frequency|
+------------------+-------------------+
| API keys         | 90 days           |
| Database passwords| 90 days          |
| JWT signing keys | 30-90 days        |
| Encryption keys  | Annually          |
| OAuth secrets    | 90 days           |
| SSH keys         | Annually          |
+------------------+-------------------+

Zero-Downtime Rotation

The key challenge: you can't change a secret everywhere simultaneously. During rotation, both old and new secrets must work.

// JWT rotation with dual validation
const JWT_SECRETS = [
  process.env.JWT_SECRET_CURRENT,   // new key -- used for signing
  process.env.JWT_SECRET_PREVIOUS,  // old key -- still valid for verification
].filter(Boolean);

// Sign with current key
function signToken(payload: TokenPayload): string {
  return jwt.sign(payload, JWT_SECRETS[0], { expiresIn: '7d' });
}

// Verify with any valid key
function verifyToken(token: string): TokenPayload | null {
  for (const secret of JWT_SECRETS) {
    try {
      return jwt.verify(token, secret) as TokenPayload;
    } catch {
      continue; // try next key
    }
  }
  return null; // no key worked
}
Rotation steps:

1. Generate new secret
2. Add new secret as JWT_SECRET_CURRENT
3. Move old secret to JWT_SECRET_PREVIOUS
4. Deploy -- new tokens signed with new key, old tokens still valid
5. Wait for old tokens to expire (e.g., 7 days)
6. Remove JWT_SECRET_PREVIOUS
7. Deploy final state

CI/CD Secret Injection

CI/CD pipelines need secrets for deployment, API calls, and artifact publishing. Each platform has its own mechanism.

GitHub Actions Secrets

# Set secrets in GitHub:
# Repo Settings > Secrets and variables > Actions > New repository secret

name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build
        run: npm run build
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      - name: Deploy
        run: ./deploy.sh
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

GitHub Environments

Use environments to scope secrets to specific deployments:

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging    # Uses staging secrets
    steps:
      - run: echo "Deploying to staging"
        env:
          API_KEY: ${{ secrets.API_KEY }}  # staging API key

  deploy-production:
    runs-on: ubuntu-latest
    environment: production # Uses production secrets, may require approval
    needs: deploy-staging
    steps:
      - run: echo "Deploying to production"
        env:
          API_KEY: ${{ secrets.API_KEY }}  # production API key

Docker Secret Injection

# DON'T bake secrets into images
# Bad:
ENV API_KEY=sk_live_abc123

# DO pass secrets at runtime
# docker run -e API_KEY=sk_live_abc123 my-app
# docker-compose.yml with .env file
services:
  app:
    build: .
    env_file:
      - .env.production
    # or individual variables:
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - JWT_SECRET=${JWT_SECRET}

Secret Scanning

Automated tools catch secrets accidentally committed to code.

Git Pre-Commit Hook (gitleaks)

# Install gitleaks
brew install gitleaks

# Scan your repository
gitleaks detect --source . --verbose

# Add as pre-commit hook
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks

GitHub Secret Scanning

GitHub automatically scans public repos (and private repos on Enterprise) for known secret patterns:

Detected patterns include:
- AWS access keys
- GitHub tokens
- Stripe API keys
- Google Cloud keys
- Azure connection strings
- npm tokens
- Slack webhooks
- And 100+ more providers

Enable in repo settings: Settings > Code security and analysis > Secret scanning.

.gitleaks.toml Configuration

# .gitleaks.toml
title = "Gitleaks Configuration"

[allowlist]
description = "Allow specific patterns"
paths = [
  '''\.env\.example''',
  '''\.env\.template''',
  '''docs/''',
]

# Custom rules
[[rules]]
id = "custom-api-key"
description = "Custom API key pattern"
regex = '''CUSTOM_API_KEY\s*=\s*['\"]?[a-zA-Z0-9]{32,}['\"]?'''
tags = ["key", "custom"]

What To Do When a Secret Is Exposed

It happens. A secret gets committed, pushed, or leaked. Act fast.

IMMEDIATE (within minutes):
1. Revoke/rotate the exposed secret at the provider
   - AWS: deactivate the access key in IAM
   - Stripe: roll the API key in dashboard
   - Database: change the password
2. Deploy with the new secret
3. Check access logs for unauthorized use

FOLLOW-UP (within hours):
4. Remove the secret from git history (if committed)
   git filter-repo --invert-paths --path .env
5. Force push the cleaned history (coordinate with team)
6. Audit what the secret had access to
7. Document the incident

PREVENT (within days):
8. Add the file pattern to .gitignore
9. Set up pre-commit secret scanning
10. Enable GitHub secret scanning
11. Review team practices

Example: Complete Environment Setup

Here's a full setup for a Next.js application with proper environment management.

File Structure

project/
  .env                    # Defaults (committed, no secrets)
  .env.local              # Local overrides (gitignored)
  .env.example            # Template for developers (committed)
  .gitignore
  src/
    config.ts             # Validated config object

.env (committed -- defaults only)

# .env -- safe defaults, no secrets
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000/api

.env.example (committed -- template)

# .env.example -- copy to .env.local and fill in values
# cp .env.example .env.local

# Server
PORT=3000
NODE_ENV=development

# Database (required)
DATABASE_URL=postgres://user:password@localhost:5432/myapp

# Authentication (required)
JWT_SECRET=generate-a-long-random-string-at-least-32-chars

# Stripe (required for payments)
STRIPE_SECRET_KEY=sk_test_your_key_here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_key_here

# Email (optional -- falls back to console logging)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=

src/config.ts (validation)

import { z } from 'zod';

const envSchema = z.object({
  // Server
  PORT: z.string().default('3000').transform(Number),
  NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),

  // Database
  DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'),

  // Auth
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),

  // Stripe
  STRIPE_SECRET_KEY: z.string().startsWith('sk_', 'STRIPE_SECRET_KEY must start with sk_'),

  // Email (optional)
  SMTP_HOST: z.string().optional(),
  SMTP_PORT: z.string().optional().transform((v) => (v ? Number(v) : undefined)),
  SMTP_USER: z.string().optional(),
  SMTP_PASS: z.string().optional(),
});

function validateEnv() {
  const result = envSchema.safeParse(process.env);

  if (!result.success) {
    console.error('Environment validation failed:');
    for (const issue of result.error.issues) {
      console.error(`  ${issue.path.join('.')}: ${issue.message}`);
    }
    process.exit(1);
  }

  return result.data;
}

export const env = validateEnv();

.gitignore

# Environment files with secrets
.env.local
.env.development.local
.env.staging.local
.env.production.local

# Keys and credentials
*.pem
*.key
*.p12
credentials.json
service-account*.json

Key Takeaways

  • Never hardcode secrets in source code -- use environment variables
  • Use .env files for local development, gitignore all .local variants
  • Validate config at startup -- crash early with clear messages rather than failing later with cryptic errors
  • Different environments (dev/staging/prod) need different values -- same code, different config
  • Store production secrets in cloud secret managers (AWS SSM, GCP Secret Manager), not in .env files
  • Rotate secrets regularly (every 90 days for API keys and passwords)
  • Use pre-commit hooks (gitleaks) and GitHub secret scanning to catch leaks
  • When a secret is exposed: revoke immediately, rotate, audit, clean git history
  • Provide a .env.example file so developers know which variables are needed
  • Frontend variables must use framework-specific prefixes (NEXT_PUBLIC_, VITE_) to be accessible in the browser

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles