Environment Variables Explained: .env Files and Best Practices

Every developer eventually runs into a hard-coded database password sitting in a GitHub repo — sometimes it's theirs, sometimes it's someone else's. Environment variables exist specifically to prevent this. They're one of the most important patterns in backend development, yet they're often misunderstood or applied inconsistently. This guide covers how they work, how to use them properly in Node.js and PHP, and the practices that keep credentials out of version control for good.

What Are Environment Variables?

An environment variable is a key-value pair stored in the operating system's environment — outside your application code. When your app starts, it reads these values from the environment rather than from a config file or hardcoded string in the source.

This matters for two reasons. First, security: credentials never enter your codebase or repository. Second, portability: the same code runs in development, staging, and production with different configurations, without changing a single line.

This is Principle III of the Twelve-Factor App methodology: store config in the environment. Config means anything that changes between deployments — database credentials, API keys, external service URLs, feature flags.

The .env File Format

Rather than setting environment variables directly in the OS for every developer on a team, the convention is to use a .env file at the root of the project. A library reads this file at startup and injects the values into the process environment.

# .env — never commit this file
DB_HOST=localhost
DB_NAME=myapp_dev
DB_USER=root
DB_PASS=supersecretpassword

APP_ENV=development
APP_URL=http://localhost:3000
APP_SECRET=a_long_random_string_32_chars_min

OPENAI_API_KEY=sk-proj-abc123...
STRIPE_SECRET_KEY=sk_test_abc123...

# Optional — empty values are allowed
REDIS_URL=
MAIL_HOST=

Syntax rules to know:

  • No spaces around =
  • Comments start with #
  • Quote values that contain spaces or special characters: GREETING="Hello, World"
  • No trailing semicolons (unlike PHP)
  • Blank lines are ignored

The .env file holds real secrets. The companion file .env.example (or .env.sample) has the same keys but with dummy values — this is committed to the repository so other developers know which variables the app needs:

# .env.example — safe to commit
DB_HOST=localhost
DB_NAME=myapp_dev
DB_USER=your_db_user
DB_PASS=your_db_password

APP_ENV=development
APP_URL=http://localhost:3000
APP_SECRET=generate_a_random_32_char_string

OPENAI_API_KEY=sk-proj-your_key_here
STRIPE_SECRET_KEY=sk_test_your_key_here

New team members clone the repo, copy .env.example to .env, fill in the real values, and run. No documentation needed for which variables exist.

Using Environment Variables in Node.js

The dotenv package is the standard for loading .env files in Node.js applications. Install it once:

npm install dotenv

Call it at the very top of your application entry point — before any other imports that might use the environment:

// server.js — call config() before anything else
require('dotenv').config();

const express = require('express');
const db = require('./db'); // safe — DB_* vars are already loaded

const app = express();
app.listen(process.env.PORT || 3000);

For ES Modules (using import syntax), import dotenv first:

import 'dotenv/config';
import express from 'express';
import { connectDB } from './db.js';

Access values via process.env:

const db = new Database({
    host:     process.env.DB_HOST,
    database: process.env.DB_NAME,
    user:     process.env.DB_USER,
    password: process.env.DB_PASS,
});

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

Validating Required Variables at Startup

If a required variable is missing and your app only fails when it reaches the code that uses it (possibly hours or days after startup), debugging is painful. Validate at startup with a simple check function:

function requireEnv(...keys) {
    const missing = keys.filter(k => !process.env[k]);
    if (missing.length > 0) {
        console.error('Missing required environment variables:', missing.join(', '));
        process.exit(1);
    }
}

requireEnv('DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS', 'APP_SECRET');

For larger applications, use Zod or Joi to validate the schema and types of environment variables — catching type mismatches (a number stored as a string) before they cause bugs downstream.

Using Environment Variables in PHP

The standard library is vlucas/phpdotenv. Install via Composer:

composer require vlucas/phpdotenv

Load at your application bootstrap (before any code that uses $_ENV):

<?php
require_once __DIR__ . '/vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->safeLoad(); // safeLoad() won't throw if .env doesn't exist
                     // use load() if .env is required

// Access values via $_ENV or getenv()
$dbHost = $_ENV['DB_HOST'] ?? 'localhost';
$apiKey = $_ENV['OPENAI_API_KEY'] ?? '';

Note: createImmutable() prevents accidental overwrites of already-set environment variables. safeLoad() is preferable in production where the .env file may not be present because the server uses its own environment variable system.

Validate required variables immediately after loading:

<?php
$dotenv->required(['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS', 'ADMIN_PASSWORD']);

This throws a ValidationException at boot time if any required variable is missing — fast, explicit failure instead of a mysterious error later.

Multiple Environments: Dev, Staging, Production

A common pattern is to have a base .env file supplemented by environment-specific files:

.env              # base defaults (committed as .env.example)
.env.development  # local development overrides
.env.production   # production overrides (NOT committed)
.env.test         # test environment

