Skip to content
Security Intermediate Tutorial

Stop Hardcoding Secrets: Environment Variables Done Right

Move credentials out of your source code with .env hygiene, per-environment config, and a secrets manager — using patterns that work in any language.

AI
DevClubHouse Curation
Jun 8, 2026 · 9 min read · 0 comments

What you'll build / learn

By the end of this tutorial you'll have a clean, repeatable pattern for managing secrets: a .env workflow for local development, airtight .gitignore hygiene, a strategy for per-environment configuration, and a path to a real secrets manager (AWS Secrets Manager) for production. The patterns are language-agnostic, with concrete examples in Node.js and Python.

Prerequisites

  • A Unix-like shell (macOS, Linux, or WSL2 on Windows). Commands use Bash/Zsh syntax.
  • Git 2.30+ (git --version).
  • One runtime to follow the examples:
    • Node.js 20.6+ (for the built-in --env-file flag), or
    • Python 3.9+ with pip.
  • For the secrets-manager section: an AWS account and the AWS CLI v2 installed and configured (aws --version, aws configure). The principles transfer to HashiCorp Vault, Google Secret Manager, Azure Key Vault, or Doppler.
  • Basic comfort with the terminal and a project under Git.

Why this matters: secrets committed to Git live forever in history. GitHub's own data shows millions of leaked credentials are detected every year. The fix is process, not heroics.

Step 1: Understand the layers

Think in three tiers, from least to most secure:

Layer Use case Secret lives in
.env file Local dev only A gitignored file on your machine
Platform env vars CI/CD, PaaS (Heroku, Vercel, Fly) The platform's encrypted config store
Secrets manager Production, rotation, audit AWS/Vault/GCP, fetched at runtime

The golden rule: secrets enter your app through environment variables at runtime; they are never written to source code or committed to Git.

Step 2: Create a .env file and a committed template

In your project root, create .env for real local values:

cat > .env <<'EOF'
DATABASE_URL=postgres://localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk_test_replace_me
JWT_SIGNING_SECRET=dev-only-not-for-prod
EOF

Now create a committed template so teammates know which variables exist — without exposing values:

cat > .env.example <<'EOF'
DATABASE_URL=
STRIPE_SECRET_KEY=
JWT_SIGNING_SECRET=
EOF

.env.example is checked in; .env is not. When someone clones the repo they run cp .env.example .env and fill in their own values.

Step 3: Lock down .gitignore

Add these lines to .gitignore:

# Secrets — never commit
.env
.env.*
!.env.example

The !.env.example negation re-includes the template while ignoring .env, .env.local, .env.production, etc.

Verify Git is honoring the rule:

git check-ignore -v .env

Expected output (the path means it's correctly ignored):

.gitignore:2:.env	.env

If .env was already committed before you added the rule, Git keeps tracking it. Remove it from the index (this keeps the file on disk):

git rm --cached .env
git commit -m "Stop tracking .env"

If a secret was ever pushed, removing it from the current commit is not enough — it remains in history. Rotate the secret immediately, then scrub history with git filter-repo or the BFG Repo-Cleaner. Treat any leaked credential as compromised.

Step 4: Load the .env in your app

Node.js (20.6+, no dependencies)

Modern Node loads env files natively:

node --env-file=.env app.js
// app.js
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
  throw new Error('DATABASE_URL is not set');
}
console.log('Connecting to:', dbUrl);

On Node 22.7+ you can also use --env-file-if-exists=.env to avoid errors when the file is absent. For older Node versions, use the dotenv package instead:

npm install dotenv
require('dotenv').config();
console.log(process.env.DATABASE_URL);

Python (3.9+)

Use python-dotenv:

pip install python-dotenv
# app.py
import os
from dotenv import load_dotenv

load_dotenv()  # reads .env in the current/parent directory

db_url = os.environ.get("DATABASE_URL")
if not db_url:
    raise RuntimeError("DATABASE_URL is not set")
