ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeThe Developer's Guide to Environment Variables and Secrets Management

The Developer's Guide to Environment Variables and Secrets Management

From .env Files to Production Secrets Managers — Handle Configuration Right Before a Credential Leak Forces You To

#security#environment-variables#secrets-management#dotenv#Docker#12-factor#DevOps#nodejs#configuration#Best Practices
Z
ZyVOP

Senior Developer

May 28, 2026
11 min read
3 views
The Developer's Guide to Environment Variables and Secrets Management

Why This Deserves More Attention Than It Gets

Credential leaks are one of the most common and preventable security incidents in software. Bots actively scan GitHub for newly pushed API keys, database URLs, and private credentials — and they find them within minutes of a commit going public. Rotating compromised credentials is painful, and in some cases the damage is done before you even realize what happened.

This isn't just an enterprise problem. It happens on solo side projects, open-source repos, and internal tools at startups. And the root cause is almost always the same: someone treated secrets like regular configuration and didn't have a clear strategy for keeping them out of version control, logs, and error messages.

The patterns in this guide aren't bureaucratic overhead. They're the minimum viable approach for any app that talks to a real database or a real API.


What Environment Variables Actually Are

An environment variable is a key-value pair that lives in a process's environment — a set of values the operating system makes available to any running program. Every process inherits the environment of the process that spawned it.

In Node.js, you access them through process.env:

const port = process.env.PORT;
const dbUrl = process.env.DATABASE_URL;

In Python:

import os
port = os.environ.get('PORT')
db_url = os.environ.get('DATABASE_URL')

The core idea — and the reason env vars are the standard approach — is that they decouple what the app does from where it runs. The same application code can point at a local development database or a production cluster. The code doesn't change; only the environment does. This is the heart of the 12-Factor App principle: store config in the environment, not in the code.


The .env File: What It Is and What It Isn't

In local development, you don't set environment variables by hand before every terminal session. Instead, you use a .env file — a plain text file at the root of your project with one key=value pair per line:

DATABASE_URL=postgresql://localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk_test_abc123xyz
JWT_SECRET=a-long-random-secret-string
PORT=3000
NODE_ENV=development

Libraries like dotenv (Node.js), python-dotenv (Python), and godotenv (Go) read this file at startup and load those values into the process environment. In Node.js:

// Entry point — must be first, before anything imports process.env
import 'dotenv/config';

This is the right tool for local development. Here's the distinction that matters most though: a .env file is a local developer convenience. It is not a secrets distribution system, and it should never be committed to version control. It belongs only on the developer's local machine — never on a server, never in a repo, never in a Docker image.


The .gitignore Rule — No Exceptions

Before writing a single value in your .env file, add it to .gitignore:

# .gitignore
.env
.env.local
.env.*.local
.env.development.local
.env.test.local
.env.production.local

If you have already committed a .env file — even once, even months ago — that secret exists in your Git history permanently. Deleting the file from the current tree doesn't remove it from past commits. Your only options are to rotate every exposed credential and use git filter-repo or BFG Repo Cleaner to scrub the history. It's a painful process. The .gitignore entry costs five seconds and prevents all of it.


The .env.example File: Document Without Exposing

Once .env is gitignored, new developers cloning the repo have no idea what environment variables the app needs. The solution is a .env.example file — committed to the repo, containing all the required keys but no real values:

# .env.example
# Copy this file to .env and fill in your local values.

# ── Database ──────────────────────────────────────────
DATABASE_URL=                    # e.g. postgresql://localhost:5432/myapp_dev
DATABASE_POOL_SIZE=10            # Optional. Defaults to 10.

# ── Authentication ────────────────────────────────────
JWT_SECRET=                      # Min 32 chars. Generate: openssl rand -hex 32
JWT_EXPIRES_IN=7d                # Token lifetime. Format: 1d, 7d, 1h

# ── Stripe ────────────────────────────────────────────
STRIPE_SECRET_KEY=               # Stripe Dashboard → Developers → API keys
STRIPE_WEBHOOK_SECRET=           # Stripe Dashboard → Webhooks → Signing secret

# ── Redis ─────────────────────────────────────────────
REDIS_URL=redis://localhost:6379 # Default local Redis

