
You’re deploying your application to production, and suddenly it can’t connect to the database. The code worked perfectly on your laptop. What went wrong? The answer often lies in environment variables—a fundamental concept that every developer needs to master but is rarely taught explicitly.
Environment variables are dynamic values that can affect the behavior of running processes on your computer or server. They provide a way to configure your applications without hardcoding sensitive information or deployment-specific settings directly into your code.
In this guide, you’ll learn what environment variables are, why they matter, how to use them across different platforms, and the best practices that will save you from costly mistakes in production.
Table of Contents
Open Table of Contents
- What Are Environment Variables?
- Why Environment Variables Matter
- How Environment Variables Work
- Common Use Cases
- Setting Environment Variables
- Reading Environment Variables in Code
- Environment Variables vs Configuration Files
- Security Best Practices
- Common Pitfalls to Avoid
- Real-World Example: Deploying with Environment Variables
- Interview Questions
- 1. What are environment variables and why are they important in software development?
- 2. How do environment variables differ from configuration files, and when would you use each?
- 3. Explain how environment variable precedence works when the same variable is defined at multiple levels.
- 4. What are the security risks of environment variables and how can you mitigate them?
- 5. How would you debug an application that’s not reading environment variables correctly in a containerized environment?
- Conclusion
- References
- YouTube Videos
What Are Environment Variables?
Environment variables are key-value pairs that exist outside your application code but are accessible to your running processes. They’re part of the operating system’s environment and can be set at the system level, user level, or process level.
Think of them as global settings that programs can read to adjust their behavior based on where they’re running. For example, a DATABASE_URL variable might point to localhost on your development machine but to db.production.com in production—all without changing a single line of code.
The Anatomy of an Environment Variable
An environment variable consists of:
- Name: A unique identifier, typically written in UPPERCASE_WITH_UNDERSCORES
- Value: A string that can represent anything—URLs, passwords, feature flags, or configuration settings
- Scope: Where the variable is accessible (system-wide, user-specific, or process-specific)
Example: DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
Here, DATABASE_URL is the name, and the connection string is the value.
Why Environment Variables Matter
Environment variables solve several critical problems in software development:
1. Separation of Configuration from Code
Hardcoding configuration values violates the Twelve-Factor App methodology, which states: “Apps sometimes store config as constants in the code. This is a violation of twelve-factor, which requires strict separation of config from code.”
When you commit database credentials or API keys directly into your codebase, you’re creating security risks and deployment nightmares. Environment variables keep configuration external and flexible.
2. Security and Secret Management
Imagine accidentally pushing your production database password to GitHub in a public repository. This happens more often than you’d think—GitHub reported scanning and removing millions of exposed secrets in 2023 alone.
Environment variables allow you to:
- Keep secrets out of version control
- Restrict access to sensitive values
- Rotate credentials without code changes
- Use different secrets per environment (dev, staging, production)
3. Environment-Specific Configuration
Your application behaves differently across environments. In development, you want verbose logging and local file storage. In production, you need structured logs and cloud storage like AWS S3.
Environment variables make this trivial:
# Development
LOG_LEVEL=debug
STORAGE_TYPE=local
# Production
LOG_LEVEL=error
STORAGE_TYPE=s3
Your code reads these values and adapts—no conditional logic checking “if production” needed.
4. Deployment Flexibility
Modern deployment platforms like Heroku, Vercel, AWS Lambda, and Docker all use environment variables as the primary configuration mechanism. Mastering them is essential for cloud-native development.
How Environment Variables Work
When you launch a process, the operating system creates an environment for it—a collection of variables inherited from the parent process and the system.
Process Environment Inheritance
System Environment Variables
â†"
User Environment Variables
â†"
Shell/Terminal Session
â†"
Your Application Process
Each level can add or override variables. Your application sees the combined result of all levels.
Variable Precedence
When the same variable is defined at multiple levels, precedence determines the final value:
- Process-level (highest priority): Set when starting the process
- User-level: Defined in your user profile (
.bashrc,.zshrc, Windows user variables) - System-level (lowest priority): Global system settings
Example in Linux/macOS:
# System has DATABASE_URL=system-value
# User's .bashrc sets DATABASE_URL=user-value
# Running with override:
DATABASE_URL=process-value node app.js
# app.js sees: DATABASE_URL=process-value
The process-level value wins.
Common Use Cases
Environment variables are used extensively across all types of applications. Here are the most common scenarios:
1. Database Connection Strings
Instead of hardcoding database credentials:
// ⌠Bad: Hardcoded credentials
const db = new Database('postgresql://admin:secret123@localhost:5432/mydb');
// ✅ Good: Using environment variables
const db = new Database(process.env.DATABASE_URL);
Now you can use different databases for development, testing, and production without changing code.
2. API Keys and Secrets
Third-party services like Stripe, SendGrid, or AWS require API keys:
import os
import stripe
# Read API key from environment
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
Your local .env file contains test keys, and production reads from secure environment configuration.
3. Feature Flags
Control feature availability without deploying new code:
const isNewUIEnabled = process.env.FEATURE_NEW_UI === 'true';
if (isNewUIEnabled) {
renderNewUI();
} else {
renderOldUI();
}
Toggle FEATURE_NEW_UI to enable or disable features instantly.
4. Application Behavior
Configure logging, debugging, and operational settings:
String logLevel = System.getenv("LOG_LEVEL"); // "debug", "info", "error"
boolean debugMode = "true".equals(System.getenv("DEBUG"));
int maxConnections = Integer.parseInt(System.getenv("MAX_DB_CONNECTIONS"));
5. CI/CD and Build Configuration
Continuous integration systems like GitHub Actions and GitLab CI use environment variables to pass build information:
# .github/workflows/deploy.yml
env:
NODE_ENV: production
BUILD_NUMBER: ${{ github.run_number }}
DEPLOY_TARGET: aws-east-1
Setting Environment Variables
The method for setting environment variables varies by operating system and use case.
Linux and macOS
Temporary (current session only):
export DATABASE_URL="postgresql://localhost:5432/mydb"
export API_KEY="sk_test_123456"
These variables disappear when you close the terminal.
Permanent (user-level):
Add to ~/.bashrc, ~/.zshrc, or ~/.profile:
# Add to ~/.bashrc
export DATABASE_URL="postgresql://localhost:5432/mydb"
export PATH="$PATH:/usr/local/bin"
Then reload: source ~/.bashrc
System-wide:
Edit /etc/environment (requires root):
DATABASE_URL="postgresql://localhost:5432/mydb"
Running a command with specific variables:
DATABASE_URL="postgres://localhost/test" node server.js
Windows
Temporary (command prompt):
set DATABASE_URL=postgresql://localhost:5432/mydb
set API_KEY=sk_test_123456
Temporary (PowerShell):
$env:DATABASE_URL="postgresql://localhost:5432/mydb"
$env:API_KEY="sk_test_123456"
Permanent (user-level):
[System.Environment]::SetEnvironmentVariable('DATABASE_URL', 'postgresql://localhost:5432/mydb', 'User')
Or use GUI: System Properties → Advanced → Environment Variables
Permanent (system-level):
Use GUI with administrator privileges or:
[System.Environment]::SetEnvironmentVariable('DATABASE_URL', 'postgresql://localhost:5432/mydb', 'Machine')
Using .env Files for Development
For local development, manually exporting variables is tedious. The .env file pattern solves this:
Create .env in your project root:
# .env
DATABASE_URL=postgresql://localhost:5432/dev_db
API_KEY=sk_test_abc123
LOG_LEVEL=debug
PORT=3000
Add .env to .gitignore:
# .gitignore
.env
.env.local
.env.*.local
This prevents accidentally committing secrets.
Load in your application:
Most languages have libraries to load .env files:
// Node.js with dotenv
require('dotenv').config();
console.log(process.env.DATABASE_URL);
# Python with python-dotenv
from dotenv import load_dotenv
import os
load_dotenv()
print(os.getenv('DATABASE_URL'))
Docker and Container Environments
Docker provides multiple ways to set environment variables:
1. Dockerfile:
FROM node:18
ENV NODE_ENV=production
ENV PORT=8080
2. docker run command:
docker run -e DATABASE_URL="postgres://localhost/db" -e API_KEY="secret" myapp
3. docker-compose.yml:
services:
web:
image: myapp
environment:
- DATABASE_URL=postgresql://db:5432/mydb
- API_KEY=sk_test_123
env_file:
- .env
4. Kubernetes ConfigMaps and Secrets:
apiVersion: v1
kind: Pod
spec:
containers:
- name: myapp
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
Reading Environment Variables in Code
Every programming language provides ways to access environment variables:
Node.js / JavaScript
// Access environment variable
const dbUrl = process.env.DATABASE_URL;
const port = parseInt(process.env.PORT || '3000'); // Default value
// Check if variable exists
if (!process.env.API_KEY) {
throw new Error('API_KEY environment variable is required');
}
// Using with dotenv package
require('dotenv').config();
console.log(process.env.DATABASE_URL);
Python
import os
# Get variable (returns None if not set)
db_url = os.getenv('DATABASE_URL')
# Get with default value
port = int(os.getenv('PORT', '5000'))
# Get variable (raises KeyError if not set)
api_key = os.environ['API_KEY']
# Check if variable exists
if 'DATABASE_URL' in os.environ:
print('Database configured')
Java
// Get environment variable
String dbUrl = System.getenv("DATABASE_URL");
// Get with default value
String port = System.getenv().getOrDefault("PORT", "8080");
// Get all environment variables
Map<String, String> env = System.getenv();
for (String key : env.keySet()) {
System.out.println(key + ": " + env.get(key));
}
Go
package main
import (
"os"
"fmt"
)
func main() {
// Get environment variable
dbURL := os.Getenv("DATABASE_URL")
// Check if variable exists
apiKey, exists := os.LookupEnv("API_KEY")
if !exists {
panic("API_KEY not set")
}
// Set environment variable (for current process only)
os.Setenv("CUSTOM_VAR", "value")
}
Ruby
# Get environment variable
db_url = ENV['DATABASE_URL']
# Get with default value
port = ENV.fetch('PORT', '3000')
# Check if variable exists
if ENV.key?('API_KEY')
puts 'API key configured'
end
# Raise error if not set
api_key = ENV.fetch('API_KEY') # Raises KeyError if missing
PHP
<?php
// Get environment variable
$dbUrl = getenv('DATABASE_URL');
// Or using $_ENV superglobal (if variables_order includes 'E')
$apiKey = $_ENV['API_KEY'] ?? 'default_key';
// Check if variable exists
if (getenv('DATABASE_URL') !== false) {
echo 'Database configured';
}
?>
Environment Variables vs Configuration Files
Both approaches manage configuration, but they have different strengths:
When to Use Environment Variables
✅ Secrets and credentials: Never commit secrets to version control
✅ Environment-specific values: Different per deployment (dev/staging/prod)
✅ Infrastructure configuration: Database URLs, cache endpoints, service discovery
✅ Cloud deployments: Most PaaS platforms expect environment variables
✅ CI/CD pipelines: Build and deployment configuration
When to Use Configuration Files
✅ Application defaults: Reasonable defaults that work for most environments
✅ Complex nested structures: JSON/YAML for hierarchical configuration
✅ Shared team settings: Version-controlled configuration everyone uses
✅ Feature flags (non-sensitive): Enable/disable features via config
✅ Business logic configuration: Rules, thresholds, and algorithm parameters
Hybrid Approach (Best Practice)
Most production applications use both:
// config.js
const config = {
// Defaults from file
app: {
name: 'MyApp',
version: '1.0.0',
features: {
newUI: false,
analytics: true
}
},
// Overridable via environment variables
database: {
url: process.env.DATABASE_URL || 'postgresql://localhost:5432/dev',
pool: {
min: parseInt(process.env.DB_POOL_MIN || '2'),
max: parseInt(process.env.DB_POOL_MAX || '10')
}
},
// Secrets from environment only
secrets: {
apiKey: process.env.API_KEY, // No default for secrets!
jwtSecret: process.env.JWT_SECRET
}
};
// Validate required secrets
if (!config.secrets.apiKey || !config.secrets.jwtSecret) {
throw new Error('Required environment variables missing');
}
module.exports = config;
Security Best Practices
Environment variables are powerful but can be misused. Follow these security practices:
1. Never Commit Secrets to Version Control
Always add .env files to .gitignore:
# Environment files
.env
.env.local
.env.*.local
.env.production
# Backup env files
*.env.backup
If you accidentally commit secrets, they remain in Git history forever. You must:
- Rotate the compromised credentials immediately
- Use tools like
git-filter-repoto remove them from history - Force-push the cleaned history (coordinate with team)
2. Use Different Secrets Per Environment
Never reuse production credentials in development or staging:
# Development
DATABASE_URL=postgresql://dev_user:dev_pass@localhost:5432/dev_db
# Staging
DATABASE_URL=postgresql://staging_user:staging_pass@staging-db:5432/staging_db
# Production
DATABASE_URL=postgresql://prod_user:strong_pass@prod-db:5432/prod_db
This limits the blast radius if development credentials leak.
3. Use Secret Management Services
For production, use dedicated secret managers:
- AWS Secrets Manager: Automatic rotation, encryption at rest
- HashiCorp Vault: Dynamic secrets, fine-grained access control
- Azure Key Vault: Integration with Azure services
- Google Secret Manager: GCP-native secret storage
Example using AWS Secrets Manager in Node.js:
const AWS = require('aws-sdk');
const secretsManager = new AWS.SecretsManager();
async function getSecret(secretName) {
const data = await secretsManager.getSecretValue({ SecretId: secretName }).promise();
return JSON.parse(data.SecretString);
}
// Fetch database credentials at runtime
const dbCreds = await getSecret('prod/database/credentials');
4. Validate Environment Variables at Startup
Fail fast if required configuration is missing:
// validate-env.js
const requiredEnvVars = [
'DATABASE_URL',
'API_KEY',
'JWT_SECRET',
'REDIS_URL'
];
function validateEnvironment() {
const missing = requiredEnvVars.filter(varName => !process.env[varName]);
if (missing.length > 0) {
console.error('Missing required environment variables:');
missing.forEach(varName => console.error(` - ${varName}`));
process.exit(1);
}
console.log('✅ Environment validation passed');
}
validateEnvironment();
Call this before starting your application.
5. Limit Environment Variable Exposure
In serverless environments, only pass necessary variables:
# AWS Lambda (serverless.yml)
functions:
api:
handler: handler.main
environment:
DATABASE_URL: ${env:DATABASE_URL}
# Don't pass unnecessary variables
6. Use Principle of Least Privilege
Grant access to environment variables based on need:
- Developers: Access to development secrets only
- CI/CD: Read-only access to deployment secrets
- Production: Minimal set of variables per service
Common Pitfalls to Avoid
1. Assuming Environment Variables Are Always Set
Always provide defaults or validation:
// ⌠Bad: Will crash if PORT is undefined
const port = parseInt(process.env.PORT);
// ✅ Good: Default value
const port = parseInt(process.env.PORT || '3000');
// ✅ Better: Validation with clear error
const port = parseInt(process.env.PORT);
if (isNaN(port) || port < 1 || port > 65535) {
throw new Error('PORT must be a valid port number (1-65535)');
}
2. Treating All Values as Strings
Environment variables are always strings. Convert types explicitly:
// ⌠Bad: Boolean comparison fails
if (process.env.DEBUG) { // Always truthy, even if DEBUG="false"
enableDebug();
}
// ✅ Good: Explicit boolean conversion
const isDebug = process.env.DEBUG === 'true';
if (isDebug) {
enableDebug();
}
// ✅ Good: Parsing numbers
const maxRetries = parseInt(process.env.MAX_RETRIES || '3');
const timeout = parseFloat(process.env.TIMEOUT || '30.5');
3. Exposing Secrets in Logs or Error Messages
Never log environment variable values:
// ⌠Bad: Logs secret
console.log('Starting with config:', process.env);
// ✅ Good: Log only non-sensitive info
console.log('Starting server on port:', process.env.PORT);
// ✅ Good: Mask secrets in logs
console.log('API Key:', process.env.API_KEY ? '***REDACTED***' : 'NOT SET');
4. Not Documenting Required Variables
Create an .env.example file with all required variables:
# .env.example
# Copy this to .env and fill in your values
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# API Keys
STRIPE_SECRET_KEY=sk_test_your_key_here
SENDGRID_API_KEY=SG.your_key_here
# Application
PORT=3000
NODE_ENV=development
LOG_LEVEL=debug
Commit .env.example to version control, not .env.
5. Inconsistent Naming Conventions
Use consistent naming across your team:
# ✅ Good: Consistent uppercase with underscores
DATABASE_URL=...
API_KEY=...
MAX_RETRIES=...
# ⌠Bad: Mixed styles
databaseUrl=...
api-key=...
maxRetries=...
Real-World Example: Deploying with Environment Variables
Let’s walk through a complete example of building and deploying a Node.js API that uses environment variables correctly.
Project Structure
my-api/
├── .env.example # Template for environment variables
├── .gitignore # Excludes .env from version control
├── config.js # Configuration loader
├── server.js # Application entry point
└── package.json
Step 1: Create Configuration Loader
// config.js
require('dotenv').config();
const config = {
server: {
port: parseInt(process.env.PORT || '3000'),
env: process.env.NODE_ENV || 'development'
},
database: {
url: process.env.DATABASE_URL,
pool: {
min: parseInt(process.env.DB_POOL_MIN || '2'),
max: parseInt(process.env.DB_POOL_MAX || '10')
}
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379'
},
secrets: {
jwtSecret: process.env.JWT_SECRET,
apiKey: process.env.API_KEY
},
features: {
enableNewUI: process.env.FEATURE_NEW_UI === 'true',
enableAnalytics: process.env.FEATURE_ANALYTICS !== 'false' // Enabled by default
}
};
// Validation
function validateConfig() {
const required = ['DATABASE_URL', 'JWT_SECRET', 'API_KEY'];
const missing = required.filter(key => !config.secrets[key] && !config.database[key]);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
console.log('✅ Configuration validated successfully');
}
if (config.server.env === 'production') {
validateConfig();
}
module.exports = config;
Step 2: Create Environment Template
# .env.example
# Copy to .env and fill in your values
# Server Configuration
PORT=3000
NODE_ENV=development
# Database (required)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
DB_POOL_MIN=2
DB_POOL_MAX=10
# Redis Cache
REDIS_URL=redis://localhost:6379
# Secrets (required)
JWT_SECRET=your-secret-key-here
API_KEY=your-api-key-here
# Feature Flags
FEATURE_NEW_UI=false
FEATURE_ANALYTICS=true
Step 3: Local Development
# Developer copies template
cp .env.example .env
# Edits .env with local values
# DATABASE_URL=postgresql://localhost:5432/dev_db
# Starts application
npm run dev
Step 4: Production Deployment
On Heroku:
# Set production variables
heroku config:set NODE_ENV=production
heroku config:set DATABASE_URL="postgresql://prod-db-url"
heroku config:set JWT_SECRET="strong-secret-here"
heroku config:set API_KEY="prod-api-key"
# Deploy
git push heroku main
On AWS Elastic Beanstalk:
# Use environment configuration
eb setenv NODE_ENV=production \
DATABASE_URL="postgresql://prod-db-url" \
JWT_SECRET="strong-secret" \
API_KEY="prod-key"
eb deploy
Using Docker Compose:
# docker-compose.prod.yml
version: '3.8'
services:
api:
image: myapi:latest
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
API_KEY: ${API_KEY}
env_file:
- .env.prod # Additional production variables
Deploy with:
docker-compose -f docker-compose.prod.yml up -d
This approach ensures:
- No secrets in version control
- Easy local development setup
- Consistent configuration across environments
- Clear documentation of required variables
Interview Questions
1. What are environment variables and why are they important in software development?
Environment variables are key-value pairs that exist outside your application code and are accessible to running processes. They’re important because they enable separation of configuration from code, which is crucial for security (keeping secrets out of version control), deployment flexibility (using different settings per environment), and following the Twelve-Factor App methodology. They allow the same codebase to run in multiple environments—development, staging, and production—with different configurations without code changes.
2. How do environment variables differ from configuration files, and when would you use each?
Environment variables are best for secrets (API keys, passwords), environment-specific values (database URLs), and infrastructure configuration, especially in cloud deployments where platforms like Heroku and AWS expect them. Configuration files are better for application defaults, complex nested structures (JSON/YAML), shared team settings that should be version-controlled, and business logic parameters. In practice, most production applications use a hybrid approach: configuration files for defaults and structure, environment variables for secrets and environment-specific overrides.
3. Explain how environment variable precedence works when the same variable is defined at multiple levels.
Environment variables follow a precedence hierarchy where more specific levels override broader ones. From highest to lowest priority: process-level variables (set when starting the process), user-level variables (defined in shell profiles like .bashrc), and system-level variables (global settings). For example, if DATABASE_URL is set system-wide as “system-value”, in your user profile as “user-value”, and you start your application with DATABASE_URL=process-value node app.js, the application sees “process-value” because process-level has the highest priority.
4. What are the security risks of environment variables and how can you mitigate them?
The main risks include accidentally committing secrets to version control (exposing them in Git history forever), logging secret values in error messages or debug output, and oversharing variables in shared environments. Mitigations include: always adding .env files to .gitignore, using different credentials per environment to limit breach impact, employing secret management services like AWS Secrets Manager or HashiCorp Vault for production, validating required variables at startup to fail fast, and never logging full environment contents—only log that variables are set, not their values.
5. How would you debug an application that’s not reading environment variables correctly in a containerized environment?
Start by verifying the variables are actually set in the container: run docker exec <container> env or kubectl exec <pod> -- env to list all variables. Check that you’re reading them correctly in code (remember all values are strings—parse booleans and numbers explicitly). Verify precedence: Docker Compose environment entries override env_file, and command-line -e flags override both. Ensure timing: if using .env files, confirm they’re copied into the image or mounted as volumes before your application starts. Finally, check for typos in variable names—DATABASE_URL vs DATABSE_URL—which silently fail.
Conclusion
Environment variables are a foundational concept that every developer must understand to build production-ready applications. They enable the critical separation of configuration from code, allowing the same application to run securely across multiple environments without modification.
Key takeaways from this guide:
- Environment variables are key-value pairs accessible to processes, used for configuration, secrets, and environment-specific settings.
- Separation of concerns is essential: keep secrets out of version control and use environment variables for deployment-specific configuration.
- Security first: Never commit
.envfiles, use different credentials per environment, employ secret managers in production, and validate variables at startup. - Hybrid approach works best: Combine configuration files (for defaults and structure) with environment variables (for secrets and overrides).
- Always validate: Treat environment variables as external input—validate types, provide defaults, and fail fast on missing required variables.
- Document thoroughly: Maintain an
.env.examplefile with all required variables and clear comments for team members.
As you build more complex applications and deploy to various platforms, your comfort with environment variables will directly impact your ability to manage configuration securely and efficiently. Practice using them in local development with .env files, and gradually adopt more sophisticated patterns like secret rotation and centralized secret management as your needs grow.
The next topic in this series covers Configuration Management Best Practices—diving deeper into advanced patterns for managing complex application configuration at scale.
For foundational backend concepts, review our guide on How Logging Works in Backend Systems—another essential operational concern that pairs well with configuration management.
References
-
The Twelve-Factor App - Config
https://12factor.net/config -
Environment Variables - Node.js Documentation
https://nodejs.org/api/process.html#process_process_env -
dotenv - Zero-dependency module for loading .env files
https://github.com/motdotla/dotenv
YouTube Videos
-
“How to Use Environment Files (.env) in Node.js - Tutorial (dotenv)“
https://www.youtube.com/watch?v=hZUNMYU4Kzo -
“What are environment variables How do they work”
https://www.youtube.com/watch?v=l7cKY6q7e3Y -
“Understanding Environment Variables in Depth | Node.js Fundamentals”
https://www.youtube.com/watch?v=Thq58zJ8-4U