dotenv supports this with dotenv-flow (Node.js) or by loading multiple files. In many frameworks the convention is already built in — Next.js, for example, reads .env.local for local development and never exposes .env.local to the browser.

Setting Environment Variables Without a .env File

In production you rarely use a .env file. Instead, you set variables at the hosting or infrastructure level:

cPanel / Shared Hosting

cPanel has an "Environment Variables" section (or you can set them in the application's PHP configuration). Some hosts require you to set them in a .htaccess file:

SetEnv DB_HOST localhost
SetEnv DB_NAME myapp_prod

However, .htaccess env vars are readable by PHP via getenv() or $_SERVER, and if the file is ever exposed, the values are visible. The cPanel GUI approach is safer.

VPS / Server (Ubuntu)

For Node.js apps managed with PM2, you configure environment variables in the PM2 ecosystem file:

// ecosystem.config.js
module.exports = {
    apps: [{
        name: 'myapp',
        script: 'server.js',
        env_production: {
            NODE_ENV: 'production',
            PORT: 3000,
            DB_HOST: 'localhost',
            DB_PASS: 'your_production_password',
        }
    }]
};
pm2 start ecosystem.config.js --env production

The ecosystem file containing real values should not be committed — add it to .gitignore.

Cloud Platforms

Every modern platform has an environment variable UI built in — no files, no git exposure. Railway (Variables tab), Render (Environment), Fly.io (fly secrets set CLI), DigitalOcean App Platform (Settings → App-Level Environment Variables), and Heroku (Settings → Config Vars) all work the same way. In GitHub Actions, use repository Secrets (Settings → Secrets and variables → Actions) and reference them as ${{ secrets.KEY_NAME }}.

Critical Mistakes to Avoid

1. Never commit .env to Git

Add .env to your .gitignore immediately when you create the project — before you ever create the file. This prevents accidental commits:

# .gitignore
.env
.env.local
.env.production
.env.*.local

If you have already committed a .env file, remove it from history: rotate every secret in it first, then use git filter-branch or BFG Repo Cleaner to purge the history. The old credentials are compromised — rotating them is non-negotiable.

2. Don't use a single .env for all environments

Using production credentials in development means a mistake in your local code can hit production data. Always use separate databases, API keys (where the provider supports test keys), and service URLs per environment.

3. Don't expose .env to the browser

In React/Next.js, only variables prefixed with NEXT_PUBLIC_ (or VITE_ in Vite) are bundled into the client JavaScript — everything else stays on the server. Never prefix a secret with those prefixes. If you expose NEXT_PUBLIC_STRIPE_SECRET_KEY, it will be visible in every user's browser DevTools.

4. Validate types, not just presence

Every environment variable is a string. If you need a number (PORT=3000), parse it explicitly: parseInt(process.env.PORT, 10). If you need a boolean (ENABLE_DEBUG=true), compare the string: process.env.ENABLE_DEBUG === 'true'. Silent type coercion bugs are common with env vars.

Running a PHP app on shared hosting? Your .env credentials belong in cPanel — not in a file under the public root.

Get Help With Your Deployment

Frequently Asked Questions

What is the difference between a .env file and OS environment variables?

OS environment variables are set at the system or shell level (e.g., export KEY=value in bash, or via System Properties on Windows). They persist across sessions and are available to all processes. A .env file is a project-local file that a library (dotenv, phpdotenv) reads at application startup and injects into the process environment. The .env file approach is developer-friendly — it keeps config close to the project without polluting the global system environment.

Can I have multiple .env files for different environments?

Yes. A common convention is .env for base defaults, .env.development for local overrides, and .env.production for production-specific values. Most frameworks support this natively — Next.js reads .env.local for local secrets, and Create React App reads .env.development or .env.production depending on the build mode. Only .env.example (with dummy values) should be committed; all files with real values go in .gitignore.

Is it safe to commit a .env.example file?

Yes — that's its purpose. .env.example should only contain placeholder values like DB_PASS=your_password_here or empty values like STRIPE_KEY=. It documents which variables the application needs without exposing real credentials. Never put real API keys, passwords, or tokens in .env.example.

How do I set environment variables in production without a .env file?

Most production setups avoid .env files entirely and instead use the platform's built-in environment variable management: Heroku Config Vars, DigitalOcean App Platform Settings, Railway Variables, or directly in cPanel's environment variable interface for shared hosting. For a VPS, you can set them in the PM2 ecosystem file (keep it out of git), or export them in the systemd service file. GitHub Actions uses repository Secrets, which are injected as env vars during the workflow run.

What happens if a required environment variable is missing?

Without validation, your app will likely start up silently and fail later — sometimes with a confusing error like "Cannot read property of undefined" rather than "DB_PASS is missing." Always validate required variables at application startup. In Node.js, call a check function before the server starts listening. In PHP with phpdotenv, call $dotenv->required([...]) immediately after loading. This converts a runtime mystery into a clear startup error.