← Back to Blog

Environment Variables: The Complete Developer Guide

Environment variables are the foundation of the Twelve-Factor App methodology and the standard way to separate configuration from code. This guide covers everything: what they are, how they work at the OS level, .env files and dotenv libraries in Node.js/Python/Ruby/Go, Docker, Kubernetes, CI/CD pipelines, and the security practices that keep secrets safe.

What Are Environment Variables?

An environment variable is a named value that exists in the operating system's environment and is accessible to any process running within that environment. Every process on a Unix or Windows system inherits a copy of the environment from its parent process. When you open a terminal, your shell inherits the OS environment; any program you run from that shell inherits the shell's environment.

At the OS level, environment variables are a simple key-value store: both keys and values are strings. The operating system provides them as part of the process's address space at startup.

# View all environment variables in the current shell (Linux/macOS):
env
printenv

# View a specific variable:
echo $HOME
echo $PATH
printenv DATABASE_URL

# Set a variable for the current shell session only:
export DATABASE_URL="postgresql://localhost:5432/myapp"

# Set a variable for a single command only (does not persist):
DATABASE_URL="postgresql://test:5432/testdb" python manage.py test

On Windows (PowerShell):

# View all env vars:
Get-ChildItem Env:

# View one:
$env:DATABASE_URL

# Set for current session:
$env:DATABASE_URL = "postgresql://localhost:5432/myapp"

# Set permanently (user scope):
[System.Environment]::SetEnvironmentVariable("DATABASE_URL", "postgresql://localhost:5432/myapp", "User")

Why Use Environment Variables?

The Twelve-Factor App methodology (developed by Heroku engineers) defines one of its core factors as: store config in the environment. The reasoning:

  • Security: Credentials never touch the codebase, git history, or build artifacts.
  • Portability: The same code runs in development, staging, and production by changing only the environment, not the code.
  • Separation of concerns: Developers manage code; operators manage configuration. These are different roles with different lifecycles.
  • Language-agnostic: Every programming language and runtime can read environment variables with a single built-in function call. No external library needed.

What belongs in environment variables: database credentials, API keys and tokens, service URLs (which differ between environments), feature flags, application ports, log levels, and any other value that changes between deployments.

What does NOT belong in environment variables: large binary data (use files or object storage), highly structured configuration with complex nesting (use a config file format like YAML or TOML), values that change frequently at runtime (use a config service or database).

.env Files: Local Development Workflow

A .env file (pronounced "dot env") is a plain text file that stores environment variable declarations. It is the standard local development mechanism for injecting configuration without setting system-wide environment variables.

Format and Syntax

# .env — comments start with #
# Syntax: KEY=VALUE (no spaces around =)

# Strings — quotes are optional but recommended for values with spaces or special chars:
APP_NAME=MyApplication
APP_URL="https://localhost:3000"

# Numbers — stored as strings, your code must parse them:
PORT=3000
MAX_CONNECTIONS=10

# Booleans — convention only, stored as strings ("true"/"false", "1"/"0"):
DEBUG=true
ENABLE_CACHE=false

# Multiline values — use double quotes with \n or actual newlines:
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"

# Variable expansion (supported by some dotenv implementations):
BASE_URL=https://example.com
API_URL=${BASE_URL}/api/v1

# Empty value:
OPTIONAL_FEATURE=

The .env.example Pattern

Never commit your .env file. Instead, commit a .env.example (or .env.template) with placeholder values. This documents what variables the application needs without exposing real secrets:

# .env.example — safe to commit, all values are placeholders
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_your_stripe_key_here
OPENAI_API_KEY=sk-proj-your_openai_key_here
JWT_SECRET=your-long-random-jwt-secret-here
APP_PORT=3000
LOG_LEVEL=info

New developers clone the repo, copy .env.example to .env, and fill in their own values. Our ENV Validator tool can check that all required variables from a template are present in your .env file.

dotenv in Node.js

The dotenv package is the de facto standard for loading .env files in Node.js. It reads the file, parses key-value pairs, and sets them on process.env — but only if they are not already set (existing env vars take precedence).

# Install:
npm install dotenv

# For TypeScript projects:
npm install dotenv
# dotenv includes its own TypeScript declarations since v16
// Load at the very top of your entry point (index.js, server.js, app.js):
require('dotenv').config();

// Or with ES modules (package.json "type": "module"):
import 'dotenv/config';

// Access variables:
const dbUrl = process.env.DATABASE_URL;
const port = parseInt(process.env.PORT || '3000', 10);
const isDev = process.env.NODE_ENV !== 'production';

