12 min read

Environment Variables Best Practices for Developers

A practical guide to .env files, dotenv configuration, secret management, Docker integration, and the security rules that prevent your API keys from ending up on GitHub.

What Are Environment Variables and Why They Matter

Environment variables are key-value pairs that exist outside your application code and configure how your software behaves at runtime. They are part of the operating system process environment, accessible to any program running in that context. When you set DATABASE_URL=postgres://localhost:5432/mydb, you are telling your application where to find its database without hardcoding that information into a source file.

This separation of configuration from code is not a convenience -- it is a foundational principle of secure, maintainable software. The Twelve-Factor App methodology identifies it as one of the twelve essential practices for building modern applications. The reasons are concrete:

If you have ever pushed an AWS access key to a public GitHub repository, you already know why this matters. Automated bots scan every public commit within seconds. The moment a secret hits a public repo, it is compromised. Environment variables are the first line of defense against that scenario.

The .env File and Dotenv: How They Work

A .env file is a plain-text file that stores environment variables in KEY=VALUE format, one per line. It lives in your project root and is loaded into the application's runtime environment by a library -- most commonly dotenv.

# .env — Example configuration
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://user:password@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
API_KEY=sk-live-abc123def456
JWT_SECRET=your-256-bit-secret-here
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587

How Dotenv Works Under the Hood

When you call require('dotenv').config() in Node.js or load_dotenv() in Python, the library reads the .env file, parses each line, and injects the key-value pairs into the process environment. After that, your application accesses them through process.env.DATABASE_URL (Node.js) or os.environ['DATABASE_URL'] (Python). The values become indistinguishable from system-level environment variables.

The critical detail: dotenv is a development convenience. In production, environment variables should be set through your hosting platform, container orchestration, or a dedicated secrets manager -- not loaded from a file on disk. The .env file is for local development only.

The .env.example Pattern

Every project that uses environment variables should include a .env.example file. This file is committed to version control. It lists every required variable with placeholder values and comments explaining what each one does.

# .env.example — Commit this to your repository
# Copy to .env and fill in real values

NODE_ENV=development
PORT=3000

# Database (PostgreSQL)
DATABASE_URL=postgres://user:password@localhost:5432/dbname

# Redis (optional, for caching)
REDIS_URL=redis://localhost:6379

# API Keys
API_KEY=your-api-key-here
JWT_SECRET=generate-a-random-256-bit-string

# Email (SMTP)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-email@example.com
SMTP_PASS=your-email-password

When a new developer joins the project, they copy .env.example to .env, fill in their local values, and the application works. No Slack messages asking "what environment variables do I need?" No hunting through code to find which keys the app expects.

Security Best Practices for Environment Variables

Environment variables are only as secure as the practices around them. Mishandling them is one of the most common causes of production security incidents. These rules are non-negotiable.

1. Never Commit .env Files to Version Control

Add .env to your .gitignore before your first commit. Not after. Not "when you get around to it." Before the repository exists. Use a .gitignore generator to create a proper ignore file that covers .env, .env.local, .env.*.local, and other sensitive files for your stack.

Warning

If you already committed a .env file with real secrets, removing it from the repository is not enough. Git preserves the file in history. Anyone with access to the repo can retrieve it. You must: (1) remove the file and add it to .gitignore, (2) rotate every secret that was exposed, and (3) consider using git filter-branch or BFG Repo-Cleaner to purge it from history entirely.

2. Rotate Secrets Regularly

API keys and passwords are not permanent. Rotate them on a schedule -- every 90 days is a common baseline. Rotate immediately when: a team member leaves, a secret is accidentally exposed, or you suspect unauthorized access. Automated rotation through a secrets manager eliminates the manual effort.

3. Use Different Secrets Per Environment

Development, staging, and production must use completely separate credentials. Your development database password should not work on your production database. Your staging API key should not have production permissions. This limits blast radius: if a development secret leaks, production is unaffected.

4. Validate Variables at Startup

Do not let your application start if required environment variables are missing. Fail fast and fail loud. A missing DATABASE_URL that causes a cryptic error ten minutes into runtime is far worse than a clear error message at startup.

