Skip to content
prod 352bb92
Browse

1 · Pre-flight & provision host

Objective — get four things right before the first byte ships: the Phase 4 handoff is complete, Composer is pinned to the confirmed deploy PHP, the selected non-production branch/target is current, and the server scaffolding (deploy tree, PHP limits, .env) exists — because a pre-flight failure is cheap and a deploy:vendors failure three minutes into a release is not.

Steps at a glance:

  1. Consume the Phase 4 handoff and pin Composer — Phase 4 confirms the deploy PHP; this page writes the Composer platform pin and refreshes the lock metadata before the first live deploy.
  2. Run the canonical pre-flight — Do not deploy blind. Preview the full pipeline, then confirm the PHP version the server actually runs — not just the binary Deployer points at.
  3. Confirm the deploy-safety flags — The recipe disables and enables a few tasks deliberately. Confirm the flags are set the way the pipeline expects before you trust the plan.
  4. Spot-check skip-policy gating — Optional tasks (AWS backup, Atlas, ZajModule migrations, Livewire publish, Sentry/Slack/Discord) must each carry a graceful-skip guard so a feature you haven’t configured can’t hard-fail the deploy.
  5. Prepare the non-production branch — Bring the source branch from Zaj-PROJECT.md current with develop and push it, then confirm the host config and SSH still resolve.
  6. Clear the server composer cache — Shared hosts carry stale composer cache from prior projects — even an identical composer.lock can pull old cached package versions with extra migration files.
  7. Take a pre-deploy snapshot — Capture the current local DB before the first non-production deploy so you have a rollback reference.
  8. Scaffold the server — Create the deploy directory tree, set the PHP limits the installer needs, then place the selected environment’s .env — three commands that prepare the host for its first release.
  9. Scan the .env for placeholders — A deploy fails if .env still has unfilled placeholders or empty required values. Catch them before you ship.

Everything in this phase rides on four things being right before the first byte ships: the Phase 4 handoff is complete, the server PHP actually satisfies composer.json, the selected non-production branch/target is current, and the server scaffolding (deploy tree, PHP limits, .env) exists. A pre-flight failure is cheap; a deploy:vendors failure three minutes into a release is not.

Read Zaj-PROJECT.md first and choose the non-production environment row you are deploying to. If the project has more than one non-production target, do not guess — use the row the operator selected for this pass.

Terminal window
ENV_KEY="staging-primary" # example; use the real Zaj-PROJECT.md row key
DEPLOY_TARGET="<selected-nonproduction-deploy-target>" # Deployer host key from Zaj-PROJECT.md
SOURCE_BRANCH="develop" # example; use the source branch from Zaj-PROJECT.md
SSH_ALIAS="<non-prod-alias>" # SSH alias from Zaj-PROJECT.md
HOSTING_ACCOUNT="<hostinger-account-ref>"
DEPLOY_DOMAIN="nonprod.example.com"
DEPLOY_PATH="~/domains/$DEPLOY_DOMAIN/deploy"
# Expected: values match one Zaj-PROJECT.md non-production row, including the hosting account
  • ✅ The environment key, deploy target, source branch, hosting account, SSH alias, domain, and deploy path all match Zaj-PROJECT.md.

1. Consume the Phase 4 handoff and pin Composer

Section titled “1. Consume the Phase 4 handoff and pin Composer”

Phase 4 confirmed the deploy PHP and authored the pipeline. This step writes the Composer platform pin for the selected non-production target and proves the lock still installs before any server mutation happens.

  1. Verify the Phase 4 handoff evidence exists.

    Terminal window
    test -f deploy.php && php -l deploy.php
    dep deploy "$DEPLOY_TARGET" --plan 2>&1 | grep -E 'deploy:vendors|deploy:symlink'
    grep -n "set('bin/php'" deploy.php
    # Expected: deploy.php parses, the plan reaches vendors + symlink, and bin/php is configured
    • ✅ Phase 4’s AUTHOR proof is present for the selected target.
  2. Pin Composer to the confirmed deploy PHP and refresh lock metadata.

    Terminal window
    BIN_PHP=$(grep "set('bin/php'" deploy.php | grep -oE "/[^']+")
    DEPLOY_PHP_VERSION="$(dep run "$BIN_PHP -r 'echo PHP_VERSION;'" --selector "alias=$DEPLOY_TARGET" 2>/dev/null | tail -1)"
    test -n "$DEPLOY_PHP_VERSION" || { echo "Could not read deploy PHP version — fix bin/php before pinning"; exit 1; }
    composer config platform.php "$DEPLOY_PHP_VERSION"
    composer update --lock
    composer prohibits php "$DEPLOY_PHP_VERSION" --locked --tree
    composer check-platform-reqs --lock --no-dev
    composer install --dry-run --no-dev
    git diff -- composer.json composer.lock
    # Expected: platform.php matches the confirmed deploy PHP; lock metadata refreshed;
    # dry-run succeeds; no package version changes unless intentionally resolving a blocker
    • composer.json and composer.lock agree with the confirmed deploy PHP, and composer install --dry-run --no-dev succeeds.
  3. Commit the platform pin before the first deploy.

    Terminal window
    git add composer.json composer.lock Zaj-PROJECT.md
    git commit -m "Pin Composer platform for non-production deploy"
    # Expected: one commit with the Composer platform/lock metadata and project-state update
    • ✅ Composer platform metadata and Zaj-PROJECT.md are version-controlled before the first live deploy.