print("Connecting to:", db_url)

Key principle: read config from process.env / os.environ, never from the .env file directly. In production there is no .env file — the variables come from the platform or secrets manager, and your code doesn't care which.

Step 5: Per-environment configuration

Don't sprinkle if (env === 'production') throughout your code. Instead, select which variables get loaded by environment. A clean convention:

File Committed? Loaded when
.env No Local default
.env.local No Local overrides (highest local priority)
.env.example Yes Documentation only

In CI and production you don't ship .env files at all. Instead, set real environment variables in the platform:

# Example: GitHub Actions (set in repo Settings > Secrets and variables > Actions)
# Reference them in a workflow:
# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run with secret
        env:
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
        run: node app.js

GitHub injects the secret as an environment variable only for that step, and masks it in logs. Same idea on Vercel (vercel env), Fly.io (fly secrets set), or Heroku (heroku config:set).

Step 6: Graduate to a secrets manager

For production, a secrets manager gives you encryption at rest, fine-grained IAM access, audit logs, and rotation. Here's the pattern with AWS Secrets Manager.

Create a secret:

aws secretsmanager create-secret \
  --name myapp/prod/stripe \
  --secret-string '{"STRIPE_SECRET_KEY":"sk_live_xxx"}'

Fetch it at runtime instead of baking it into an image. In Node.js:

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

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

export async function loadSecrets() {
  const res = await client.send(
    new GetSecretValueCommand({ SecretId: 'myapp/prod/stripe' })
  );
  const parsed = JSON.parse(res.SecretString);
  // Promote into process.env so the rest of the app stays env-var based
  Object.assign(process.env, parsed);
}

Call await loadSecrets() once during startup, before you read the values. The AWS SDK authenticates via the host's IAM role (EC2 instance profile, ECS task role, or Lambda execution role) — no static AWS keys in your app.

Grant least-privilege access via an IAM policy attached to that role:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:myapp/prod/*"
    }
  ]
}

The wildcard scopes access to your app's secrets only. The same fetch-at-startup pattern works with Vault (vault kv get), Google Secret Manager, or Doppler — only the client library changes.

Verify it works

  1. .env is ignored: git status --short shows no .env; git check-ignore .env prints .env.
  2. App reads env vars: Run node --env-file=.env app.js (or python app.py) and confirm it prints your value.
  3. Missing var fails loudly: Temporarily rename .env and rerun — you should get the explicit DATABASE_URL is not set error, proving you validate config at startup.
  4. No secrets in history: git log -p -- .env returns nothing (or only its removal commit).

Troubleshooting

.env still shows up in git status after editing .gitignore. It was already tracked. Run git rm --cached .env, commit, and confirm with git check-ignore -v .env.

Node throws --env-file is not allowed. You're on Node < 20.6. Either upgrade (nvm install 20) or use the dotenv package as shown in Step 4.

Environment variable is undefined at runtime. Three common causes: (1) the .env file isn't in the working directory — load_dotenv() and --env-file look relative to where you invoke the process; (2) you read the value before load_dotenv() ran; (3) a typo or stray space around = in the file. Print process.env/os.environ to confirm what actually loaded.

AWS SDK throws AccessDeniedException on GetSecretValue. The IAM role lacks permission or the ARN/region is wrong. Confirm the attached policy matches the secret's ARN and that the SDK region equals where the secret was created.

Next steps

  • Add schema validation for config at boot — Zod (TS) or Pydantic Settings (Python) — so missing or malformed env vars crash on startup, not mid-request.
  • Enable automatic rotation in AWS Secrets Manager (or Vault dynamic secrets) so credentials are short-lived.
  • Add a pre-commit hook with Gitleaks or TruffleHog to block secret commits before they happen.
  • Read the Twelve-Factor App config chapter — the foundation for everything above.

Discussion 0

Join the discussion

Sign in with GitHub to comment and vote.

Sign in with GitHub

No comments yet

Be the first to weigh in.

Related Reading