// Node.js — Validate required env vars at startup
const required = ['DATABASE_URL', 'JWT_SECRET', 'API_KEY'];
const missing = required.filter(key => !process.env[key]);

if (missing.length > 0) {
  console.error(`Missing required environment variables: ${missing.join(', ')}`);
  console.error('Copy .env.example to .env and fill in the values.');
  process.exit(1);
}

5. Never Log Secret Values

Logging process.env for debugging is a habit that ends careers. Logs get stored in centralized logging systems, piped to third-party services, and retained for months. A single console.log(process.env) can expose every secret in your application to anyone with log access. If you need to verify that a variable is set, log its presence, not its value: console.log('API_KEY is set:', !!process.env.API_KEY).

6. Restrict Access on a Need-to-Know Basis

Not every developer needs access to production secrets. Use role-based access control in your secrets manager. Junior developers and CI runners should have access to development and staging variables only. Production secrets should be accessible to a small group of senior engineers and deployment systems.

Key Takeaway

The .gitignore file is your first line of defense. Generate one that covers all sensitive files for your language and framework. Use the NexTool .gitignore Generator to create a comprehensive ignore file in seconds -- it includes .env patterns for Node.js, Python, Go, Ruby, React, and more.

Code Examples: Node.js, Python, and Shell

Node.js with dotenv

The dotenv npm package is the standard for loading .env files in Node.js. Install it as a dev dependency and call it as early as possible in your entry point.

# Install
npm install dotenv

# Your .env file
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
JWT_SECRET=super-secret-key-change-me
PORT=3000
// app.js — Load env vars before anything else
require('dotenv').config();

const express = require('express');
const app = express();

// Access variables through process.env
const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;
const jwtSecret = process.env.JWT_SECRET;

// Validate at startup
if (!dbUrl || !jwtSecret) {
  console.error('Missing required environment variables.');
  console.error('See .env.example for required configuration.');
  process.exit(1);
}

app.listen(port, () => {
  console.log(`Server running on port ${port}`);
});

For TypeScript projects, use a typed configuration module to get autocompletion and compile-time checks. Libraries like envalid or zod can validate and parse environment variables with type safety.

Python with python-dotenv

# Install
pip install python-dotenv
# config.py — Centralized configuration
import os
from dotenv import load_dotenv

load_dotenv()  # Reads .env file into os.environ

class Config:
    DATABASE_URL = os.environ.get('DATABASE_URL')
    SECRET_KEY = os.environ.get('SECRET_KEY')
    DEBUG = os.environ.get('DEBUG', 'false').lower() == 'true'
    PORT = int(os.environ.get('PORT', 5000))

    @classmethod
    def validate(cls):
        required = ['DATABASE_URL', 'SECRET_KEY']
        missing = [var for var in required if not getattr(cls, var)]
        if missing:
            raise EnvironmentError(
                f"Missing required env vars: {', '.join(missing)}. "
                f"Copy .env.example to .env and fill in values."
            )

# Validate on import
Config.validate()

Shell and System-Level Variables

# Set for current session
export DATABASE_URL="postgres://user:pass@localhost:5432/mydb"

# Set permanently (add to ~/.bashrc or ~/.zshrc)
echo 'export API_KEY="your-key-here"' >> ~/.zshrc
source ~/.zshrc

# Check if a variable is set
if [ -z "$DATABASE_URL" ]; then
  echo "ERROR: DATABASE_URL is not set"
  exit 1
fi

# List all environment variables (careful — may contain secrets)
env | sort

Docker and Environment Variables

Docker adds another layer of complexity to environment variable management. Variables can be injected at build time, runtime, or through orchestration tools. Each method has different security implications.

Docker Compose: The env_file Directive

The cleanest approach for local Docker development is the env_file directive in docker-compose.yml. You can generate a Docker Compose file with proper env_file configuration using the Docker Compose Generator.