Do not deploy blind. Preview the full pipeline, then confirm the PHP version the server actually runs — not just the binary Deployer points at.

  1. Preview the plan.

    Terminal window
    # Deployer 7 uses --plan; the old --dry-run was removed and errors out
    dep deploy "$DEPLOY_TARGET" --plan 2>&1 | tail -60
    # Expected: a ~40–50 task pipeline including the gates that matter

    You should see a ~40–50 task pipeline. The exact list depends on enabled features (Atlas, AWS backup, Sentry, Slack/Discord, module migrations, Livewire publish), but the canonical shape includes the gates that matter:

    deploy:check_existing_pending ← BLOCKS if staging has pending migrations
    deploy:verify_clear_paths ← BLOCKS if a sensitive file survives clear_paths
    deploy:vendors ← composer install — fails here on PHP mismatch
    deploy:verify_migrations
    deploy:smoke_test ← BLOCKS before the symlink switch
    deploy:symlink ← atomic cutover
    deploy:health_check ← BLOCKS after symlink; auto-rollback on failure
    • ✅ The plan previews a sane ~40–50 task pipeline with the gates above present.
  2. Triple-check PHPcomposer.json ↔ Deployer bin/php ↔ Composer runtime, with the web SAPI captured by hPanel now and page 4 later.

    Terminal window
    # 1. What does composer.json require?
    grep '"php"' composer.json | head -1
    # 2. What does deploy.php point bin/php at, and what does that binary report?
    BIN_PHP=$(grep "set('bin/php'" deploy.php | grep -oE "/[^']+")
    dep run "$BIN_PHP -v | head -1" --selector "alias=$DEPLOY_TARGET"
    # 3. Does bin/composer run through the same configured PHP?
    grep -n "set('bin/composer'.*bin/php" deploy.php
    dep run "$BIN_PHP -r 'echo PHP_VERSION.PHP_EOL;'" --selector "alias=$DEPLOY_TARGET"
    # Expected: composer.json's PHP requirement is satisfied by the configured deploy PHP,
    # and bin/composer is wired through bin/php (not bare login-shell php)
    • bin/php satisfies composer.json, and bin/composer is wired through bin/php.

Then cross-check the control panel: hPanel → Websites → [domain] → Advanced → PHP Configuration. On shared hosting the web-serving PHP is set there per-domain — not by bin/php in deploy.php.

Use the table to read the result of the triple-check:

PatternMeaningAction
bin/php and bin/composer satisfy composer.jsonSafe for deploy:vendorsProceed
bin/composer does not include bin/phpComposer may run under login-shell PHPFix deploy.php, re-run the plan
hPanel/web SAPI is lower than the app needsBrowser requests or installer checks may failUpgrade PHP in hPanel, wait ~60s, re-check
bin/php path doesn’t exist on the serverbin/php is staleRe-run the SSH PHP discovery from Phase 4

The three layers and which one actually runs composer install:

flowchart TD
CJ["composer.json<br/>requires PHP ^8.x"]
BP["deploy.php bin/php<br/>(CLI binary)"]
BC["deploy.php bin/composer<br/>(must use bin/php)"]
WS["web SAPI php<br/>(hPanel + installer screen)"]
CI["composer install<br/>(deploy:vendors)"]
BP --> BC
BC --> CI
CJ -->|must be satisfied by| CI
WS -.->|browser request truth| CI

The recipe disables and enables a few tasks deliberately. Confirm the flags are set the way the pipeline expects before you trust the plan.

  1. Confirm the three deploy-safety flags.

    Terminal window
    grep -n "artisan:migrate.*disable" deploy.php # expect: task('artisan:migrate')->disable();
    grep -n "clear_paths_strict" deploy.php # expect: set('clear_paths_strict', true);
    grep -n "atlas_enabled" deploy.php # expect: set('atlas_enabled', true);
    # Expected: each grep prints its matching line
    • artisan:migrate is disabled, clear_paths_strict is true, and atlas_enabled is set.

