Tooling

Environment Management with .env Files

A comprehensive guide to managing environment variables with .env files covering multi-environment setups, Docker integration, CI/CD secrets, and validation patterns.

Environment Management with .env Files

Every production incident I have investigated that traced back to configuration started the same way: someone hardcoded a database connection string, an API key ended up in version control, or a deployment targeted the wrong environment because a variable was missing. Environment variables solve all of these problems, and .env files make working with them practical during development.

This guide covers everything you need to manage environment variables properly across development, testing, staging, and production. We will walk through the .env file format, multi-environment hierarchies, validation at startup, Docker integration, CI/CD secrets, and the patterns that keep your configuration secure and consistent.

The .env File Format

A .env file is a plain text file containing key-value pairs. There is no formal specification, but the format established by the Ruby dotenv gem has become the de facto standard across languages.

# Database configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_development
DB_USER=appuser
DB_PASSWORD=secretpassword

# API keys
STRIPE_SECRET_KEY=sk_test_abc123
SENDGRID_API_KEY=SG.xxxxxxxxxxxx

# Application settings
PORT=3000
NODE_ENV=development
LOG_LEVEL=debug

# Multiline values use quotes
RSA_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"

# Variable interpolation (requires dotenv-expand)
BASE_URL=http://localhost:${PORT}
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}

Key rules for the format:

  • One variable per line, using KEY=VALUE syntax.
  • Lines starting with # are comments.
  • Values with spaces, special characters, or newlines must be wrapped in double quotes.
  • No spaces around the = sign. DB_HOST = localhost will not parse correctly in every implementation.
  • Empty lines are ignored.

Variable Naming Conventions

Use SCREAMING_SNAKE_CASE for all environment variable names. This is not just convention; it is a universal standard across operating systems and programming languages. Environment variables in uppercase stand out clearly in code and are immediately recognizable as external configuration rather than local variables.

Group related variables with a common prefix:

# Good: grouped and scannable
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp
DB_USER=admin
DB_PASSWORD=secret

REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=

SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=SG.xxxx

# Bad: inconsistent and hard to scan
database_host=localhost
DbPort=5432
name=myapp

The dotenv Package

The dotenv package loads variables from a .env file into process.env. Install it as a production dependency since your application needs it to start:

npm install dotenv

Load it as early as possible in your application entry point:

// app.js
require("dotenv").config();

var express = require("express");
var app = express();

var port = process.env.PORT || 3000;

app.listen(port, function () {
  console.log("Server running on port " + port);
});

The config() call reads .env from the current working directory and merges the variables into process.env. Importantly, dotenv does not overwrite existing environment variables. If PORT is already set in the shell environment, the value from .env is ignored. This is the correct behavior because it means deployment platforms can override development defaults without code changes.

Custom File Paths

You can specify a different file path:

require("dotenv").config({ path: "/opt/app/.env.production" });

Or load multiple files by calling config() more than once:

require("dotenv").config({ path: ".env.local" });
require("dotenv").config({ path: ".env" });

Since dotenv does not overwrite existing values, the first file loaded wins. This gives you a natural override hierarchy.

Environment File Hierarchy

A well-structured project uses multiple .env files to handle different environments:

.env                  # Shared defaults for all environments
.env.local            # Local overrides (never committed)
.env.development      # Development-specific defaults
.env.test             # Test-specific defaults
.env.production       # Production-specific defaults (committed but safe values only)
.env.development.local # Local development overrides
.env.test.local       # Local test overrides
.env.production.local # Local production overrides

The loading order should be:

  1. .env.{NODE_ENV}.local (highest priority, never committed)
  2. .env.local (never committed, skipped for test environment)
  3. .env.{NODE_ENV} (committed, environment-specific defaults)
  4. .env (committed, shared defaults)

Here is a loader that implements this hierarchy:

// config/env-loader.js
var dotenv = require("dotenv");
var path = require("path");
var fs = require("fs");