# docker-compose.yml
version: '3.8'
services:
  app:
    build: .
    ports:
      - "${PORT:-3000}:3000"
    env_file:
      - .env
    depends_on:
      - db
      - redis

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: ${DB_USER:-myapp}
      POSTGRES_PASSWORD: ${DB_PASS:-localdev}
      POSTGRES_DB: ${DB_NAME:-myapp}
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  pgdata:

Dockerfile: Build-Time vs Runtime Variables

There are two types of variables in Dockerfiles, and confusing them is a common source of security vulnerabilities.

# ARG — build-time only, NOT available at runtime
# Safe for non-secret build configuration
ARG NODE_VERSION=20

# ENV — persisted in the image, available at runtime
# NEVER put secrets here — they are baked into every layer
ENV NODE_ENV=production

# WRONG — secret is permanently in the image
# Anyone who pulls the image can extract it
ENV API_KEY=sk-live-abc123

# RIGHT — pass secrets at runtime only
# docker run -e API_KEY=sk-live-abc123 myapp
Warning

Never use ENV or ARG for secrets in a Dockerfile. Both can be extracted from the built image using docker history or docker inspect. Even multi-stage builds do not fully protect against this. Always inject secrets at runtime through -e flags, env_file, or Docker Secrets.

Docker Secrets (Swarm and Compose v3.1+)

For production Docker deployments, Docker Secrets provides encrypted storage and controlled access. Secrets are mounted as files inside the container at /run/secrets/, not exposed as environment variables.

# Create a secret
echo "sk-live-abc123" | docker secret create api_key -

# Use in docker-compose.yml
services:
  app:
    image: myapp
    secrets:
      - api_key

secrets:
  api_key:
    external: true
// Read Docker secret in Node.js
const fs = require('fs');

function getSecret(name) {
  try {
    return fs.readFileSync(`/run/secrets/${name}`, 'utf8').trim();
  } catch {
    // Fall back to environment variable for local dev
    return process.env[name.toUpperCase()];
  }
}

const apiKey = getSecret('api_key');

CI/CD Secret Management

Your CI/CD pipeline needs access to secrets for testing, building, and deploying. Every major platform provides a mechanism for this. The rules are the same as everywhere else: never hardcode secrets in pipeline configuration files.

GitHub Actions

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}
        run: |
          # Secrets are masked in logs automatically
          npm run deploy

GitLab CI/CD

# .gitlab-ci.yml
deploy:
  stage: deploy
  script:
    - echo "Deploying with $DEPLOY_TOKEN"  # Set in GitLab CI/CD Settings
  variables:
    NODE_ENV: production
  # $DATABASE_URL and $API_KEY are set as masked, protected
  # variables in Settings > CI/CD > Variables

Regardless of platform, follow these principles:

Secret Management at Scale

For teams and production systems, .env files and platform secrets are not enough. Dedicated secret management tools provide encryption, access control, audit logging, and automatic rotation.

Tool Best For Key Feature Complexity
HashiCorp Vault Enterprise, multi-cloud Dynamic secrets, auto-rotation High
AWS Secrets Manager AWS-native stacks RDS password rotation, IAM integration Medium
Doppler Startups, small teams Universal sync, easy setup Low
1Password Secrets Automation Teams already using 1Password Developer-friendly CLI, Connect server Low
SOPS (Mozilla) GitOps workflows Encrypted files in Git, KMS integration Medium

For most small-to-medium projects, your CI/CD platform's built-in secret storage combined with good .env practices is sufficient. Move to a dedicated secrets manager when you have: multiple services sharing secrets, regulatory compliance requirements, or a need for automatic credential rotation.

Generate Your Project Configuration Files

Start with a proper .gitignore and Docker Compose setup. No secrets in your repo from day one.

Generate .gitignore

Common Mistakes That Leak Secrets

Security incidents from environment variable mishandling follow predictable patterns. Knowing them is the first step to avoiding them.

1. Committing .env to a Public Repository

GitHub's own research found that over 10 million secrets are leaked in public repositories every year. Bots scan new commits in real time. AWS keys committed to a public repo are typically exploited within minutes -- often to spin up cryptocurrency mining instances that cost thousands of dollars. Always verify your .gitignore is working: run git status and confirm your .env file does not appear.