Optional tasks (AWS backup, Atlas, ZajModule migrations, Livewire publish, Sentry/Slack/Discord) must each carry a graceful-skip guard so a feature you haven’t configured can’t hard-fail the deploy. Confirm each guards on missing config.

  1. Scan each optional task for a skip guard.

    Terminal window
    for t in deploy:aws_backup deploy:atlas_preflight deploy:zajmodule_migrations \
    livewire:publish_assets sentry:release slack:notify:success; do
    echo "=== $t ==="
    awk -v t="$t" '
    $0 ~ "task(\047" t "\047" { grab=1 }
    grab && /^task\(/ && $0 !~ "task(\047" t "\047" { exit }
    grab { print }
    ' deploy.php \
    | grep -E "if \(!get\(|if \(empty|NOT_FOUND|return;" | head -3 | sed 's/^/ /'
    done
    # Expected: each task prints at least one if (!get('..._enabled')) / if (empty(...)) / NOT_FOUND … return guard
    • ✅ Every optional task prints at least one graceful-skip guard; none is unguarded.

A task with zero guards may abort the pipeline when its feature isn’t configured — read its full body before you deploy.

5. Prepare the selected non-production branch

Section titled “5. Prepare the selected non-production branch”

Bring the selected source branch from Zaj-PROJECT.md current with develop and push it, then confirm the host config and SSH still resolve.

  1. Confirm the selected branch matches the Deployer host block, then push it.

    Terminal window
    git status --short # expect: clean before branch work
    git fetch origin develop "$SOURCE_BRANCH" --no-tags
    DEPLOY_BRANCH=$(awk -v host="host('$DEPLOY_TARGET')" '
    $0 ~ host { in_host=1 }
    in_host && /set\('\''branch'\''/ {
    match($0, /set\('\''branch'\'', *'\''([^'\'']+)/, m); print m[1]; exit
    }
    in_host && /^host\('/ && $0 !~ host { exit }
    ' deploy.php)
    test -n "$DEPLOY_BRANCH" || { echo "STOP — no branch set for $DEPLOY_TARGET in deploy.php"; exit 1; }
    test "$DEPLOY_BRANCH" = "$SOURCE_BRANCH" || {
    echo "STOP — Zaj-PROJECT says $SOURCE_BRANCH, deploy.php says $DEPLOY_BRANCH";
    exit 1;
    }
    git checkout "$SOURCE_BRANCH" || git checkout -b "$SOURCE_BRANCH" "origin/$SOURCE_BRANCH"
    if [ "$SOURCE_BRANCH" != "develop" ]; then
    git merge develop
    fi
    git push -u origin "$SOURCE_BRANCH"
    # Expected: selected non-production branch matches deploy.php and is pushed to origin
    • ✅ The selected non-production branch matches the Deployer host’s branch setting and is pushed to origin.
  2. Confirm the host config and SSH resolve.

    Terminal window
    grep -A5 "host('$DEPLOY_TARGET')" deploy.php # correct hostname, remote_user, deploy_path
    ssh "$SSH_ALIAS" "echo 'Non-production SSH OK'"
    # Expected: deploy.php host block looks right; SSH prints "Non-production SSH OK"
    • ✅ The selected Deployer host block is correct and SSH responds via the alias.

6. Clear the server composer cache (first deploy only)

Section titled “6. Clear the server composer cache (first deploy only)”

Shared hosts carry stale composer cache from prior projects — even an identical composer.lock can pull old cached package versions with extra migration files.

  1. Clear the server’s composer cache.

    Terminal window
    dep clear_composer_cache "$DEPLOY_TARGET"
    # manual equivalent:
    ssh "$SSH_ALIAS" 'CACHE_DIR=~/.cache/composer/files; [ -d "$CACHE_DIR" ] && mv "$CACHE_DIR" "${CACHE_DIR}_backup_$(date +%Y%m%d%H%M%S)"; mkdir -p "$CACHE_DIR"; find "$CACHE_DIR" -mindepth 1 | wc -l'
    # Expected: 0
    • ✅ The server composer cache is empty (0).

Run this on a first deploy, after major composer.lock changes, or when you hit mysterious migration conflicts.

Capture the current local DB before the first non-production deploy so you have a rollback reference.

  1. Snapshot the current local DB.

    Terminal window
    SNAP="Admin-Local/1-Project/6-Schema/2-Snapshots/$(date +%Y-%m-%d)-pre-${ENV_KEY}"
    mkdir -p "$SNAP"
    cp Admin-Local/1-Project/6-Schema/1-Current/local.sql "$SNAP/local.sql"
    echo "Pre-first-${ENV_KEY}-deploy snapshot" > "$SNAP/notes.md"
    # Expected: a dated snapshot dir holding local.sql + notes.md
    • ✅ A dated pre-$ENV_KEY snapshot exists with local.sql and notes.md.

Create the deploy directory tree, set the PHP limits the installer needs, then place the selected environment’s .env — three commands that prepare the host for its first release.

  1. Create the server directory tree.

    Terminal window
    ssh "$SSH_ALIAS" "mkdir -p $DEPLOY_PATH/shared/storage"
    # Expected: no output (directories created)
    • deploy/shared/storage exists on the server.
  2. Set the PHP limits the installer needs.

    Terminal window
    # Feed the heredoc to the LOCAL ssh's stdin (EOF at column 0 here), and let
    # ssh forward it to the remote `cat`. Keep the heredoc outside the remote
    # quoted command so the terminator is controlled by the local shell.
    ssh "$SSH_ALIAS" "cat > $DEPLOY_PATH/shared/.user.ini" <<'EOF'
    max_execution_time = 300
    memory_limit = 512M
    max_input_time = 300
    upload_max_filesize = 64M
    post_max_size = 64M
    EOF
    # Expected: shared/.user.ini written with the installer limits
    • shared/.user.ini sets max_execution_time = 300 and the upload limits.
  3. Place the selected environment .env — always via the SSH alias, never raw user@IP:port. Prefer rendering from .env.tpl and 1Password so no filled env file lives on disk.

    Terminal window
    TMP_ENV="$(mktemp)"
    op inject -f -i .env.tpl -o "$TMP_ENV"
    ssh "$SSH_ALIAS" "mkdir -p '$DEPLOY_PATH/shared' && umask 027 && cat > '$DEPLOY_PATH/shared/.env'" < "$TMP_ENV"
    ssh "$SSH_ALIAS" "chmod 640 '$DEPLOY_PATH/shared/.env' && test -s '$DEPLOY_PATH/shared/.env' && wc -c '$DEPLOY_PATH/shared/.env'"
    shred -u "$TMP_ENV" 2>/dev/null || rm -f "$TMP_ENV"
    test ! -f "$TMP_ENV"
    # Expected: shared/.env has a non-zero byte count, mode 640, and the local temp file is gone
    • shared/.env is present, chmod 640, and no rendered temp file remains locally.

A deploy fails if .env still has unfilled placeholders or empty required values. Catch them before you ship.

  1. Scan for placeholders and empty required secrets.

    Terminal window
    ssh "$SSH_ALIAS" "grep -inE '_HERE|your_|YOUR_|\[.*\]|CHANGE_ME|REPLACE|PLACEHOLDER|TODO|FIXME|password_here' \
    '$DEPLOY_PATH/shared/.env'" 2>/dev/null
    ssh "$SSH_ALIAS" "grep -E '^(DB_PASSWORD|MAIL_PASSWORD|REDIS_PASSWORD)=(\"\")?$' \
    '$DEPLOY_PATH/shared/.env'" 2>/dev/null
    # Expected: no output (no placeholders, no empty required secrets)
    • ✅ Neither scan returns a line — no placeholders, no empty required secrets.

If a service isn’t ready yet (e.g. Redis/Upstash), set it to a local driver temporarily and switch later:

CACHE_DRIVER="file" # Laravel 11+: CACHE_STORE="file"
SESSION_DRIVER="file"
QUEUE_CONNECTION="sync"

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

  • 🤖 Phase 4 handoff consumeddeploy.php parses, dep --plan reaches vendors + symlink, and confirmed bin/php is present.
  • 🤖 Composer platform pinned — Composer platform matches the confirmed deploy PHP, lock metadata refreshed, dry-run install passes, and the commit is made before first deploy.
  • 🤖 Plan previeweddep deploy "$DEPLOY_TARGET" --plan pipeline shape looks right.
  • 🔀 PHP triple-check passes — Deployer bin/php / bin/composer satisfy composer.json; hPanel web SAPI checked now and installer Requirements screen recorded on page 4.
  • 🤖 Deploy-safety flags confirmedartisan:migrate disabled, clear_paths_strict true.
  • 🤖 Optional tasks guarded — each carries a graceful-skip guard (no unguarded hard-fail).
  • 🤖 Source branch ready — selected non-production source branch from Zaj-PROJECT.md merged from develop and pushed; SSH + host config verified.
  • 🤖 Cache cleared + snapshot taken — composer cache cleared (first deploy); pre-deploy snapshot saved.
  • 🤖 Server scaffoldeddeploy/shared/storage tree exists; shared/.user.ini sets installer limits.
  • 🤖 shared/.env placedchmod 640, no placeholders, APP_KEY empty, APP_DEBUG=false.