function loadEnvFiles() {
  var nodeEnv = process.env.NODE_ENV || "development";
  var rootDir = path.resolve(__dirname, "..");

  // Files in priority order (first loaded wins because dotenv won't overwrite)
  var files = [
    ".env." + nodeEnv + ".local",
    nodeEnv !== "test" ? ".env.local" : null,
    ".env." + nodeEnv,
    ".env"
  ].filter(Boolean);

  var loaded = [];

  files.forEach(function (file) {
    var filePath = path.join(rootDir, file);
    if (fs.existsSync(filePath)) {
      dotenv.config({ path: filePath });
      loaded.push(file);
    }
  });

  if (process.env.LOG_LEVEL === "debug") {
    console.log("Loaded env files:", loaded.join(", "));
  }
}

module.exports = loadEnvFiles;

Use it as the first line in your entry point:

// app.js
require("./config/env-loader")();

// Now process.env is fully populated
var app = require("./server");

Notice that .env.local is skipped when NODE_ENV is test. This prevents your local development overrides from interfering with test assertions.

dotenv-expand for Variable References

The base dotenv package does not support variable interpolation. Install dotenv-expand to reference other variables:

npm install dotenv-expand
var dotenv = require("dotenv");
var dotenvExpand = require("dotenv-expand");

var env = dotenv.config();
dotenvExpand.expand(env);

// Now DATABASE_URL is fully resolved
console.log(process.env.DATABASE_URL);
// postgres://appuser:secret@localhost:5432/myapp

This is invaluable for constructing compound connection strings from individual components.

The .env.example File

Every project should include a .env.example file committed to version control. It documents every environment variable the application expects, with placeholder values:

# .env.example
# Copy this file to .env and fill in real values
# cp .env.example .env

# Server
PORT=3000
NODE_ENV=development

# Database (PostgreSQL)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_development
DB_USER=
DB_PASSWORD=

# Redis
REDIS_URL=redis://localhost:6379

# External APIs
STRIPE_SECRET_KEY=sk_test_...
SENDGRID_API_KEY=SG....

# Authentication
JWT_SECRET=generate-a-random-string-here
SESSION_SECRET=generate-another-random-string

# Feature flags
ENABLE_SIGNUP=true
ENABLE_BETA_FEATURES=false

This file serves three purposes: it documents what variables are needed, provides sensible defaults where possible, and gives new developers a starting point. Your README should reference it with setup instructions.

Validating Environment Variables at Startup

Never let your application run with missing or malformed configuration. Validate every required variable at startup and fail fast with a clear error message.

// config/validate-env.js
function validateEnv() {
  var errors = [];

  var required = [
    { name: "DB_HOST", type: "string" },
    { name: "DB_PORT", type: "port" },
    { name: "DB_NAME", type: "string" },
    { name: "DB_USER", type: "string" },
    { name: "DB_PASSWORD", type: "string" },
    { name: "JWT_SECRET", type: "string", minLength: 32 },
    { name: "PORT", type: "port", default: "3000" },
    { name: "NODE_ENV", type: "enum", values: ["development", "test", "staging", "production"] }
  ];

  required.forEach(function (spec) {
    var value = process.env[spec.name];

    // Apply default if not set
    if (!value && spec.default) {
      process.env[spec.name] = spec.default;
      value = spec.default;
    }

    // Check presence
    if (!value) {
      errors.push(spec.name + " is required but not set");
      return;
    }

    // Type checks
    if (spec.type === "port") {
      var port = parseInt(value, 10);
      if (isNaN(port) || port < 1 || port > 65535) {
        errors.push(spec.name + " must be a valid port (1-65535), got: " + value);
      }
    }

    if (spec.type === "enum" && spec.values.indexOf(value) === -1) {
      errors.push(spec.name + " must be one of [" + spec.values.join(", ") + "], got: " + value);
    }

    if (spec.minLength && value.length < spec.minLength) {
      errors.push(spec.name + " must be at least " + spec.minLength + " characters");
    }
  });

  if (errors.length > 0) {
    console.error("\n=== Environment Validation Failed ===");
    errors.forEach(function (err) {
      console.error("  - " + err);
    });
    console.error("\nSee .env.example for required variables.\n");
    process.exit(1);
  }
}

