Skip to content
prod e051e98
Browse

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.

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]

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+

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.

  1. Render or copy the .env.

    Terminal window
    # Option A — 1Password CLI renders a template into a real .env
    op inject -i .env.tpl -o .env
    # Option B — copy from example and fill by hand
    cp .env.example .env
    # Expected: a .env exists on the server populated with real production values
    • ✅ A production .env exists on the server with real values filled in.
  2. 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; run php artisan key:generate only if APP_KEY is empty (regenerating it invalidates all existing encrypted data and sessions).

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.

  1. Set permissions and check extensions.

    Terminal window
    # Directory permissions (writable by the web user only)
    chmod -R 775 storage bootstrap/cache
    chown -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
    • storage and bootstrap/cache are writable by the web user and all required extensions are present.
  2. Warm the production caches (on the server, after .env is in place).

    Terminal window
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
    # Expected: each artisan command reports its cache file was written
    • ✅ Config, route, view, and event caches are warmed.

Confirm the app actually reads the production settings you intended.

  1. 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 about reports production, app.debug is false, and values are quoted.

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

  • 🤖 Drift resolved.env.env.example resolved in both directions (same key set).
  • 🔀 History clean — no secret ever committed (or rotated + scrubbed).
  • 🔀 Production flags setAPP_ENV=production, APP_DEBUG=false, APP_URL matches the canonical host.
  • 🤖 Quoted + writable — all values double-quoted; storage/ + bootstrap/cache writable by the web user.
  • 🤖 Caches warmed — production config/route/view/event caches warmed after .env finalized.