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.
- Dry-run, then deploy — Preview the pipeline one last time, then run the atomic release. The symlink flips only on a passing smoke test.
- 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. - Verify the Deployer directory structure — Confirm Deployer laid out the atomic release tree as expected.
- Verify the
.envsymlink —shared_filescontrols which files Deployer symlinks fromshared/into each release. If someone usedset('shared_files', [])instead ofadd('shared_files', [...]), the recipe’s default['.env']is wiped — and every release boots from.env.exampleor a stale vendor.env, silently loading the wrong. - Verify demo/default content deployed — CodeCanyon apps ship demo content (images, avatars, themes, sample data) in app-specific locations.
- Loosen permissions for the installer — The web installer requires writable paths. This is a deliberate, temporary loosening — it gets hardened on page 4.
Background
Section titled “Background”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:
ENV_KEY="staging-primary" # example; use the selected Zaj-PROJECT.md rowDEPLOY_TARGET="<selected-nonproduction-deploy-target>" # Deployer target from that rowSSH_ALIAS="<non-prod-alias>"DEPLOY_DOMAIN="nonprod.example.com"DEPLOY_PATH="~/domains/$DEPLOY_DOMAIN/deploy"# Expected: values match the selected non-production row0. Set first-deploy-safe runtime drivers
Section titled “0. Set first-deploy-safe runtime drivers”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.
-
Force file/sync drivers in the selected server
.envbefore 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.
1. Dry-run, then deploy
Section titled “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.
-
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!;currentnow 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:#fffFirst-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 step | First response | Likely next move |
|---|---|---|
500 after symlink flip | Tail storage/logs/laravel.log | Use the 500 classification table |
| Empty Laravel log | Check current/storage/logs/ and shared/storage/logs/ | Confirm logging target or Sentry-only routing |
2. Confirm APP_KEY and clear caches
Section titled “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.
-
Confirm
APP_KEYis 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.
- ✅
-
Generate
APP_KEYonce 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.
-
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:clearflushes every cache layer.
- ✅ Stale lock removed;
3. Verify the Deployer directory structure
Section titled “3. Verify the Deployer directory structure”Confirm Deployer laid out the atomic release tree as expected.
-
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 tree shows
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 metadata4. Verify the .env symlink (BLOCKER)
Section titled “4. Verify the .env symlink (BLOCKER)”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.
-
Confirm
current/.envis a symlink intoshared/.env, and that Laravel reads your values.Terminal window # 1. Must be a symbolic linkssh "$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/.envssh "$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/.envis a symbolic link resolving toshared/.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 version | config/cache.php reads | .env must set |
|---|---|---|
| Laravel 11+ | env('CACHE_STORE', …) | CACHE_STORE=file |
| Laravel 10 and earlier | env('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.
-
Confirm the Deployer defaults hook exists and runs before shared symlinks.
Terminal window grep -n "task('shared:init_defaults'" deploy.phpgrep -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.
- ✅ The first deploy has a hook that can copy release defaults before
-
Discover seeded content dirs locally and confirm the deploy plan preserves them.
Terminal window # Discover content dirs locallyfor 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"donegrep -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/publicis seeded byshared:init_defaults; every other content directory with demo files is listed inshared_dirs.
- ✅
-
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" ]; thenssh "$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.
- ✅ A vendor-seeded storage media file exists in shared storage and returns
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:
rsync -az storage/app/public/ "$SSH_ALIAS:$DEPLOY_PATH/shared/storage/app/public/"# Expected: seeded storage/app/public files copied into shared storage5.5. Verify (and relink) the public asset symlinks
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/storagetracked 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, butpublic/packagesdoesn’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 trackedpublic/storagesymlink still rode along in the release, the relink below replaces it.)
-
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/storageresolves to a path that exists on the server (not a dead/var/www/...).
- ✅
-
If the app serves in-tree module assets (e.g. WorkDo
packages/), add thepublic/packageslink 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/packagesexists when the app ships in-tree module assets; otherwisetest ! -d packagesprovesN/A — no in-tree package assets on this release.
- ✅
6. Loosen permissions for the installer
Section titled “6. Loosen permissions for the installer”The web installer requires writable paths. This is a deliberate, temporary loosening — it gets hardened on page 4.
-
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.
- ✅
Background
Section titled “Background”Troubleshooting — first-release failures
Section titled “Troubleshooting — first-release failures”Connection refused 127.0.0.1:6379on the first artisan task — the release is loading.envfrom a fallback, notshared/.env. This is the symlink BLOCKER above. Switchset('shared_files', …)→add('shared_files', …), or untrack a committed.env(git rm --cached .env), then redeploy.SQLSTATE[42S02]: Table '…cache' doesn't existbefore the installer — the vendor defaults cache/session/queue to database drivers before the installer creates those tables. Re-run section 0 and setCACHE_STORE=file,CACHE_DRIVER=file,SESSION_DRIVER=file,QUEUE_CONNECTION=sync, then redeploy.Too many levels of symbolic linksduringdeploy:shared— a file is listed inshared_filesand lives inside ashared_dirsdirectory (e.g.storage/.ignore_localeswithshared_dirs: ['storage']). A file may be in one or the other, never both. Remove theshared_filesentry —shared_dirsalready persists it. Fails before the symlink switch, so no downtime.Class not found(Faker / Debugbar / Telescope) — production code references a dev-only package excluded bycomposer install --no-dev. Guard withclass_exists()or move the logic into seeders; add to prod deps only as a last resort.Vite manifest not found—public/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’spublic/storagepoints at a dead absolute path (often a vendor-shipped symlink to/var/www/...), orpublic/packageswas never created. Relink per §5.5:rm -f public/storage && $PHP_BIN artisan storage:link, andln -s ../packages public/packagesfor 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, sowire:modelsilently fails and component state is lost on refresh. One-off fix: resolve$PHP_BINfromdeploy.php(same as §3), thenssh "$SSH_ALIAS" "cd $DEPLOY_PATH/current && $PHP_BIN artisan livewire:publish --assets", then purge the CDN cache. Prevention: the deploy template runs alivewire:publish_assetstask aftervendor:restoreon every release — copy it in if yourdeploy.phppredates it. your php version does not satisfy that requirementatdeploy:vendors— web SAPI PHP is belowcomposer.json. Fixes belong on page 1’s PHP triple-check: upgrade in hPanel, updatebin/php, redeploy. Fails before the symlink switch.- Emergency rollback —
dep rollback "$DEPLOY_TARGET"(changes the symlink to the previous release; no files deleted).
Checklist
Section titled “Checklist”Do not mark this step done until every box below is checked.
- 🤖 First-deploy drivers safe —
CACHE_STORE=file,CACHE_DRIVER=file,SESSION_DRIVER=file,QUEUE_CONNECTION=syncbefore installer tables exist. - 🤖 Release deployed —
dep deploy "$DEPLOY_TARGET"completed for the selected non-production target;currentsymlinks to the new release. - 🤖 Key + caches —
APP_KEYpresent (44+ chars); install lock removed; caches cleared with the versioned PHP. - 🤖 Deployer tree correct —
current,releases/,shared/,.dep/. - 🤖
.envsymlink resolves —current/.envis a symlink intoshared/.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 relinked —
public/storageresolves to an existing server path (not a dead/var/www/...);public/packageslinked if the app ships in-tree module assets; both stay gitignored. - 🤖 Installer permissions set — 777 for the installer (re-hardened on page 4).