# ── App ───────────────────────────────────────────────
PORT=3000
NODE_ENV=development

Good comments here are worth more than you'd think. Telling a developer where to get a value (not just that it's needed) eliminates a whole class of "how do I set this up?" questions and dependency on tribal knowledge.

The onboarding flow becomes clean and self-contained:

git clone https://github.com/your-org/your-app
cp .env.example .env
# Fill in your values
npm install && npm run dev

No Slack messages. No waiting for someone to send credentials. No undocumented required services discovered at runtime.


Separating Config by Environment

Most apps run in at least three environments: local development, CI testing, and production. Each has different config values, different credentials, and sometimes different behavior. A layered .env file strategy handles this cleanly:

.env                  # Shared non-secret defaults, committed
.env.local            # Local machine overrides, gitignored
.env.test             # CI test runner config, possibly committed (no secrets)
.env.staging          # Staging environment — NOT committed
.env.production       # Production environment — NOT committed

In Node.js, you load the right file based on NODE_ENV:

import dotenv from 'dotenv';
import path from 'path';

dotenv.config({
  path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV ?? 'development'}`),
});

Frameworks handle this automatically — Next.js, Vite, and Create React App all have their own .env loading conventions. The principle is the same regardless: one environment, one configuration source, no bleed between them.

One rule worth stating directly: production credentials should not exist on developer laptops. If a developer can connect to the production database from their local machine because they have the URL in their .env, that's a real risk. Each environment should have its own credentials with access scoped appropriately.


Validate Configuration at Startup — Don't Discover Problems at Runtime

One of the most common causes of production incidents is an application that starts successfully but then fails at runtime because an environment variable is missing or has the wrong format. The error surfaces in a user-facing request with a cryptic message. The on-call engineer spends 20 minutes tracing it to a misconfigured env var that the app should have caught at boot.

The fix is to validate your entire configuration when the application starts and refuse to start if anything is wrong.

Using zod in TypeScript:

import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'),
  DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
  JWT_EXPIRES_IN: z.string().default('7d'),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  NODE_ENV: z.enum(['development', 'test', 'staging', 'production']),
});

const result = envSchema.safeParse(process.env);

if (!result.success) {
  console.error('❌ Invalid environment configuration:');
  console.error(result.error.format());
  process.exit(1);
}

export const env = result.data;

Now import env from this module everywhere instead of accessing process.env directly. You get full TypeScript types on your config, startup validation, and a single place where all required variables are declared. If anything is missing, the app exits immediately with a clear error:

❌ Invalid environment configuration:
{
  JWT_SECRET: { _errors: ['JWT_SECRET must be at least 32 characters'] },
  DATABASE_URL: { _errors: ['DATABASE_URL must be a valid URL'] }
}

That's infinitely better than a TypeError: Cannot read properties of undefined surfacing mid-request in production.


Secrets in CI/CD Pipelines

Your CI/CD pipeline needs secrets too — deploy keys, cloud provider credentials, API keys for integration tests. These should never appear in pipeline config files.

Every major CI platform has a built-in secret store:

GitHub Actions:

# .github/workflows/deploy.yml
jobs:
  deploy:
    steps:
      - name: Run deploy script
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: ./deploy.sh

Secrets are configured in GitHub → Settings → Secrets and Variables → Actions. They're encrypted at rest, masked in logs, and only available during workflow runs.

GitLab CI:

deploy:
  script: ./deploy.sh
  variables:
    DATABASE_URL: $DATABASE_URL  # Set in GitLab → Settings → CI/CD → Variables

The pattern is identical everywhere: secrets live in the platform's encrypted store, referenced by name in your config, injected as environment variables at runtime. Your pipeline file can be committed publicly with no risk.


Secrets Managers for Production

For production, .env files sitting on a server disk are a problem. They're plain text accessible to anyone with shell access, hard to rotate across multiple instances, and they create configuration drift between servers. The right solution is a dedicated secrets manager.

AWS Secrets Manager is the natural fit for AWS-hosted applications. Secrets are stored and encrypted in AWS, fetched by your app at startup using the AWS SDK, and access is controlled through IAM roles — no static credentials needed:

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'us-east-1' });

async function loadSecrets() {
  const res = await client.send(new GetSecretValueCommand({ SecretId: 'myapp/production' }));
  const secrets = JSON.parse(res.SecretString);
  // Inject into process.env or use directly
  process.env.DATABASE_URL = secrets.DATABASE_URL;
}

Doppler is popular for teams who want something simpler and platform-agnostic. You manage secrets in Doppler's dashboard and run your app through their CLI:

doppler run -- node server.js

Doppler fetches the right secrets for the environment and injects them. Your app code changes nothing — it still reads from process.env. Doppler handles rotation, audit logging, and access control centrally.

HashiCorp Vault is the most powerful option for teams that want full control — dynamic credentials, detailed audit logs, fine-grained access policies, and support for dozens of secret backends. It's more complex to operate but gives you capabilities the hosted services don't.

The common thread: in production, secrets should be fetched at runtime from an encrypted, auditable store — not read from a file sitting on disk.


The Things You Should Never Do

A few hard rules that are worth being explicit about:

Never commit a .env file. If it happened, rotate every credential in it and treat them as compromised. Git history is permanent.

Never hardcode secrets in source code. There's no such thing as "just temporarily." It lives in the history forever, and eventually someone will search the codebase and find it.

Never log process.env. A single console.log(process.env) at startup sends every secret to your log aggregator, monitoring dashboard, and anywhere else logs flow:

// 🚫 Dumps every secret everywhere logs go
console.log('App starting with config:', process.env);

// ✅ Log only what's safe and useful
console.log(`Starting in ${env.NODE_ENV} mode on port ${env.PORT}`);

Never pass secrets as CLI arguments. Arguments show up in ps aux output and in process manager logs. They're not private.

Never bake secrets into Docker images. A COPY .env . in a Dockerfile embeds the secrets permanently in that image layer. Even if you delete the file later in the build, the earlier layer still contains it:

# 🚫 Secrets are now baked into the image layer — forever
COPY .env .

# ✅ Inject at runtime, never at build time
# docker run --env-file .env.production myapp
# docker run -e DATABASE_URL=$DATABASE_URL myapp

Quick Audit Checklist

Run through this whenever you start a new project or review an existing one:

  • .env and all local env files are in .gitignore

  • .env.example is committed with all keys, no real values, with comments

  • npm install fully sets up the environment — zero extra steps for new devs

  • Config is validated at startup with a schema; app refuses to start on missing vars

  • process.env is only accessed through the validated config module

  • No console.log(process.env) anywhere in the codebase

  • CI/CD secrets live in the platform's secret store, not in config files

  • Production uses a secrets manager, not flat files on disk

  • Each environment has its own credentials — no production credentials on laptops

  • Docker images don't copy any .env files


Summary

Environment variable management is one of those things you either get right from the start or fix after something goes wrong. The cost of a credential leak ranges from embarrassing to catastrophic. The cost of the setup in this guide is a few hours, once. .env.example keeps onboarding smooth. Startup validation catches misconfigurations before they hit users. Secrets managers in production keep your credentials off of disks and out of the hands of anyone who shouldn't have them.

None of this is complicated — it's mostly just being deliberate about where secrets live and how they flow through your system. Build that habit early and it becomes invisible.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Related Posts

REST API Design Best Practices Every Developer Should Know

A poorly designed API becomes technical debt the moment clients start depending on it. This guide covers practical REST API design patterns for naming, versioning, pagination, validation, error handling, status codes, and response consistency that keep APIs scalable and developer-friendly.

Read article

Docker for Developers: Stop "It Works on My Machine" Forever

Docker eliminates the “works on my machine” problem by packaging your app, dependencies, and runtime into portable containers. This guide covers production-grade Dockerfiles, layer caching, multi-stage builds, Docker Compose, volumes, networking, and practical workflows for real applications.

Read article

NestJS Error Monitoring with Sentry: Production-Grade Setup Guide

Read article

TypeORM is Killing Your Node Process: Handling Large Datasets Without OOM Crashes

Read article

The Death of Try/Catch: A Better Way to Handle Errors in TypeScript

Read article

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design