// Validate required vars at startup:
function requireEnv(name) {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required env var: ${name}`);
  return value;
}

const config = {
  databaseUrl: requireEnv('DATABASE_URL'),
  redisUrl:    requireEnv('REDIS_URL'),
  jwtSecret:   requireEnv('JWT_SECRET'),
  port:        parseInt(process.env.PORT || '3000', 10),
  isDev:       process.env.NODE_ENV !== 'production',
};

Multiple .env Files by Environment

The dotenv-flow package (or the built-in support in frameworks like Next.js and Vite) supports multiple .env files that are merged in a defined priority order:

# Priority order (higher overrides lower):
.env.local           # never committed, local machine overrides
.env.development     # committed, dev-specific defaults
.env                 # committed, base defaults for all environments
.env.example         # committed, documentation only

Next.js and Vite use this pattern natively. For a plain Node.js app with dotenv-flow:

// npm install dotenv-flow
require('dotenv-flow').config();
// Automatically loads .env, then .env.development, then .env.local
// Each file's values override the previous

dotenv in Python

Python's equivalent is python-dotenv. It integrates with Django, Flask, FastAPI, and plain scripts.

# Install:
pip install python-dotenv
import os
from dotenv import load_dotenv

# Load .env file (defaults to .env in current directory):
load_dotenv()

# Or specify a path:
load_dotenv('/path/to/.env.production')

# Access variables — os.environ raises KeyError if missing (fail fast):
database_url = os.environ['DATABASE_URL']

# os.getenv returns None (or a default) if missing (optional vars):
log_level = os.getenv('LOG_LEVEL', 'INFO')
debug = os.getenv('DEBUG', 'false').lower() == 'true'

# Pydantic BaseSettings (recommended for FastAPI/modern Python):
# pip install pydantic-settings
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    redis_url: str
    stripe_secret_key: str
    port: int = 3000
    debug: bool = False

    class Config:
        env_file = '.env'
        env_file_encoding = 'utf-8'

# Pydantic validates types, raises clear errors for missing required vars:
settings = Settings()
print(settings.database_url)

dotenv in Ruby / Rails

Ruby on Rails uses the dotenv-rails gem. It auto-loads before the application boots, making variables available throughout the app.

# Gemfile:
gem 'dotenv-rails', groups: [:development, :test]

# For non-Rails Ruby:
gem 'dotenv'
# In a non-Rails Ruby script:
require 'dotenv/load'

# Or load explicitly:
require 'dotenv'
Dotenv.load('.env', '.env.local')

# Access:
database_url = ENV.fetch('DATABASE_URL')         # raises KeyError if missing
log_level    = ENV.fetch('LOG_LEVEL', 'info')    # returns default if missing

dotenv in Go

Go's standard library provides os.Getenv. The popular godotenv package adds .env file loading:

// go get github.com/joho/godotenv

package main

import (
    "log"
    "os"
    "github.com/joho/godotenv"
)

func init() {
    // Only load .env in non-production environments:
    if os.Getenv("APP_ENV") != "production" {
        if err := godotenv.Load(); err != nil {
            log.Println("No .env file found — using system environment")
        }
    }
}

func mustGetenv(key string) string {
    val := os.Getenv(key)
    if val == "" {
        log.Fatalf("Required environment variable %s is not set", key)
    }
    return val
}

func main() {
    dbURL    := mustGetenv("DATABASE_URL")
    apiKey   := mustGetenv("STRIPE_SECRET_KEY")
    port     := os.Getenv("PORT")  // optional
    if port == "" { port = "8080" }
    // ...
}

Environment Variables in Docker

Docker provides multiple ways to inject environment variables into containers. The right method depends on whether the values are sensitive and whether they need to differ between deployments.

Using -e and --env-file with docker run

# Pass individual variables:
docker run -e NODE_ENV=production -e PORT=3000 myapp

# Load from a file (do NOT commit this file):
docker run --env-file .env.production myapp

In docker-compose.yml

services:
  app:
    image: myapp
    environment:
      - NODE_ENV=production
      - PORT=3000
    env_file:
      - .env.production    # loaded at runtime, not baked into image

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp_user
      POSTGRES_PASSWORD: ${DB_PASSWORD}  # interpolated from shell environment

What NOT to Do in a Dockerfile

# WRONG — hardcodes secrets into the image layer permanently:
ENV STRIPE_SECRET_KEY=sk_live_abc123

# WRONG — even if you unset it in a later layer, it persists in the intermediate layer:
ENV API_KEY=secret
RUN unset API_KEY  # this does NOT remove it from the image history

The correct pattern: define the variable name in the Dockerfile without a value, and inject the value at runtime:

# CORRECT — no value in the image, injected at container startup:
ENV NODE_ENV=production
ENV PORT=3000
# Sensitive vars like DATABASE_URL are NOT in the Dockerfile at all.
# They are passed via --env-file or a secret manager at runtime.

See our Docker multi-stage build guide for a complete production-ready Dockerfile pattern. Use our Docker to Compose converter to convert existing docker run commands to Compose format.

Environment Variables in Kubernetes

Kubernetes provides two mechanisms for injecting configuration into pods: ConfigMaps (for non-sensitive config) and Secrets (for sensitive data). Both can be exposed as environment variables or as mounted files.

ConfigMap — Non-Sensitive Config

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  NODE_ENV: production
  PORT: "3000"
  LOG_LEVEL: info
  APP_URL: https://myapp.example.com
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
      - name: app
        envFrom:
        - configMapRef:
            name: app-config

Secret — Sensitive Config

# Create a Secret (values are base64-encoded, NOT encrypted by default):
kubectl create secret generic app-secrets \
  --from-literal=DATABASE_URL="postgresql://user:pass@host/db" \
  --from-literal=STRIPE_SECRET_KEY="sk_live_abc"

# Or in a manifest (values must be base64-encoded):
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
data:
  DATABASE_URL: cG9zdGdyZXNxbDovL3VzZXI6cGFzc0Bob3N0L2Ri  # base64
  STRIPE_SECRET_KEY: c2tfbGl2ZV9hYmM=
# Reference in the Deployment:
containers:
- name: app
  envFrom:
  - secretRef:
      name: app-secrets
  - configMapRef:
      name: app-config

ExternalSecrets Operator

Kubernetes Secrets are base64-encoded, not encrypted, by default. The recommended production pattern is to store secrets in AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault, and sync them into Kubernetes using the ExternalSecrets Operator:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: app-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: app-secrets   # creates a Kubernetes Secret with this name
  data:
  - secretKey: DATABASE_URL
    remoteRef:
      key: myapp/production/database
      property: url
  - secretKey: STRIPE_SECRET_KEY
    remoteRef:
      key: myapp/production/stripe
      property: secret_key

Read our in-depth guide on Kubernetes ConfigMaps and Secrets for a full walkthrough.

Environment Variables in CI/CD Pipelines

CI/CD platforms provide secret storage that injects values as environment variables during builds. These are distinct from repository variables — they are masked in logs and accessible only to authorized workflows.

GitHub Actions

# Store secrets in GitHub: Settings > Secrets and variables > Actions
# Access in workflow:
jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      NODE_ENV: production           # plain variable (visible in logs)
    steps:
    - name: Deploy
      env:
        DATABASE_URL: ${{ secrets.DATABASE_URL }}   # masked in logs
        STRIPE_KEY:   ${{ secrets.STRIPE_KEY }}     # masked in logs
      run: |
        echo "Deploying to production..."
        # $DATABASE_URL and $STRIPE_KEY available here

GitLab CI

# Store in GitLab: Settings > CI/CD > Variables
# Access in .gitlab-ci.yml:
deploy:
  stage: deploy
  script:
    - echo "DATABASE_URL is set"  # value masked automatically
    - ./deploy.sh
  variables:
    NODE_ENV: production         # plain, shown in logs
    # DATABASE_URL injected from CI/CD variables settings (masked)

Environment Scoping in CI/CD

Most CI/CD platforms support environment-scoped variables: different values per environment (development, staging, production). Always use this to ensure that a staging build cannot accidentally use production credentials.

Security Best Practices

Environment variables are better than hardcoded secrets in code — but they are not inherently secure. Here are the security considerations:

What Can Go Wrong

  • Process listing exposure: On Linux, /proc/<pid>/environ is readable by processes running as the same user. In a container environment, this is generally not a concern (containers have separate PID namespaces) but it matters on shared VMs.
  • Subprocess inheritance: Child processes inherit the parent's environment. A forked subprocess or a shelled-out command gets all the environment variables, including secrets it has no reason to know about.
  • Crash dumps and core files: When a process crashes and produces a core dump, the dump may contain the full environment (including secret values) in memory.
  • Log leakage: Frameworks and debugging tools sometimes dump the full environment on errors. Make sure DEBUG=true is never set in production, and that your error handler sanitizes the environment before logging.
  • Docker image inspection: docker inspect reveals all environment variables set on a container. Limit who has docker inspect access in production.

Principles for Secure Env Var Management

  • Never commit .env files. Add them to .gitignore before the first commit. See our .gitignore best practices guide.
  • Use a secret manager for production. Environment variables set manually on servers are better than hardcoded secrets but worse than a secret manager. AWS Secrets Manager, HashiCorp Vault, and GCP Secret Manager all provide audit trails, rotation, and fine-grained access control.
  • Scope secrets tightly. A background job that only reads from a database should only have a read-only DATABASE_URL. It should not have access to payment API keys.
  • Validate at startup. Fail fast: if a required variable is missing, crash loudly at startup rather than failing silently in a production request. Every example in this guide shows this pattern.
  • Sanitize logs. Never log the value of environment variables. Log only their presence (e.g., DATABASE_URL is set) or a hash of the value for debugging identity.
  • Rotate regularly. Read our guide on how to secure API keys for a full rotation strategy.

Validate Your .env File Instantly

Use SecureBin's free ENV Validator to check your .env file for syntax errors, missing required variables, and common formatting issues. Paste your .env content and get an instant report.

Validate Your .env File

Common Mistakes and How to Fix Them

Mistake 1: Inconsistent naming conventions

Mixing DatabaseUrl, database_url, DATABASEURL, and DATABASE_URL in the same project causes confusion. The universal convention is SCREAMING_SNAKE_CASE for environment variables. Stick to it across all languages and platforms.

Mistake 2: No startup validation

Without validation, a missing environment variable might not cause an error until the specific code path that uses it is hit — potentially minutes or hours into production traffic. Always validate all required variables at application startup and exit immediately if any are missing.

Mistake 3: Overloading a single variable

Combining multiple pieces of configuration into one variable (e.g., CONFIG=host:5432:user:password:db) and then parsing it in application code is fragile and obscures what the variable contains. Use separate, clearly named variables: DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME. Or use a full connection URL with a standard format like DATABASE_URL=postgresql://user:pass@host:5432/db parsed by a well-known library.

Mistake 4: Boolean parsing inconsistency

Environment variables are strings. The string "false" is truthy in JavaScript: if (process.env.FEATURE_FLAG) evaluates to true even when the value is "false". Always parse booleans explicitly:

// JavaScript — correct boolean parsing:
const featureEnabled = process.env.FEATURE_FLAG === 'true';

// Python — correct:
debug = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')

// Ruby — correct:
debug = ENV.fetch('DEBUG', 'false') == 'true'

Mistake 5: Committing .env to git by accident

If a .env file was committed even once, it exists in git history even after deletion. To remove it:

# Remove from tracking (keeps the file on disk):
git rm --cached .env
echo ".env" >> .gitignore
git add .gitignore
git commit -m "Remove .env from tracking"

# Then purge from full history (REWRITES HISTORY — coordinate with team):
git filter-repo --path .env --invert-paths

# After any exposure: rotate every credential that was in the file.

Mistake 6: Different variable names across environments

Using DB_HOST in development and DATABASE_HOST in production means application code must handle both names. Standardize variable names across all environments and document them in .env.example.

Environment Variable Reference Cheat Sheet

# Shell — set for current session:
export KEY=value

# Shell — set for one command only:
KEY=value command

# Shell — read value:
echo $KEY

# Node.js:
process.env.KEY                          # undefined if not set
process.env.KEY || 'default'             # with fallback

# Python:
os.environ['KEY']                        # KeyError if not set
os.getenv('KEY', 'default')              # with fallback

# Ruby:
ENV['KEY']                               # nil if not set
ENV.fetch('KEY')                         # raises KeyError if not set
ENV.fetch('KEY', 'default')              # with fallback

# Go:
os.Getenv("KEY")                         # empty string if not set
os.LookupEnv("KEY")                      # returns (value, ok bool)

# Bash — check if set:
if [ -z "$KEY" ]; then echo "not set"; fi
if [ -n "$KEY" ]; then echo "set"; fi

The Bottom Line

Environment variables are the right tool for separating configuration from code. For local development, .env files with dotenv are sufficient. For production, graduate to a proper secret manager (AWS Secrets Manager, HashiCorp Vault, or a platform-native equivalent). Always validate required variables at startup, never log their values, and treat .env files with the same care as passwords.

Related reading: How to Secure API Keys, .gitignore Best Practices, Kubernetes ConfigMaps and Secrets, Docker Multi-Stage Builds, CI/CD Pipeline Explained. Related tools: ENV Validator, Text Encryption, .gitignore Generator, Docker to Compose Converter.