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.