module.exports = validateEnv;
// app.js
require("./config/env-loader")();
require("./config/validate-env")();

// Application is safe to start
var server = require("./server");

This pattern catches configuration problems immediately. A missing variable at startup is far better than a crash at 3 AM when a code path finally tries to read an unset value.

Type Coercion Patterns

Environment variables are always strings. You need explicit coercion for booleans, numbers, and arrays:

// config/index.js
require("./env-loader")();
require("./validate-env")();

var config = {
  port: parseInt(process.env.PORT, 10) || 3000,
  db: {
    host: process.env.DB_HOST,
    port: parseInt(process.env.DB_PORT, 10) || 5432,
    name: process.env.DB_NAME,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    pool: {
      min: parseInt(process.env.DB_POOL_MIN, 10) || 2,
      max: parseInt(process.env.DB_POOL_MAX, 10) || 10
    }
  },
  features: {
    signup: process.env.ENABLE_SIGNUP === "true",
    beta: process.env.ENABLE_BETA_FEATURES === "true"
  },
  cors: {
    origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean)
  },
  logLevel: process.env.LOG_LEVEL || "info",
  isProduction: process.env.NODE_ENV === "production",
  isDevelopment: process.env.NODE_ENV === "development"
};

module.exports = config;

Import this config module everywhere instead of reading process.env directly. This gives you one place to manage defaults, coercion, and documentation.

Node.js 20+ Built-in --env-file Flag

Starting with Node.js 20.6.0, you can load .env files without any package:

node --env-file=.env app.js
node --env-file=.env --env-file=.env.local app.js

This loads the files before your application code runs, so process.env is populated by the time your first require() executes. If you are on Node.js 20 or later and want to eliminate the dotenv dependency, this is the way to do it. The trade-off is that you handle the file hierarchy in your start scripts rather than in code:

{
  "scripts": {
    "start": "node --env-file=.env --env-file=.env.production app.js",
    "dev": "node --env-file=.env --env-file=.env.development app.js",
    "test": "node --env-file=.env --env-file=.env.test app.js"
  }
}

Frontend Environment Variables

Webpack DefinePlugin

Webpack replaces references to process.env.VARIABLE_NAME at build time using DefinePlugin:

// webpack.config.js
var webpack = require("webpack");
var dotenv = require("dotenv");

var env = dotenv.config().parsed || {};

// Only expose variables prefixed with APP_
var envKeys = Object.keys(env).reduce(function (acc, key) {
  if (key.indexOf("APP_") === 0) {
    acc["process.env." + key] = JSON.stringify(env[key]);
  }
  return acc;
}, {});

module.exports = {
  plugins: [
    new webpack.DefinePlugin(envKeys)
  ]
};

Use a prefix like APP_ or REACT_APP_ to explicitly mark which variables are safe to expose in the browser bundle. Never expose server-side secrets to the frontend.

Vite's import.meta.env

Vite automatically loads .env files and exposes variables prefixed with VITE_ through import.meta.env:

# .env
VITE_API_URL=http://localhost:3000/api
VITE_APP_TITLE=My Application
SECRET_KEY=never-exposed  # Not prefixed, not exposed

Vite handles the file hierarchy natively: .env, .env.local, .env.{mode}, .env.{mode}.local.

Docker and .env Files

Docker Compose reads .env files natively:

# docker-compose.yml
version: "3.8"

services:
  app:
    build: .
    ports:
      - "${PORT:-3000}:3000"
    env_file:
      - .env
      - .env.${NODE_ENV:-development}
    environment:
      - NODE_ENV=${NODE_ENV:-development}
    depends_on:
      - db
      - redis

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    ports:
      - "${DB_PORT:-5432}:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "${REDIS_PORT:-6379}:6379"

volumes:
  pgdata:

Docker Compose also reads the .env file in the project root automatically for variable substitution in the compose file itself. The env_file directive injects variables into the container's environment.

