Skip to content
prod 352bb92
Browse

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:

  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.
  2. Bidirectional drift check (.env ↔ .env.example) — Drift goes both ways, and each direction is a different bug:.
  3. Scan Git history for leaked secrets — Before going to production, confirm no secret was ever committed — not just that .env is gitignored now.
  4. Create the production env source — Update the project vault item and .env.tpl references, then dry-render to a temporary local file without printing secrets.
  5. Record where RUN-LIVE verification happens — Non-production upload is Phase 5; production upload, perms, caches, and runtime proof are Phase 12.

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]

You’re reconciling three things: what .env.example declares, what the running app actually reads, and what production needs that the example omits.

  1. Inventory the env files and count expected keys.

    Terminal window
    ls -la .env .env.example 2>/dev/null
    grep -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 .env but missing from .env.example → new teammates and fresh deploys boot without them.
  • Keys in .env.example but missing from .env → the app falls back to defaults (often the wrong service).
  1. Diff the two key sets in both directions.

    Terminal window
    # Keys present in .env.example but absent from .env
    comm -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.example
    comm -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.

Before going to production, confirm no secret was ever committed — not just that .env is gitignored now.

  1. Search history for tracked .env and 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
    • .env was 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:

Terminal window
git grep -lE "AKIA[0-9A-Z]{16}" $(git rev-list --all) 2>/dev/null # AWS access key id
git 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-examples
git grep -lE "gh[ops]_[A-Za-z0-9]{36}" $(git rev-list --all) 2>/dev/null # GitHub PAT
git grep -lE "SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}" $(git rev-list --all) 2>/dev/null
Secret typePatternLength
AWS Access Key IDAKIA[0-9A-Z]{16}20
Stripe secret keysk_(live|test)_[A-Za-z0-9]{24,}32+
GitHub PATgh[ops]_[A-Za-z0-9]{36}40
SendGrid API keySG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}69
Slack webhookhttps://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]{24}77+

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.

  1. Update the existing Production credential item.

    Phase 2 Prerequisites §3 already created the Production item and its flat fields. When the production database password exists, edit that field; do not create a separate item or a legacy Databases note.

    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/null
    op item edit "Production" --vault "$PROJECT_VAULT" \
    "DB_PASSWORD[password]=<PASTE_PRODUCTION_DB_PASSWORD>" >/dev/null
    op item get "Production" --vault "$PROJECT_VAULT" --fields label=DB_DATABASE
    # Expected: edit exits 0; verification prints only the non-secret database name
    • ✅ The Production item exists, uses the same flat field set as Local and Staging, and its DB_PASSWORD field was updated without printing the value.
  2. Render the .env to 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.
  3. Set the production flags.

    APP_ENV="production"
    APP_DEBUG="false" # never true in prod — leaks stack traces + env
    APP_URL="https://app.example.com" # must match the canonical host from page 2
    LOG_LEVEL="error"
    SESSION_SECURE_COOKIE="true" # HTTPS-only cookies
    DEBUGBAR_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.

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 workOwning phase
Selected non-production shared/.env render/upload, mode 640, placeholder scan, APP_KEY emptyPhase 5 Step 1 · Pre-flight & provision host
Production shared/.env render/upload, mode 640, placeholder scanPhase 12 Step 2 · Release & deploy
Live permissions, extension proof, cache clear/warm, and Laravel runtime checksPhase 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.

Do not mark this step done until every box below is checked.

  • 🤖 Drift resolved.env.env.example resolved in both directions (same key set).
  • 🔀 Production credential item updatedProduction item 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 setAPP_ENV=production, APP_DEBUG=false, APP_URL matches the canonical host.
  • 🤖 Render check clean.env.tpl renders locally through the secrets manager, placeholder scan prints Clean, 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 updatedZaj-PROJECT.md records the production host, deploy target, vault item pointer, env template path, non-secret APP_URL, and the Phase 5/12 RUN-LIVE owner.