Environment Variables & Secrets Management for Web Apps
Every web application breach that starts with "API keys found in public GitHub repo" is a story about bad secrets hygiene. This article covers how to handle API keys, database passwords, and other sensitive values safely — using environment variables, .env files, and the right git discipline — for both PHP and Node.js stacks.
Why this matters
The 12-factor app methodology puts config (anything that varies between deploys) into environment variables. In practice this means a single idea: your codebase should run the same way on your laptop, on staging, and on production — only the environment changes.
The alternative — hardcoding credentials in source — causes the specific failure mode of public-repo leaks. Bots scan GitHub constantly for strings that look like AWS keys, Stripe secrets, Razorpay keys. A leaked credential is abused within minutes.
Environment variables solve this cleanly.
The .env file pattern
A .env file is a plain-text file at your project root containing KEY=VALUE lines:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://yourdomain.com
DB_HOST=localhost
DB_NAME=myapp_production
DB_USER=myapp
DB_PASSWORD=pl3A5e-ch4nGe-m3
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...Rule #1 — never commit this file. First line of every .gitignore:
.env
.env.local
.env.productionRule #2 — commit a `.env.example` instead. Same keys, but safe placeholder values:
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost
DB_HOST=localhost
DB_NAME=myapp_local
DB_USER=changeme
DB_PASSWORD=changeme
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=When a new developer joins, they copy .env.example to .env and fill in their local values.
PHP — loading env vars
The standard library is vlucas/phpdotenv:
composer require vlucas/phpdotenvAt the top of your app bootstrap:
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
// Access values:
$dbPassword = $_ENV['DB_PASSWORD'];
$stripeKey = $_ENV['STRIPE_SECRET_KEY'];Use $_ENV (not getenv()) — it is faster and thread-safe.
Laravel includes phpdotenv automatically. Access via env('DB_PASSWORD') at config-load time, or via config('database.connections.mysql.password') at runtime. When config:cache has been run, calls to env() outside config files return null — always wrap env-var reads in config/*.php files.
Node.js — loading env vars
npm install dotenvFirst line of your entry file:
require('dotenv').config();
// or ES modules:
import 'dotenv/config';
// Access:
const dbPassword = process.env.DB_PASSWORD;
const stripeKey = process.env.STRIPE_SECRET_KEY;For framework-specific handling: Next.js reads .env.local, .env.development, .env.production automatically — no dotenv needed.
Add basic validation on startup so missing vars fail fast:
const required = ['DB_PASSWORD', 'STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'];
const missing = required.filter(k => !process.env[k]);
if (missing.length) {
console.error(`Missing required env vars: ${missing.join(', ')}`);
process.exit(1);
}Production secrets — beyond .env
On a live server, you have three places to put your production env vars:
1. A .env file on the server (simplest)
The file lives at /home/youruser/app/.env, owned by your app user, chmod 600 (readable only by owner). It was never committed to git — you uploaded it once manually or via a deploy script that reads from a secret store.
2. Environment variables at the process level
On cPanel: Environment Variables section (if your plan exposes it).
On a VPS with systemd:
# /etc/systemd/system/myapp.service
[Service]
Environment=DB_PASSWORD=xxx
Environment=STRIPE_SECRET_KEY=sk_live_...
# OR reference an env file:
EnvironmentFile=/home/myapp/.env3. A dedicated secret store (for teams)
For larger teams: HashiCorp Vault, AWS Secrets Manager, Doppler. Overkill for a one-dev shop, sensible once you have production secrets rotating across multiple environments.
Deploy-time secrets (CI/CD)
If you use GitHub Actions or similar:
- Never put secrets in the workflow YAML.
- Add them under repo → Settings → Secrets and variables → Actions.
- Reference as
${{ secrets.STRIPE_SECRET_KEY }}in the workflow.
Example:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
run: ./deploy.shSecrets in GitHub are encrypted at rest and masked in logs.
What belongs in env vars vs config files
| Kind | Where | Example |
|---|---|---|
| Secrets (credentials, API keys, tokens) | env var | STRIPE_SECRET_KEY |
| Feature flags | env var or dedicated flag service | FEATURE_NEW_CHECKOUT=true |
| Per-environment URLs | env var | APP_URL, DATABASE_URL |
| Static UI copy | config file (committed) | sidebar.menu.title |
| Non-sensitive constants | config file | PAGE_SIZE=25 |
Rule of thumb: if changing the value requires a security review, it is a secret.
Rotating secrets
Secrets get old. Rotate them:
- Database passwords — every 90 days for sensitive apps
- API keys (Stripe, Razorpay, SendGrid) — on employee departure, on any suspicion of leak, annually as hygiene
- Session secret / JWT signing key — on any breach; understand this will log out all users
Zero-downtime rotation procedure:
- Generate the new secret in the provider's dashboard.
- Deploy the new value to production (staging first if you can).
- Verify the app is working with the new value.
- Revoke the old value in the provider's dashboard.
Never revoke before deploying.
Detecting committed secrets
Tools that help:
- git-secrets — pre-commit hook that blocks commits containing common secret patterns.
- GitHub secret scanning — free, always on for public repos, alerts the repo owner.
- trufflehog — scans full git history for secrets (useful when auditing a repo).
Install git-secrets on your dev machine:
brew install git-secrets # macOS
# Linux: clone from https://github.com/awslabs/git-secrets
git secrets --install
git secrets --register-awsWhat to do if you committed a secret by mistake
Order of operations (critical):
- Revoke the secret first. In Stripe / Razorpay / AWS console, rotate the key immediately. Do this BEFORE rewriting git history — the damage is live exposure, not the git record.
- Assume breach. Review your provider's logs for unexpected usage since the commit was pushed.
- Rotate and deploy the new secret.
- (Optional) Rewrite git history to remove the secret from the repo —
git filter-repoorbfg-repo-cleaner. Note: this does not help if the repo was ever public, because anyone who cloned already has the bad value. - Tell anyone who had access to the repo that the old secret is invalidated.
Frequently asked questions
Is `.env` encrypted?
No. .env is plain text. The security model is file permissions (chmod 600) and keeping it out of git. For encrypted secret-at-rest, use a dedicated secret store.
Can I `export` env vars in `~/.bashrc` instead of using `.env`?
You can, but it makes onboarding and scripted deploys harder. Prefer .env or a managed secret store — they travel with the project.
What about Docker?
Pass env vars via --env-file to docker run, or under environment: / env_file: in docker-compose.yml. Same principle — never bake secrets into the image layers.
How do I share secrets with teammates without emailing them?
Use a password manager (1Password, Bitwarden) with shared vaults. Or a secret manager like Doppler. Never paste into Slack / email.
Is it safe to log env vars for debugging?
Only if the var is non-sensitive. Never console.log(process.env) — logs often get shipped to monitoring systems, grep-able by more people than you think.
Should I commit `.env.production`?
No. Only .env.example. Production values belong on the production server or in the CI secret store, never in git.
Got questions? [email protected]. We help hosting customers audit env-var setups on request.