Your Dockerfile should never contain secrets:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]

Pass environment variables at runtime, never bake them into the image.

CI/CD Environment Injection

GitHub Actions

Store secrets in the repository settings and inject them as environment variables:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      NODE_ENV: test
      DB_HOST: localhost
      DB_PORT: 5432
      DB_NAME: myapp_test
      DB_USER: postgres
      DB_PASSWORD: postgres
      JWT_SECRET: ${{ secrets.JWT_SECRET }}

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: myapp_test
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          # Your deployment script here
          ./scripts/deploy.sh

GitLab CI

GitLab CI variables are set in the project settings and automatically available in jobs:

# .gitlab-ci.yml
stages:
  - test
  - deploy

test:
  stage: test
  image: node:20
  variables:
    NODE_ENV: test
    DB_HOST: postgres
    DB_NAME: myapp_test
  services:
    - postgres:16
  script:
    - npm ci
    - npm test

deploy:
  stage: deploy
  script:
    - ./scripts/deploy.sh
  environment:
    name: production
  only:
    - main

Both platforms mask secret values in build logs automatically.

Complete Working Example

Here is a full multi-environment setup. The project structure:

myapp/
  .env
  .env.example
  .env.development
  .env.test
  .env.production
  .gitignore
  config/
    env-loader.js
    validate-env.js
    index.js
  docker-compose.yml
  .github/workflows/deploy.yml
  app.js
  package.json

.env (committed, shared defaults):

PORT=3000
LOG_LEVEL=info
CORS_ORIGINS=http://localhost:3000
ENABLE_SIGNUP=true
ENABLE_BETA_FEATURES=false

.env.development (committed):

NODE_ENV=development
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_development
LOG_LEVEL=debug

.env.test (committed):

NODE_ENV=test
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_test
LOG_LEVEL=error
ENABLE_SIGNUP=true
ENABLE_BETA_FEATURES=true

.env.production (committed, no secrets):

NODE_ENV=production
DB_PORT=5432
LOG_LEVEL=warn
ENABLE_BETA_FEATURES=false

.env.local (NOT committed, developer fills from .env.example):

DB_USER=shane
DB_PASSWORD=localdevpassword
JWT_SECRET=a-long-random-string-at-least-32-chars-long
STRIPE_SECRET_KEY=sk_test_abc123

config/index.js (the unified config):

var envLoader = require("./env-loader");
var validateEnv = require("./validate-env");

envLoader();
validateEnv();

var config = {
  port: parseInt(process.env.PORT, 10) || 3000,
  nodeEnv: process.env.NODE_ENV || "development",
  isProduction: process.env.NODE_ENV === "production",
  db: {
    host: process.env.DB_HOST,
    port: parseInt(process.env.DB_PORT, 10) || 5432,
    name: process.env.DB_NAME,
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD
  },
  jwt: {
    secret: process.env.JWT_SECRET
  },
  features: {
    signup: process.env.ENABLE_SIGNUP === "true",
    beta: process.env.ENABLE_BETA_FEATURES === "true"
  },
  cors: {
    origins: (process.env.CORS_ORIGINS || "").split(",").filter(Boolean)
  },
  logLevel: process.env.LOG_LEVEL || "info"
};

module.exports = config;

app.js:

var config = require("./config");
var express = require("express");

var app = express();

app.get("/health", function (req, res) {
  res.json({
    status: "ok",
    environment: config.nodeEnv,
    features: config.features
  });
});

app.listen(config.port, function () {
  console.log(
    "Server running on port " + config.port +
    " in " + config.nodeEnv + " mode"
  );
});

Security: What Goes Where

Variable Type .env file Secrets Manager
Port numbers Yes No
Feature flags Yes No
Log levels Yes No
Database passwords Dev only Production
API keys Test keys only Production keys
JWT secrets Dev only Production
Encryption keys Never Always
OAuth client secrets Never Always

For production, use your platform's secrets management: AWS Secrets Manager, HashiCorp Vault, DigitalOcean App Platform environment variables, or your cloud provider's equivalent. The .env file is a development convenience, not a production secrets store.

