Skip to content
prod 352bb92
Browse

3 · First release

Objective — ship the app’s first atomic release and prove it booted correctly: force first-deploy-safe cache/session/queue drivers when the vendor defaults to database-backed drivers, confirm APP_KEY, clear caches with the versioned PHP binary, verify the Deployer tree and the .env symlink (a 5-second check that prevents a 90-minute outage), confirm seeded storage assets survived shared_dirs, then loosen permissions for the installer.

Steps at a glance: 0. Set first-deploy-safe runtime drivers — Some vendors default cache/session/queue to database tables the installer has not created yet. Use file/sync drivers until the installer completes.

  1. Dry-run, then deploy — Preview the pipeline one last time, then run the atomic release. The symlink flips only on a passing smoke test.
  2. Confirm APP_KEY and clear caches — Confirm the health check generated APP_KEY, remove any stale install lock, then clear caches with the versioned PHP binary.
  3. Verify the Deployer directory structure — Confirm Deployer laid out the atomic release tree as expected.
  4. Verify the .env symlinkshared_files controls which files Deployer symlinks from shared/ into each release. If someone used set('shared_files', []) instead of add('shared_files', [...]), the recipe’s default ['.env'] is wiped — and every release boots from .env.example or a stale vendor .env, silently loading the wrong.
  5. Verify demo/default content deployed — CodeCanyon apps ship demo content (images, avatars, themes, sample data) in app-specific locations.
  6. Loosen permissions for the installer — The web installer requires writable paths. This is a deliberate, temporary loosening — it gets hardened on page 4.

The payoff of Phase 4 — the app’s first atomic release. Deployer flips the current symlink only after the smoke test passes, so a failed build never touches the live release.

Reuse the environment variables from page 1, or reload them from Zaj-PROJECT.md before running commands:

Terminal window
ENV_KEY="staging-primary" # example; use the selected Zaj-PROJECT.md row
DEPLOY_TARGET="<selected-nonproduction-deploy-target>" # Deployer target from that row
SSH_ALIAS="<non-prod-alias>"
DEPLOY_DOMAIN="nonprod.example.com"
DEPLOY_PATH="~/domains/$DEPLOY_DOMAIN/deploy"
# Expected: values match the selected non-production row

Some CodeCanyon vendors ship .env defaults like CACHE_STORE=database, SESSION_DRIVER=database, or QUEUE_CONNECTION=database. That is valid after the installer creates cache, sessions, and jobs tables. It is unsafe on the first deploy because Deployer runs cache/artisan checks before the installer builds those tables.

  1. Force file/sync drivers in the selected server .env before the first deploy.

    Terminal window
    ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/shared && \
    cp .env .env.before-first-deploy-drivers.$(date +%Y%m%d%H%M%S) && \
    grep -q '^CACHE_STORE=' .env && sed -i 's/^CACHE_STORE=.*/CACHE_STORE=file/' .env || printf '\nCACHE_STORE=file\n' >> .env; \
    grep -q '^CACHE_DRIVER=' .env && sed -i 's/^CACHE_DRIVER=.*/CACHE_DRIVER=file/' .env || printf '\nCACHE_DRIVER=file\n' >> .env; \
    grep -q '^SESSION_DRIVER=' .env && sed -i 's/^SESSION_DRIVER=.*/SESSION_DRIVER=file/' .env || printf '\nSESSION_DRIVER=file\n' >> .env; \
    grep -q '^QUEUE_CONNECTION=' .env && sed -i 's/^QUEUE_CONNECTION=.*/QUEUE_CONNECTION=sync/' .env || printf '\nQUEUE_CONNECTION=sync\n' >> .env; \
    grep -E '^(CACHE_STORE|CACHE_DRIVER|SESSION_DRIVER|QUEUE_CONNECTION)=' .env"
    # Expected: CACHE_STORE=file, CACHE_DRIVER=file, SESSION_DRIVER=file, QUEUE_CONNECTION=sync
    • ✅ The first deploy cannot fail because cache/session/queue database tables do not exist yet.

Preview the pipeline one last time, then run the atomic release. The symlink flips only on a passing smoke test.

  1. Preview, then run the atomic deploy.

    Terminal window
    dep deploy "$DEPLOY_TARGET" --plan # final review of the task pipeline (see page 1)
    dep deploy "$DEPLOY_TARGET" # atomic release; symlink flips only on success
    # Expected: "Successfully deployed!" — symlink flips only after the smoke test passes
    • Successfully deployed!; current now points at the new release.

The smoke test gates the symlink flip — a failed build never touches the live release:

flowchart LR
P["--plan<br/>(preview)"] --> B["build release"]
B --> S["smoke test"]
S -->|pass| F["flip current ✅"]
S -->|fail| K["abort — live release untouched"]
style F fill:#0b3,stroke:#062,color:#fff
style K fill:#a40,stroke:#600,color:#fff

