3 · Production .env
Objective — assemble a safe production env source (bidirectional drift check against .env.example, a Git-history secret scan, production flags, and a secrets-manager render check) — the rendered .env will live server-side and be symlinked into every release by Deployer, but server upload and runtime verification happen in Phase 5 or Phase 12.
Steps at a glance:
- Audit the current environment — You’re reconciling three things: what
.env.exampledeclares, what the running app actually reads, and what production needs that the example omits. - Bidirectional drift check (.env ↔ .env.example) — Drift goes both ways, and each direction is a different bug:.
- Scan Git history for leaked secrets — Before going to production, confirm no secret was ever committed —
not just that
.envis gitignored now. - Create the production env source — Update the project vault item and
.env.tplreferences, then dry-render to a temporary local file without printing secrets. - Record where RUN-LIVE verification happens — Non-production upload is Phase 5; production upload, perms, caches, and runtime proof are Phase 12.
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 authors the safe source of truth; the selected server receives the rendered file only in the phase that actually deploys to it.
Read the production environment row in Zaj-PROJECT.md before filling placeholders. Update that row with non-secret facts only: canonical host, deploy target, vault item pointer, env template path, APP_URL, and the Phase 5/12 RUN-LIVE owner. Secrets remain in 1Password and the server-side rendered .env.
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 source
Section titled “4. Create the production env source”Update the vault item and .env.tpl, then prove the template can render without uploading anything to a server. Prefer a secrets manager — it keeps the source of truth out of shell history and screenshots.
-
Update the existing
Productioncredential item.Phase 2 Prerequisites §3 already created the
Productionitem and its flat fields. When the production database password exists, edit that field; do not create a separate item or a legacyDatabasesnote.Terminal window PROJECT_VAULT="[PROJECT]"PROJECT_SLUG="[project]"# Legacy/resume fallback only: create the expected item shape if this project predates the Phase 2 seed step.op item get "Production" --vault "$PROJECT_VAULT" >/dev/null 2>&1 \|| op item create --category "Secure Note" --title "Production" --vault "$PROJECT_VAULT" \"DB_CONNECTION[text]=mysql" \"DB_HOST[text]=127.0.0.1" \"DB_PORT[text]=3306" \"DB_DATABASE[text]=${PROJECT_SLUG}_production_db" \"DB_USERNAME[text]=${PROJECT_SLUG}_production_user" \"DB_PASSWORD[password]=REPLACE_ME_PASTE_FROM_PANEL" \"APP_KEY[password]=SET_AT_INSTALL" \"env_file[password]=SET_AT_DEPLOY" >/dev/nullop item edit "Production" --vault "$PROJECT_VAULT" \"DB_PASSWORD[password]=<PASTE_PRODUCTION_DB_PASSWORD>" >/dev/nullop item get "Production" --vault "$PROJECT_VAULT" --fields label=DB_DATABASE# Expected: edit exits 0; verification prints only the non-secret database name- ✅ The
Productionitem exists, uses the same flat field set asLocalandStaging, and itsDB_PASSWORDfield was updated without printing the value.
- ✅ The
-
Render the
.envto a temporary local file, verify by shape only, then clean up.Terminal window TMP_ENV="$(mktemp)"# 1Password CLI renders .env.tpl without printing secret values.op inject -f -i .env.tpl -o "$TMP_ENV"test -s "$TMP_ENV" && wc -c "$TMP_ENV"grep -inE '_HERE|your_|CHANGE_ME|REPLACE|PLACEHOLDER|TODO|xxx|<[A-Z_]+>' "$TMP_ENV" \&& echo "STOP — fix placeholders" || echo "Clean"# Clean the local rendered secret file.shred -u "$TMP_ENV" 2>/dev/null || rm -f "$TMP_ENV"test ! -f "$TMP_ENV"# Expected: local render had a non-zero byte count; placeholder check prints Clean; local temp file is gone- ✅ The env source renders locally, the placeholder scan prints
Clean, and the local rendered temp file is gone. Verify by byte count and exit code only — never print.env.
- ✅ The env source renders locally, the placeholder scan prints
-
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 in the env source; the runtime server file is placed later by Phase 12.
5. Record the RUN-LIVE owners
Section titled “5. Record the RUN-LIVE owners”This page is complete without touching a live server. The actual server file and runtime proof happen only after the target release exists.
| RUN-LIVE work | Owning phase |
|---|---|
Selected non-production shared/.env render/upload, mode 640, placeholder scan, APP_KEY empty | Phase 5 Step 1 · Pre-flight & provision host |
Production shared/.env render/upload, mode 640, placeholder scan | Phase 12 Step 2 · Release & deploy |
| Live permissions, extension proof, cache clear/warm, and Laravel runtime checks | Phase 5 Step 3 for non-production; Phase 12 Step 3 · Harden, verify & sync for production |
Cache only after the final server .env is in place. Cached config freezes env values — if you change .env later, re-run the cache clear/warm sequence in the owning run-live phase.
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). - 🔀 Production credential item updated —
Productionitem exists, uses the flat field set, and required production fields were edited without printing values. - 🔀 History clean — no secret ever committed (or rotated + scrubbed).
- 🔀 Production flags set —
APP_ENV=production,APP_DEBUG=false,APP_URLmatches the canonical host. - 🤖 Render check clean —
.env.tplrenders locally through the secrets manager, placeholder scan printsClean, and the local temp file is removed. - 🤖 RUN-LIVE owners recorded — server upload/runtime checks are assigned to Phase 5 or Phase 12, not left as Phase 4 deferrals.
- 🤖 Project state updated —
Zaj-PROJECT.mdrecords the production host, deploy target, vault item pointer, env template path, non-secret APP_URL, and the Phase 5/12 RUN-LIVE owner.