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>/environis 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=trueis never set in production, and that your error handler sanitizes the environment before logging. - Docker image inspection:
docker inspectreveals all environment variables set on a container. Limit who hasdocker inspectaccess in production.
Principles for Secure Env Var Management
- Never commit
.envfiles. Add them to.gitignorebefore 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 FileCommon 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.