2. Logging process.env in Error Handlers

A common debugging pattern is to dump the entire environment into error logs for context. This sends every secret to your logging provider. Use structured logging that explicitly selects non-sensitive fields instead.

3. Using ENV in Dockerfiles for Secrets

As covered above, ENV instructions persist in every layer of the Docker image. Even if you delete the variable in a later layer, it exists in earlier layers that can be extracted. Use runtime injection or Docker Secrets instead.

4. Sharing .env Files Over Chat

Sending a .env file through Slack, Teams, or email creates a permanent copy in a system you do not control. Those messages are indexed, searchable, and often retained indefinitely. Use a secrets manager with sharing capabilities, or at minimum, use an encrypted channel and delete the message immediately after the recipient has copied the values.

5. Hardcoding Fallback Values for Secrets

Code like const apiKey = process.env.API_KEY || 'sk-default-key' is dangerous. If the environment variable is not set (misconfiguration, deployment error), the application silently uses the hardcoded fallback -- which might be a real key from development, now embedded in your source code. Fail loudly instead of falling back silently for any secret value.

6. Not Using .env.example

Without a .env.example file, new developers either ask teammates to send them a .env file (see mistake 4) or try to reverse-engineer the required variables from code. Both are error-prone and waste time. A well-documented .env.example is self-service onboarding for your project's configuration.

Frequently Asked Questions

What is a .env file and why should I use one?

A .env file is a plain-text configuration file that stores environment variables as KEY=VALUE pairs. It keeps sensitive data like API keys, database credentials, and service URLs out of your source code. Libraries like dotenv load these values into your application at runtime. You should use .env files because they separate configuration from code, prevent secrets from being committed to version control, and make it easy to use different settings across development, staging, and production environments.

Should I commit my .env file to Git?

No. You should never commit a .env file that contains real secrets to version control. Add .env to your .gitignore file immediately when starting any project. Instead, commit a .env.example file that lists all required variable names with placeholder values. This documents the expected configuration without exposing actual secrets. If a .env file with secrets has already been committed, removing it from future commits is not enough -- you must rotate every secret it contained, because Git history preserves the original values permanently.

How do I manage environment variables in Docker?

Docker provides three main methods for managing environment variables. First, the env_file directive in docker-compose.yml loads variables from a file. Second, the environment key lets you define variables directly in the compose file. Third, you can pass variables at runtime with docker run -e. For secrets in production, use Docker Secrets or a dedicated vault service instead of environment variables, since env vars can be exposed through docker inspect, process listings, and crash logs. Never bake secrets into Docker images with ENV instructions in your Dockerfile. Use the Docker Compose Generator to scaffold a secure starting configuration.

What is the difference between dotenv and system environment variables?

System environment variables are set at the operating system or shell level and are available to all processes. They persist across sessions and are managed through shell profiles (.bashrc, .zshrc) or system settings. Dotenv is a library that reads a .env file and injects its contents into your application's runtime environment (like process.env in Node.js). The key difference is scope: system env vars are global and persistent, while dotenv values are local to the project and loaded only when the application starts. Use dotenv for project-specific config in development, and system-level or platform-managed variables in production.

How should I handle environment variables in CI/CD pipelines?

Never store secrets directly in CI/CD configuration files, which are often committed to version control. Instead, use your CI/CD platform's built-in secret management: GitHub Actions secrets, GitLab CI/CD variables (masked and protected), or your provider's equivalent. Mark all sensitive variables as secret or masked to prevent them from appearing in build logs. Scope secrets to specific environments (staging, production) so development builds cannot access production credentials. Rotate CI/CD secrets on the same schedule as other credentials, and audit access logs for who can view or modify them.

Explore 150+ Free Developer Tools

From .gitignore generators to Docker Compose builders, JSON formatters to Base64 encoders -- all browser-based, no signup required.

Browse All Free Tools
NT

NexTool Team

We build free, privacy-first developer and security tools. Every tool runs client-side in your browser. No data collection, no accounts required, no compromises on privacy.