First-deploy behavior (Release 1): migrations are skipped automatically — the web installer owns the initial schema — and APP_KEY is auto-generated if missing. Expect Successfully deployed!.

If the first-release smoke test returns an unfamiliar 500, start with HTTP error triage: tail laravel.log, classify the first fragment, then fix the matching layer instead of redeploying blind.

At this stepFirst responseLikely next move
500 after symlink flipTail storage/logs/laravel.logUse the 500 classification table
Empty Laravel logCheck current/storage/logs/ and shared/storage/logs/Confirm logging target or Sentry-only routing

Confirm the health check generated APP_KEY, remove any stale install lock, then clear caches with the versioned PHP binary.

  1. Confirm APP_KEY is present.

    Terminal window
    ssh "$SSH_ALIAS" "grep APP_KEY $DEPLOY_PATH/shared/.env | head -1"
    # Expected: APP_KEY=base64:... (44+ chars)
    • APP_KEY=base64:... is set with 44+ characters.
  2. Generate APP_KEY once if it’s empty (common after a failed first deploy where the health check never ran).

    Terminal window
    ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && php artisan key:generate --force"
    # Expected: "Application key set successfully."
    • ✅ A key is set if one was missing.
  3. Remove any stale install lock, then clear caches with the versioned PHP binary.

    Terminal window
    ssh "$SSH_ALIAS" "rm -f $DEPLOY_PATH/shared/storage/installed 2>/dev/null"
    # Use the absolute versioned binary from deploy.php — NOT bare `php`.
    PHP_BIN=$(grep "set('bin/php'" deploy.php | grep -oE "/[^'\"]+/php[^'\"]*" | head -1)
    PHP_BIN="${PHP_BIN:-php}"
    ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && \
    $PHP_BIN artisan optimize:clear"
    # Expected: each cache layer reports "cleared"
    • ✅ Stale lock removed; optimize:clear flushes every cache layer.

3. Verify the Deployer directory structure

Section titled “3. Verify the Deployer directory structure”

Confirm Deployer laid out the atomic release tree as expected.

  1. List the deploy tree.

    Terminal window
    ssh "$SSH_ALIAS" "ls -la $DEPLOY_PATH/"
    # Expected: releases/, shared/, current -> releases/1, and .dep/
    • ✅ The tree shows releases/, shared/, current -> releases/1, and .dep/.

The expected shape:

deploy/
├── releases/
│ └── 1/ ← current release
├── shared/
│ ├── .env ← persistent environment
│ ├── storage/ ← persistent files (logs, uploads)
│ └── .user.ini ← PHP settings
├── current -> releases/1 ← symlink to latest release
└── .dep/ ← Deployer metadata

shared_files controls which files Deployer symlinks from shared/ into each release. If someone used set('shared_files', []) instead of add('shared_files', [...]), the recipe’s default ['.env'] is wiped — and every release boots from .env.example or a stale vendor .env, silently loading the wrong database, cache driver, or keys. This exact bug cost a 90-minute debugging session. The check below catches it in seconds.

  1. Confirm current/.env is a symlink into shared/.env, and that Laravel reads your values.

    Terminal window
    # 1. Must be a symbolic link
    ssh "$SSH_ALIAS" "stat -c '%F' $DEPLOY_PATH/current/.env"
    # expected: symbolic link (regular file = shared_files broken; missing = app will crash)
    # 2. Target must resolve into shared/.env
    ssh "$SSH_ALIAS" "readlink -f $DEPLOY_PATH/current/.env"
    # expected: .../deploy/shared/.env
    # 3. Laravel must actually read your values (not the vendor fallback)
    PHP_BIN=$(grep "set('bin/php'" deploy.php | grep -oE "/[^'\"]+/php[^'\"]*" | head -1)
    PHP_BIN="${PHP_BIN:-php}"
    ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && \
    $PHP_BIN artisan tinker --execute=\"
    echo 'cache.default = '.config('cache.default').PHP_EOL;
    echo 'session.driver = '.config('session.driver').PHP_EOL;
    echo 'queue.default = '.config('queue.default').PHP_EOL;
    \""
    # Expected: "symbolic link", a path ending in shared/.env, and YOUR cache/session/queue values
    • current/.env is a symbolic link resolving to shared/.env, and Laravel resolves your real cache/session/queue values.

If cache.default resolves to something other than what shared/.env sets, suspect a variable-name mismatch for your Laravel version:

Laravel versionconfig/cache.php reads.env must set
Laravel 11+env('CACHE_STORE', …)CACHE_STORE=file
Laravel 10 and earlierenv('CACHE_DRIVER', …)CACHE_DRIVER=file