.gitignore Patterns

# Environment files with secrets
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local

# Never commit the main .env if it contains secrets
# .env

# DO commit these (they should not contain secrets)
# .env.example
# .env.development
# .env.test
# .env.production

The .env.example file must always be committed. The files without .local in their name can be committed if they contain only non-sensitive defaults. Any file with .local in the name must be gitignored.

Multi-Service Environment Management

When your project has multiple services (API, worker, frontend), share common variables and separate service-specific ones:

project/
  .env                    # Shared: DB connection, Redis, etc.
  services/
    api/
      .env                # API-specific: PORT=3000, API_RATE_LIMIT=100
    worker/
      .env                # Worker-specific: CONCURRENCY=5, QUEUE_NAME=default
    frontend/
      .env                # Frontend-specific: VITE_API_URL=http://localhost:3000

In your Docker Compose, merge the shared and service-specific files:

services:
  api:
    env_file:
      - .env
      - ./services/api/.env
  worker:
    env_file:
      - .env
      - ./services/worker/.env

Common Issues and Troubleshooting

Variables not loading: The most common cause is loading dotenv too late. If you require("./config/database") before require("dotenv").config(), the database module reads process.env before it is populated. Always load dotenv as the very first operation.

Values include surrounding quotes: If your .env has DB_HOST="localhost" and your code receives the literal string "localhost" (with quotes), your parser is not stripping quotes. The dotenv package handles this correctly, but some minimal parsers do not. Use the dotenv package and avoid wrapping simple values in quotes.

Variables overwriting each other: Remember that dotenv does not overwrite existing variables. If you set PORT=8080 in your shell and PORT=3000 in .env, the app uses 8080. This is usually the correct behavior, but it confuses developers who do not realize they exported a variable in their .bashrc months ago. Run env | grep PORT to check.

Docker Compose variable substitution not working: Docker Compose reads the .env file in the project root for its own variable substitution (the ${VAR} syntax in the YAML file), but this is separate from the env_file directive that passes variables into containers. If substitution in the YAML file is not working, make sure your .env is in the same directory as docker-compose.yml.

Line ending issues on Windows: If you create .env files on Windows with CRLF line endings and then mount them into a Linux Docker container, the \r carriage return becomes part of the variable value. Your DB_HOST becomes localhost\r and connections fail with cryptic errors. Configure your editor to use LF line endings for .env files, or add *.env text eol=lf to your .gitattributes.

Best Practices

  1. Fail fast on missing variables. Validate all required environment variables at application startup. A clear error message saying "JWT_SECRET is required but not set" is infinitely better than a TypeError: Cannot read property of undefined deep in your authentication middleware thirty minutes later.

  2. Use a single config module. Never scatter process.env.SOME_VAR calls throughout your codebase. Centralize all environment variable access in one config module that handles defaults, coercion, and validation. This gives you one place to see every external dependency your application has.

  3. Never commit real secrets. Development and test defaults can go in committed .env.development and .env.test files. Real API keys, database passwords, and signing secrets belong in .env.local (gitignored) during development and in your platform's secrets manager in production.

  4. Maintain .env.example religiously. Every time you add a new environment variable, update .env.example in the same commit. Treat it like a schema file. New developers should be able to copy it, fill in a few values, and have a working application.

  5. Use prefixes for frontend exposure. When building frontend bundles, only expose variables with an explicit prefix (VITE_, REACT_APP_, NEXT_PUBLIC_, or a custom APP_ prefix). This prevents accidental leakage of server-side secrets into client-side JavaScript bundles.

  6. Keep environment parity. Your development, staging, and production environments should use the same variable names with different values. If production uses DATABASE_URL and development uses DB_CONNECTION_STRING, you are creating unnecessary divergence that will cause deployment bugs.

  7. Document variable dependencies. When a variable has constraints (minimum length, must be a URL, requires a specific format), document these in .env.example with comments. Your validation module should enforce the same constraints programmatically.

References

Powered by Contentful