3 · Production .env
Objective — assemble a safe production .env (bidirectional drift check against .env.example, a Git-history secret scan, production flags and caches, directory permissions, verification) — the file lives server-side and is symlinked into every release by Deployer, and must never enter Git.
Background
Section titled “Background”The production .env is the single most security-sensitive file in the deploy. It lives server-side, is symlinked into every release via Deployer’s shared_files (see 1 · Deployer), and must never enter Git. This page makes it complete, correct, and locked down.
flowchart LR Repo[".env.example<br/>(in git)"] --> Audit[Reconcile keys] Server["shared/.env<br/>(server only)"] --> Symlink[Symlink into each release] Audit --> Server Symlink --> App[Laravel reads env at boot]1. Audit the current environment
Section titled “1. Audit the current environment”You’re reconciling three things: what .env.example declares, what the running app actually reads, and what production needs that the example omits.
-
Inventory the env files and count expected keys.
Terminal window ls -la .env .env.example 2>/dev/nullgrep -c '=' .env.example # how many keys the app expects# Expected: both files listed, plus a count of the keys .env.example declares- ✅ You know which env files exist and how many keys the app expects.
2. Bidirectional drift check (.env ↔ .env.example)
Section titled “2. Bidirectional drift check (.env ↔ .env.example)”Drift goes both ways, and each direction is a different bug:
- Keys in
.envbut missing from.env.example→ new teammates and fresh deploys boot without them. - Keys in
.env.examplebut missing from.env→ the app falls back to defaults (often the wrong service).
-
Diff the two key sets in both directions.
Terminal window # Keys present in .env.example but absent from .envcomm -23 \<(grep -oE '^[A-Z0-9_]+' .env.example | sort -u) \<(grep -oE '^[A-Z0-9_]+' .env | sort -u)# Keys present in .env but absent from .env.examplecomm -13 \<(grep -oE '^[A-Z0-9_]+' .env.example | sort -u) \<(grep -oE '^[A-Z0-9_]+' .env | sort -u)# Expected: ideally no output — any printed key is drift to resolve- ✅ Both directions print no unresolved keys (the two files declare the same key set).
Fix strategy: add missing real values to .env; add any new keys to .env.example with a safe placeholder (never a real secret). The two files should declare the same key set.
3. Scan Git history for leaked secrets
Section titled “3. Scan Git history for leaked secrets”Before going to production, confirm no secret was ever committed — not just that .env is gitignored now.
-
Search history for tracked
.envand leaked secret patterns.Terminal window git log --all --full-history -- .env # was .env ever tracked? (expect empty)git grep -iE 'API_KEY|SECRET|PASSWORD|TOKEN' $(git rev-list --all) -- '*.php' ':!*.env.example' ':!*/.env.example' | head# Expected: no history for .env, and no real secret values in the grep output- ✅
.envwas never tracked and no real secret appears anywhere in history.
- ✅
Provider-specific scan patterns. The generic API_KEY|SECRET|PASSWORD|TOKEN scan misses provider-shaped keys and over-reports. Run these targeted scans across full history:
git grep -lE "AKIA[0-9A-Z]{16}" $(git rev-list --all) 2>/dev/null # AWS access key idgit grep -lE "sk_(live|test)_[A-Za-z0-9]{24,}" $(git rev-list --all) 2>/dev/null \ | grep -v "laravel-best-practices" # Stripe secret; filter Boost anti-examplesgit grep -lE "gh[ops]_[A-Za-z0-9]{36}" $(git rev-list --all) 2>/dev/null # GitHub PATgit grep -lE "SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}" $(git rev-list --all) 2>/dev/null| Secret type | Pattern | Length |
|---|---|---|
| AWS Access Key ID | AKIA[0-9A-Z]{16} | 20 |
| Stripe secret key | sk_(live|test)_[A-Za-z0-9]{24,} | 32+ |
| GitHub PAT | gh[ops]_[A-Za-z0-9]{36} | 40 |
| SendGrid API key | SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43} | 69 |
| Slack webhook | https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]{24} | 77+ |
4. Create the production .env
Section titled “4. Create the production .env”Either edit the server file directly or render it from a secrets manager. Prefer the latter — it keeps the source of truth out of shell history and screenshots.
-
Render or copy the
.env.Terminal window # Option A — 1Password CLI renders a template into a real .envop inject -i .env.tpl -o .env# Option B — copy from example and fill by handcp .env.example .env# Expected: a .env exists on the server populated with real production values- ✅ A production
.envexists on the server with real values filled in.
- ✅ A production
-
Set the production flags.
APP_ENV="production"APP_DEBUG="false" # never true in prod — leaks stack traces + envAPP_URL="https://app.example.com" # must match the canonical host from page 2LOG_LEVEL="error"SESSION_SECURE_COOKIE="true" # HTTPS-only cookiesDEBUGBAR_ENABLED="false" # if laravel-debugbar is installed- ✅ Production flags are set; run
php artisan key:generateonly ifAPP_KEYis empty (regenerating it invalidates all existing encrypted data and sessions).
- ✅ Production flags are set; run
5. Permissions, extensions, and production caches
Section titled “5. Permissions, extensions, and production caches”Lock down directory ownership, confirm the required PHP extensions, then warm the caches.
-
Set permissions and check extensions.
Terminal window # Directory permissions (writable by the web user only)chmod -R 775 storage bootstrap/cachechown -R www-data:www-data storage bootstrap/cache# Required PHP extensions present?php -m | grep -iE 'bcmath|ctype|curl|fileinfo|json|mbstring|openssl|pdo|tokenizer|xml'# Expected: storage + bootstrap/cache owned by the web user; every listed extension prints- ✅
storageandbootstrap/cacheare writable by the web user and all required extensions are present.
- ✅
-
Warm the production caches (on the server, after
.envis in place).Terminal window php artisan config:cachephp artisan route:cachephp artisan view:cachephp artisan event:cache# Expected: each artisan command reports its cache file was written- ✅ Config, route, view, and event caches are warmed.
6. Verify
Section titled “6. Verify”Confirm the app actually reads the production settings you intended.
-
Inspect the live environment.
Terminal window php artisan about --no-ansi | grep -iE 'environment|debug|cache'php artisan tinker --execute="dump(config('app.debug'));"# Expected: environment "production", app.debug false, quoted values, caches reported by "about"# Laravel 11+: prefer `about` / tinker — `php artisan env` and `config:show` are not on every skeleton.- ✅
artisan aboutreportsproduction,app.debugisfalse, and values are quoted.
- ✅
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🤖 Drift resolved —
.env↔.env.exampleresolved in both directions (same key set). - 🔀 History clean — no secret ever committed (or rotated + scrubbed).
- 🔀 Production flags set —
APP_ENV=production,APP_DEBUG=false,APP_URLmatches the canonical host. - 🤖 Quoted + writable — all values double-quoted;
storage/+bootstrap/cachewritable by the web user. - 🤖 Caches warmed — production config/route/view/event caches warmed after
.envfinalized.