Setting CACHE_STORE in a Laravel 10 .env is silently ignored — the config reads CACHE_DRIVER, gets nothing, and falls through to the template’s hardcoded default (often redis).

5. Verify seeded storage/default content survived shared storage

Section titled “5. Verify seeded storage/default content survived shared storage”

CodeCanyon apps ship demo content (images, avatars, themes, sample data) in app-specific locations. The dangerous case is storage/app/public/*: Deployer replaces release storage/ with shared storage/, so vendor-shipped logos can disappear unless shared:init_defaults copies them into shared storage before deploy:shared runs.

  1. Confirm the Deployer defaults hook exists and runs before shared symlinks.

    Terminal window
    grep -n "task('shared:init_defaults'" deploy.php
    grep -n "before('deploy:shared', 'shared:init_defaults')" deploy.php
    # Expected: the task exists and runs before deploy:shared
    • ✅ The first deploy has a hook that can copy release defaults before storage/ is shadowed.
  2. Discover seeded content dirs locally and confirm the deploy plan preserves them.

    Terminal window
    # Discover content dirs locally
    for dir in uploads public/uploads public/images public/avatars public/themes \
    storage/app/public public/media public/assets/images; do
    [ -d "$dir" ] && echo " $dir — $(find "$dir" -type f 2>/dev/null | wc -l | tr -d ' ') files"
    done
    grep -A10 "shared_dirs" deploy.php | grep "'"
    # Expected: storage/app/public is handled by shared:init_defaults; other content dirs appear in shared_dirs
    • storage/app/public is seeded by shared:init_defaults; every other content directory with demo files is listed in shared_dirs.
  3. Verify at least one seeded storage media file exists in shared storage and serves over HTTP.

    Terminal window
    SEEDED_MEDIA=$(find storage/app/public -type f 2>/dev/null | sed 's#^storage/app/public/##' | head -1)
    test -n "$SEEDED_MEDIA" || echo "No local storage/app/public seed files found — skip media URL proof"
    if [ -n "$SEEDED_MEDIA" ]; then
    ssh "$SSH_ALIAS" "test -f '$DEPLOY_PATH/shared/storage/app/public/$SEEDED_MEDIA' && echo 'shared media present'"
    curl -s -o /dev/null -w "%{http_code}\n" "https://$DEPLOY_DOMAIN/storage/$SEEDED_MEDIA"
    fi
    # Expected: "shared media present" and HTTP 200 for the media URL
    • ✅ A vendor-seeded storage media file exists in shared storage and returns 200.

Every content directory with demo files must be preserved by one of two paths: storage/app/public is copied into shared storage by shared:init_defaults before deploy:shared, and other durable content dirs should be listed in shared_dirs. If seeded storage media is missing after the first deploy, copy it from the local vendor snapshot into shared storage and re-run the URL proof:

Terminal window
rsync -az storage/app/public/ "$SSH_ALIAS:$DEPLOY_PATH/shared/storage/app/public/"
# Expected: seeded storage/app/public files copied into shared storage
Section titled “5.5. Verify (and relink) the public asset symlinks”

public/storage (and, for in-tree module apps, public/packages) are per-environment symlinks — they point at a path that only makes sense on the machine that created them. Two server-side traps:

  • The vendor often ships public/storage tracked in Git as an absolute symlink to a dead path (/var/www/..., /home/<other-user>/...). On your server that target doesn’t exist, so every /storage/* URL (logos, avatars, uploaded media) returns 403/404 until it’s relinked.
  • WorkDo-style apps reference in-tree module assets as /packages/<vendor>/<Module>/... URLs, but public/packages doesn’t exist out of the box → all module images 404 and the landing page looks broken. (These per-env symlinks should already be gitignored from Phase 2 — see Initialize the repository §2. If a tracked public/storage symlink still rode along in the release, the relink below replaces it.)
  1. Inspect the server symlinks, then recreate them with the versioned PHP binary.

    Terminal window
    PHP_BIN=$(grep "set('bin/php'" deploy.php | grep -oE "/[^'\"]+/php[^'\"]*" | head -1)
    PHP_BIN="${PHP_BIN:-php}"
    REL="$DEPLOY_PATH/current"
    # What does public/storage currently resolve to? (a dead /var/www/... target = the trap)
    ssh "$SSH_ALIAS" "readlink -f $REL/public/storage 2>/dev/null; \
    [ -e $REL/public/storage ] && echo ' target exists' || echo ' ❌ target MISSING — relink below'"
    # Recreate the storage symlink fresh (rm the stale/dead one first; storage:link won't overwrite)
    ssh "$SSH_ALIAS" "cd $REL && rm -f public/storage && $PHP_BIN artisan storage:link && \
    readlink -f public/storage"
    # Expected: public/storage → .../shared/storage/app/public (an existing path on THIS server)
    • public/storage resolves to a path that exists on the server (not a dead /var/www/...).
  2. If the app serves in-tree module assets (e.g. WorkDo packages/), add the public/packages link too.

    Terminal window
    ssh "$SSH_ALIAS" "cd $REL && [ -d packages ] && [ ! -e public/packages ] && \
    ln -s ../packages public/packages && echo ' ✅ public/packages linked' || echo ' (no packages/ dir, or already linked)'"
    # Expected: public/packages exists only when packages/ is shipped
    • public/packages exists when the app ships in-tree module assets; otherwise test ! -d packages proves N/A — no in-tree package assets on this release.

The web installer requires writable paths. This is a deliberate, temporary loosening — it gets hardened on page 4.

  1. Loosen storage and cache permissions for the installer.

    Terminal window
    ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && \
    chmod -R 777 storage bootstrap/cache resources/lang uploads 2>/dev/null; \
    chmod -R 777 $DEPLOY_PATH/shared/storage 2>/dev/null && echo 'Permissions set to 777'"
    # Expected: "Permissions set to 777"
    • Permissions set to 777 — the installer’s writable paths are ready.

Troubleshooting — first-release failures

Section titled “Troubleshooting — first-release failures”
  • Connection refused 127.0.0.1:6379 on the first artisan task — the release is loading .env from a fallback, not shared/.env. This is the symlink BLOCKER above. Switch set('shared_files', …)add('shared_files', …), or untrack a committed .env (git rm --cached .env), then redeploy.
  • SQLSTATE[42S02]: Table '…cache' doesn't exist before the installer — the vendor defaults cache/session/queue to database drivers before the installer creates those tables. Re-run section 0 and set CACHE_STORE=file, CACHE_DRIVER=file, SESSION_DRIVER=file, QUEUE_CONNECTION=sync, then redeploy.
  • Too many levels of symbolic links during deploy:shared — a file is listed in shared_files and lives inside a shared_dirs directory (e.g. storage/.ignore_locales with shared_dirs: ['storage']). A file may be in one or the other, never both. Remove the shared_files entry — shared_dirs already persists it. Fails before the symlink switch, so no downtime.
  • Class not found (Faker / Debugbar / Telescope) — production code references a dev-only package excluded by composer install --no-dev. Guard with class_exists() or move the logic into seeders; add to prod deps only as a last resort.
  • Vite manifest not foundpublic/build/ is gitignored and wasn’t deployed. For the build-locally strategy, un-ignore it: git add -f public/build/ && git commit && dep deploy "$DEPLOY_TARGET".
  • All /storage/* (or /packages/*) URLs 403/404 — images/avatars/uploads broken — the server’s public/storage points at a dead absolute path (often a vendor-shipped symlink to /var/www/...), or public/packages was never created. Relink per §5.5: rm -f public/storage && $PHP_BIN artisan storage:link, and ln -s ../packages public/packages for in-tree module apps. Both are per-env and must stay gitignored.
  • Livewire “published assets are out of date” — the Livewire package and the published assets in public/vendor/livewire/ disagree, so wire:model silently fails and component state is lost on refresh. One-off fix: resolve $PHP_BIN from deploy.php (same as §3), then ssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && $PHP_BIN artisan livewire:publish --assets", then purge the CDN cache. Prevention: the deploy template runs a livewire:publish_assets task after vendor:restore on every release — copy it in if your deploy.php predates it.
  • your php version does not satisfy that requirement at deploy:vendors — web SAPI PHP is below composer.json. Fixes belong on page 1’s PHP triple-check: upgrade in hPanel, update bin/php, redeploy. Fails before the symlink switch.
  • Emergency rollbackdep rollback "$DEPLOY_TARGET" (changes the symlink to the previous release; no files deleted).

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

  • 🤖 First-deploy drivers safeCACHE_STORE=file, CACHE_DRIVER=file, SESSION_DRIVER=file, QUEUE_CONNECTION=sync before installer tables exist.
  • 🤖 Release deployeddep deploy "$DEPLOY_TARGET" completed for the selected non-production target; current symlinks to the new release.
  • 🤖 Key + cachesAPP_KEY present (44+ chars); install lock removed; caches cleared with the versioned PHP.
  • 🤖 Deployer tree correctcurrent, releases/, shared/, .dep/.
  • 🤖 .env symlink resolvescurrent/.env is a symlink into shared/.env; Laravel resolves your cache/session/queue values.
  • 🤖 Seeded storage content present — vendor storage defaults copied to shared storage; at least one expected media URL returns 200.
  • 🤖 Public symlinks relinkedpublic/storage resolves to an existing server path (not a dead /var/www/...); public/packages linked if the app ships in-tree module assets; both stay gitignored.
  • 🤖 Installer permissions set — 777 for the installer (re-hardened on page 4).