Environment Variables
How to read, set, and manage environment variables securely across Python, JavaScript, and Java.
Overview
Environment variables are key-value pairs that live outside your code and control how it behaves. They’re the cornerstone of the 12-Factor App methodology and the standard way to handle secrets, API keys, database URLs, and feature flags.
Keeping config outside the code makes the app portable across dev, staging, and prod — and keeps secrets out of Git.
Before environment variables became the standard, configuration was often embedded in source code or stored in XML files checked into repositories. That made deployments fragile: a database password change required a commit, rebuild, and redeploy. Environment variables fix this by externalizing every environment-specific setting, so the same compiled artifact runs in dev, staging, and prod untouched — the “build once, deploy many” principle behind modern CI/CD and containers.
When to Use
Reach for this recipe when you’re:
- Configuring apps per environment (dev, staging, prod). See Docker Basics for containerized app config.
- Storing API keys and database credentials. See JWT Authentication for secure token handling.
- Enabling or disabling capabilities with feature flags. See Feature Flags for toggle management.
- Managing containerized app config in Docker and Kubernetes. See Docker Compose Local Dev for local orchestration.
- Avoiding hard-coded values in source code
- Sharing configuration across microservices without a central config server
- Switching database endpoints between primary and replica for read scaling
- Enabling debug logging or profiling only in specific environments
Solution
Python
os.getenv is Python’s way in: give it a key and it returns the value, or a default you choose. For local dev, python-dotenv pulls variables out of a .env file so you don’t have to export them in your shell first.
import os
from dotenv import load_dotenv
# Load from .env file (dev only)
load_dotenv()
# Read variables
api_key = os.getenv('API_KEY')
db_url = os.getenv('DATABASE_URL', 'sqlite:///default.db') # with default
port = int(os.getenv('PORT', '8080'))
debug = os.getenv('DEBUG', 'false').lower() == 'true'
print(f"API_KEY={api_key}, PORT={port}, DEBUG={debug}")
JavaScript (Node.js)
In Node, everything lives under process.env. The dotenv package loads your .env file at startup — Node only, since browsers don’t get process.env at runtime.
require('dotenv').config(); // Load .env file
const apiKey = process.env.API_KEY;
const dbUrl = process.env.DATABASE_URL || 'mongodb://localhost:27017/default';
const port = parseInt(process.env.PORT || '8080', 10);
const debug = process.env.DEBUG === 'true';
console.log(`API_KEY=${apiKey}, PORT=${port}, DEBUG=${debug}`);
Java
System.getenv() hands you an immutable snapshot of the process environment. getOrDefault covers the optional stuff, and you parse strings into real types yourself.
public class Config {
public static void main(String[] args) {
String apiKey = System.getenv("API_KEY");
String dbUrl = System.getenv().getOrDefault("DATABASE_URL", "jdbc:mysql://localhost/default");
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
boolean debug = Boolean.parseBoolean(System.getenv().getOrDefault("DEBUG", "false"));
System.out.println("API_KEY=" + apiKey + ", PORT=" + port + ", DEBUG=" + debug);
}
}
.env File
# .env — never commit this file to version control
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
API_KEY=sk-live-xxxxxxxxxxxx
PORT=3000
DEBUG=true
Add .env to .gitignore:
.env
.env.local
.env.*.local
Explanation
os.environ/process.env/System.getenv(): runtime access to environment variables, inherited from the parent process (shell, systemd, Docker). Children can’t modify the parent’s environment.load_dotenv()/require('dotenv').config(): loads variables from a.envfile in development. Don’t commit that file — it’s a local-dev convenience only.- Defaults: default the boring stuff (ports, debug flags); never default secrets. Missing a required var? Crash loud and early — name the key — instead of dying later with a cryptic error.
- Type coercion: environment variables are always strings, so cast to int/boolean yourself. Watch out: in JavaScript the string
"false"is truthy — compare it explicitly. - Scope: variables set in the shell are visible to the current process and its children. Use
exportin Bash orsetxin Windows to persist them across sessions.
Variants
Go
Go’s os.Getenv returns an empty string when the key isn’t set. Use os.LookupEnv to tell unset apart from empty:
package main
import (
"fmt"
"os"
"strconv"
)
func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok {
return val
}
return fallback
}
func main() {
apiKey := os.Getenv("API_KEY")
dbURL := getEnv("DATABASE_URL", "postgres://localhost:5432/default")
port, _ := strconv.Atoi(getEnv("PORT", "8080"))
debug := getEnv("DEBUG", "false") == "true"
fmt.Printf("API_KEY=%s, DB=%s, PORT=%d, DEBUG=%v\n", apiKey, dbURL, port, debug)
}
Bash
#!/bin/bash
# Source .env file if it exists
if [ -f .env ]; then
set -a
source .env
set +a
fi
# Read variables with defaults
API_KEY="${API_KEY:-default-key}"
DB_URL="${DATABASE_URL:-postgres://localhost:5432/default}"
PORT="${PORT:-8080}"
DEBUG="${DEBUG:-false}"
echo "API_KEY=$API_KEY, PORT=$PORT, DEBUG=$DEBUG"
# Validate required variables
if [ -z "$API_KEY" ]; then
echo "ERROR: API_KEY is required" >&2
exit 1
fi
Docker
# Dockerfile — set defaults at build time
ENV NODE_ENV=production
ENV PORT=3000
# Override at runtime: docker run -e PORT=8080 myapp
# Use ARG for build-time only variables
ARG BUILD_VERSION=latest
ENV APP_VERSION=$BUILD_VERSION
# Pass env vars at runtime
$ docker run -e DATABASE_URL=postgres://prod-db:5432/myapp -e API_KEY=sk-live-xxx myapp:v1
# Load from file
$ docker run --env-file .env.production myapp:v1
# Docker Compose
$ docker compose --env-file .env.production up
# docker-compose.yml
services:
app:
image: myapp:v1
environment:
- DATABASE_URL=postgres://db:5432/myapp
- API_KEY=${API_KEY}
- PORT=3000
env_file:
- .env.production
Kubernetes
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
DATABASE_URL: postgres://db-svc:5432/mydb
PORT: "8080"
DEBUG: "false"
---
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
API_KEY: sk-live-xxxxxxxxxxxx
DB_PASSWORD: super-secret-password
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
template:
spec:
containers:
- name: api
image: myapp:v1
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
Schema validation with pydantic-settings (Python)
from pydantic_settings import BaseSettings
from pydantic import Field, ValidationError
class Settings(BaseSettings):
api_key: str = Field(..., min_length=10)
database_url: str = Field(..., min_length=1)
port: int = Field(default=8080, ge=1, le=65535)
debug: bool = False
allowed_origins: list[str] = ["localhost"]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
try:
settings = Settings()
print(f"Loaded: port={settings.port}, debug={settings.debug}")
except ValidationError as e:
print(f"Configuration error: {e}")
raise SystemExit(1)
Schema validation with envalid (Node.js)
const { cleanEnv, str, port, bool, email } = require("envalid");
const env = cleanEnv(process.env, {
API_KEY: str({ minLength: 10 }),
DATABASE_URL: str({ default: "postgres://localhost:5432/default" }),
PORT: port({ default: 8080 }),
DEBUG: bool({ default: false }),
ADMIN_EMAIL: email({ default: "admin@example.com" }),
});
// env is now typed and validated
console.log(`API_KEY set: ${env.API_KEY.length > 0}`);
console.log(`PORT: ${env.PORT}`);
Per-environment .env files
# .env.development
DATABASE_URL=postgres://localhost:5432/dev_db
DEBUG=true
LOG_LEVEL=debug
# .env.staging
DATABASE_URL=postgres://staging-db:5432/staging_db
DEBUG=false
LOG_LEVEL=info
# .env.production
DATABASE_URL=postgres://prod-db:5432/prod_db
DEBUG=false
LOG_LEVEL=warning
# Python: load environment-specific .env
import os
from dotenv import load_dotenv
env = os.getenv("APP_ENV", "development")
load_dotenv(f".env.{env}")
load_dotenv(".env", override=False) # Fallback, don't override env-specific
// Node.js: dotenv-flow for hierarchical .env loading
require("dotenv-flow").config();
// Loads in order: .env.{NODE_ENV}.local > .env.{NODE_ENV} > .env.local > .env
What Works
- Never commit secrets: add
.envto.gitignoreon day one. A committed.envwith production credentials stays in Git history forever — deleting the file doesn’t remove it. - Ship a
.env.example: document required variables without real values so a new dev can copy it to.envand fill in their own credentials. - Validate on startup: fail fast when a required variable is missing; a half-configured app fails later with errors you can’t trace back.
- Keep per-environment files:
.env.development,.env.productionside by side. Most frameworks load the right one fromNODE_ENVor equivalent. - Use a secrets manager in production: AWS Secrets Manager, Azure Key Vault, and HashiCorp Vault give you rotation, audit logging, and access control that plain files can’t.
- Log config, never secrets: seeing
DATABASE_URL=***in a log confirms the var is set without handing credentials to whoever reads it. - Prefix public variables in frontend: Vite, Next.js, and Create React App only expose
VITE_*,NEXT_PUBLIC_*, orREACT_APP_*to the browser; everything else stays server-side. - Rotate secrets regularly: even good storage gets compromised. Put API key and database password rotation on a quarterly calendar.
Common Mistakes
- Committing
.envwith real secrets: deleting the file doesn’t remove it from Git history. Scrub it withgit filter-repoor BFG Repo-Cleaner if it already happened. - Assuming variables exist without defaults: the app crashes with confusing errors. Validate required vars and provide defaults for optional ones.
- Not validating at startup: missing config surfaces deep in the call stack, far away from the missing variable.
- Stuffing structured data into env vars: they’re flat key-value strings. For nested config, keep a JSON or YAML file and point to its path with an environment variable.
- Confusing build-time and runtime vars in frontend bundlers: values get baked into the client bundle at build time; changing the variable afterwards does nothing.
- Printing secrets in error messages: stack traces and public error pages get scanned for exactly this mistake.
- Reusing the same secrets everywhere: dev and prod should use different credentials — a leaked dev password shouldn’t open production.
Performance Tips
- Read env vars once at startup. Repeated
os.getenv()calls have minimal overhead, but caching in a config object is cleaner:
# Read once
_config = Settings()
# Use everywhere
def get_db_url():
return _config.database_url
- Avoid parsing complex values on every call. Parse once, cache the result:
# Parse once
_allowed_origins = os.getenv("ALLOWED_ORIGINS", "").split(",")
# Use cached list
def is_origin_allowed(origin):
return origin in _allowed_origins
- Use lazy loading for secrets. Fetch secrets from AWS Secrets Manager on first use, then cache:
import boto3
from functools import lru_cache
@lru_cache(maxsize=1)
def get_db_password():
client = boto3.client("secretsmanager")
response = client.get_secret_value(SecretId="prod/db/password")
return response["SecretString"]
See Also
Frequently Asked Questions
Can I use environment variables in the browser?
Only at build time via bundler substitution — the browser can't see the server's environment. Never expose server secrets in client code; use your framework's public prefix (VITE_, NEXT_PUBLIC_, REACT_APP_).
What is the 12-Factor App config principle?
Store config in environment variables so the same artifact deploys anywhere without code changes. One Docker image can run dev, staging, and prod with different variables.
How do I manage secrets in a Docker container?
Pass them at runtime with -e flags, Docker secrets, or mounted files — never bake them into the image. If the registry leaks, every baked-in secret leaks with it.
What is the difference between .env and shell exports?
.env is a file your app reads at startup — it only affects that process. export VAR=value lives in your shell session and passes down to children. Per-project settings go in .env; global tooling gets exports.
Should I validate environment variables by hand or with a schema library?
Small project? A manual check at startup is enough. Once the config grows, envalid (Node), pydantic-settings (Python), or Spring's @ConfigurationProperties buy you types, defaults, and automatic validation.
What happens if a required variable is missing?
Whatever you decided — so make it fail fast. Read required vars at startup, raise a clear error naming the missing key, and exit before the app serves traffic.
.env files or a secrets manager in production?
Files are fine for dev and small deploys. In production, a secrets manager adds rotation, audit logs, and access control — and keeps plaintext secrets off disk.
Related Resources
Docker Basics
How to containerize an application, write a Dockerfile, and run containers with Docker Compose.
RecipeManage Application Secrets Securely
How to store, rotate, and inject API keys, database passwords, and certificates without hardcoding them in source code or environment files.
RecipeDocker Secrets Management Without Hardcoding Credentials
Inject secrets into containers using Docker secrets, env files, and external secret managers without hardcoding them in images.
RecipeJWT Authentication
Sign, verify, and refresh JWTs in Python, JavaScript, and Java — access/refresh flows, RS256 key pairs, and token revocation done right.
RecipeParse and Validate YAML/JSON Configuration
How to parse and validate application configuration files in YAML and JSON across Python, JavaScript, Java, and Go.
GuideSecrets Management: Vault, Cloud Managers
A practical guide to secrets management: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, and GCP Secret Manager with rotation, access control, and CI/CD integration.