StackPractices
beginner By Mathias Paulenko

Environment Variables

How to read, set, and manage environment variables securely across Python, JavaScript, and Java.

Topics: devops

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 .env file 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 export in Bash or setx in 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 .env to .gitignore on day one. A committed .env with 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 .env and 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.production side by side. Most frameworks load the right one from NODE_ENV or 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_*, or REACT_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 .env with real secrets: deleting the file doesn’t remove it from Git history. Scrub it with git filter-repo or 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

  1. 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
  1. 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
  1. 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.