Skip to content
prod 352bb92
Browse

1 · Deployer (zero-downtime)

Objective — install Deployer and author deploy.php for atomic, zero-downtime releases (symlinked current → timestamped releases, shared storage + .env, retention, hooks, confirmed server binaries) so a failed deploy never takes the site down and rollback is a symlink flip.

Steps at a glance:

  1. Install the Deployer CLI — Get dep on the PATH so every later task can run.
  2. Learn the configuration API before touching deploy.php — Deployer’s DSL has four functions that look interchangeable but behave very differently. Picking the wrong one silently overrides recipe defaults — the exact bug class behind a real 90-minute production debug session (see the .env warning below).
  3. Create and configure deploy.php — On a first run, copy the template; on a rerun, never blindly cp over an existing deploy.php — it holds your real hostnames, SSH ports, users, and binary paths.
  4. Confirm the server binary paths — Shared hosting often exposes the wrong /usr/bin/php. Confirm the domain’s deploy PHP and pin Deployer’s PHP binary to it; Phase 5 writes the Composer platform pin immediately before the first live deploy.
  5. Disable migrations for the first deploy — Running migrations before the vendor installer wizard causes schema conflicts. Comment the hook out for deploy #1, then re-enable after the installer runs in Phase 5.
  6. Validate, then verify SSH access — Prove the file parses and the servers are reachable before any real deploy.

Deployer gives you atomic, zero-downtime releases: each deploy builds a fresh timestamped directory, links shared state into it, runs your hooks, and only on success flips a current symlink. A failed deploy never takes the site down, and rollback is a symlink flip back to the previous release.

flowchart TD
D["dep deploy <target>"] --> R["releases/2026-06-09-1/"]
R --> SH["link shared: storage/, .env"]
SH --> H["hooks: migrate · cache warm · queue restart"]
H --> SY["flip current → new release"]
SY --> OK["live ✅ (old release kept for rollback)"]
style SY fill:#0b3,stroke:#062,color:#fff

Get dep on the PATH so every later task can run.

  1. Check for, then install, Deployer.

    Terminal window
    dep --version # already installed?
    composer global require deployer/deployer
    dep --version | grep -E 'Deployer +7\.'
    # Expected: "Deployer 7.x.x" after install or from the existing dep binary
    • dep is installed and reports Deployer 7.x. If it reports Deployer 6 or 8+, use the matching docs before continuing.
  2. Add the Composer global bin to PATH if dep is still not found.

    Terminal window
    COMPOSER_BIN="$(composer global config bin-dir --absolute)"
    grep -q "$COMPOSER_BIN" ~/.zshrc \
    || echo "export PATH=\"\$PATH:$COMPOSER_BIN\"" >> ~/.zshrc
    source ~/.zshrc
    dep --version
    # Expected: dep --version returns "Deployer 7.x.x"
    • dep --version returns Deployer 7.x.x.

2. Learn the configuration API before touching deploy.php

Section titled “2. Learn the configuration API before touching deploy.php”

Deployer’s DSL has four functions that look interchangeable but behave very differently. Picking the wrong one silently overrides recipe defaults — the exact bug class behind a real 90-minute production debug session (see the .env warning below).

FunctionSemanticsUse when
set('key', $v)Replaces the value, overriding recipe defaultsDefining a value from scratch, or intentionally overriding a default
add('key', $v)Merges into an existing array, preserving defaultsExtending a recipe-default list with your own entries
get('key')Reads the current valueReferencing a value inside a task closure
has('key')Checks whether a value is definedConditional logic on a setting

Laravel recipe array defaults you can silently destroy with set():

SettingRecipe defaultIf you set() it wrong
shared_files['.env']Drops .env from the symlink list — every release boots on .env.example values.
shared_dirs['storage']Logs, uploads, and framework cache get wiped on every deploy.
writable_dirs['bootstrap/cache', 'storage', 'storage/app', 'storage/app/public', 'storage/framework', 'storage/framework/cache', 'storage/framework/sessions', 'storage/framework/views', 'storage/logs']A shorter replacement list means Laravel cannot write missing paths at runtime → 500s after deploy.
clear_paths[]Safe to set() or add(); use add() for symmetry with the GIT_ONLY_PATHS check on the CI page.
keep_releases10Safe to set() — scalar, not array.
bin/phpAuto-detectedSafe to set() — you usually want to override detection on shared hosting.

Quick audit: grep -nE "^\\s*set\\('(shared_files|shared_dirs|writable_dirs|clear_paths)'" deploy.php — any hit on an array key means you must confirm the replacement includes every recipe-default entry, or switch to add().

On a first run, copy the template; on a rerun, never blindly cp over an existing deploy.php — it holds your real hostnames, SSH ports, users, and binary paths. Reconcile instead.

  1. Detect first-run vs rerun before copying anything.

    Terminal window
    [ -f deploy.php ] && echo "RERUN — reconcile, do not overwrite" || echo "FIRST RUN — cp template"
    # Expected: "FIRST RUN" on a fresh project, "RERUN" if deploy.php already exists
    • ✅ You know whether to copy the template or reconcile an existing file.
  2. Define the core settings. At minimum:

    • shared_dirsstorage (persist uploads/logs/sessions across releases)

    • shared_files.env (the production env lives server-side, shared)

    • keep_releases → e.g. 5 (retention for fast rollback)

    • hooksafter('deploy:vendors', 'artisan:migrate') for migrations; after('deploy:symlink', 'artisan:storage:link') so each fresh release re-creates public/storage → shared storage/app/public; and any cache-warm / queue-restart hooks that should run after the current symlink flips

    • shared_dirs, shared_files, keep_releases, the storage-link hook, and the migrate/cache hooks are all defined.

  3. Map the template branch names to this project’s branch model. Templates often use staging and production branch names, while setup-new projects commonly use develop as the default work branch and main as the protected release/tag branch. Read Zaj-PROJECT.md, then set each Deployer host’s branch/source explicitly instead of inferring it from the target name.

    host('staging')
    ->set('branch', 'develop'); // selected non-production source branch from Zaj-PROJECT.md
    host('production')
    ->set('branch', 'main'); // release/tag branch, not the default PR target
    • ✅ Deployer target names (staging, qa, production) are mapped to the actual Git branches recorded in Zaj-PROJECT.md.
  4. Write shared_dirs / shared_files correctly — two silent footguns.

    set('shared_dirs', [
    'storage', // all of storage — DO NOT list subdirs like storage/app
    ]);
    // ✅ add() MERGES with the recipe default ['.env'] — .env stays shared
    add('shared_files', [
    // '.env' is already in the recipe default — do NOT repeat it
    // add only files OUTSIDE any shared_dirs directory
    ]);
    • storage is the only shared dir (no nested subdirs); .env is shared via add(), not set().
  5. Register extra migration paths — module-based CodeCanyon apps ship migrations outside database/migrations/, and the recipe won’t run them unless you list each path.

    Terminal window
    find packages -type d -name "Migrations" 2>/dev/null
    # Expected: one path per module that ships its own migrations
    set('extra_migration_paths', [
    'packages/<vendor>/*/src/Database/Migrations',
    'packages/<modules-namespace>/*/src/Database/Migrations',
    'database/migrations-<suffix>',
    ]);
    set('has_migration_installer_check', true);
    • ✅ Every Migrations directory found by find packages is covered by a pattern in extra_migration_paths.

Don’t nest shared dirs — 'storage' already includes storage/app, storage/logs, etc. Listing a subdir alongside it breaks symlink creation.

Shared hosting often exposes the wrong /usr/bin/php. Confirm the correct PHP per host so Deployer uses the versioned CLI binary your selected environment row records. This is an [AUTHOR] step: Phase 4 records the deploy PHP facts and proves the dry-run graph; Phase 5 writes the Composer platform pin immediately before the first live deploy.

  1. Discover the available PHP binaries on the server.

    Terminal window
    DEPLOY_TARGET="<selected-nonproduction-deploy-target>" # from Zaj-PROJECT.md
    dep run 'ls /usr/bin/php* /opt/alt/php*/usr/bin/php 2>/dev/null | sort' --selector "alias=$DEPLOY_TARGET"
    # Expected: absolute paths to the PHP binaries present on the host
    • ✅ You have candidate versioned PHP binaries. Do not rely on login-shell Composer; Deployer’s vendors task will verify the configured Composer install path under the selected PHP.
  2. Confirm both server PHP surfaces. On shared hosting, web PHP and CLI PHP are separate. The hosting panel controls the domain’s web PHP; Deployer runs the versioned CLI binary.

    Terminal window
    BIN_PHP="/opt/alt/php83/usr/bin/php" # example: versioned binary, not login-shell php
    dep run "$BIN_PHP -v | head -1" --selector "alias=$DEPLOY_TARGET"
    dep run "php -v | head -1" --selector "alias=$DEPLOY_TARGET" # diagnostic only — login-shell PHP may differ
    # Expected: BIN_PHP is the confirmed deploy PHP. Bare php may differ and is not the deploy proof.
    • Zaj-PROJECT.md records the domain web PHP and the versioned CLI BIN_PHP path for the selected non-production and production rows.
  3. Pin bin/php to the domain’s deploy PHP path.

    set('bin/php', '/opt/alt/php83/usr/bin/php'); // match the confirmed deploy PHP binary
    • bin/php points at the confirmed versioned deploy PHP binary.
  4. Record the Phase 5 platform-pin carry-forward. Do not write Composer’s platform setting in Phase 4. Store the confirmed deploy PHP version, the web-PHP selector value, and the versioned CLI binary in Zaj-PROJECT.md; Phase 5 page 1 consumes those facts and commits the Composer pin before the first deploy.

    Terminal window
    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 continuing"; exit 1; }
    printf 'Phase 5 carry-forward: pin Composer platform to deploy PHP %s from %s\n' "$DEPLOY_PHP_VERSION" "$BIN_PHP"
    # Expected: a non-empty deploy PHP version and a carry-forward note for Phase 5
    • Zaj-PROJECT.md records the deploy PHP version, web PHP, versioned CLI binary, and a Phase 5 carry-forward to write the Composer platform pin.
  5. Commit the Deployer runtime facts with the deploy pipeline changes.

    Terminal window
    git add deploy.php Zaj-PROJECT.md
    git commit -m "Configure Deployer PHP paths"
    # Expected: deploy.php plus non-secret runtime facts committed together
    • ✅ The Deployer PHP path and Zaj-PROJECT.md runtime facts are version-controlled together. Composer platform metadata is intentionally still untouched until Phase 5.

5. Disable migrations for the first deploy

Section titled “5. Disable migrations for the first deploy”

Running migrations before the vendor installer wizard causes schema conflicts. Comment the hook out for deploy #1, then re-enable after the installer runs in Phase 5.

  1. Comment out the migrate hook.

    // after('deploy:vendors', 'artisan:migrate');
    • ✅ The migrate hook is commented out for the first deploy.
  2. Confirm it’s disabled.

    Terminal window
    grep -E "^\s*after\('deploy:vendors', 'artisan:migrate'\);" deploy.php \
    && echo "ENABLED" || echo "DISABLED — correct for first deploy"
    # Expected: "DISABLED — correct for first deploy"
    • ✅ The grep reports DISABLED — correct for first deploy.

Prove the file parses and the servers are reachable before any real deploy.

  1. Syntax-check and parse deploy.php, then enumerate Deployer hosts without old remote-command syntax.

    Terminal window
    php -l deploy.php # PHP syntax valid
    dep list 2>&1 | tail -30 # Deployer parses the file — expect 20+ tasks grouped by namespace
    # Expected: "No syntax errors detected", then a task catalog with no PHP/Deployer errors
    Terminal window
    # Deployer 7 removed the old host-listing commands. These now fail:
    # dep config:hosts, dep hosts, dep list hosts
    # Correct host enumeration: parse deploy.php, then SSH-test the aliases from Zaj-PROJECT.md.
    grep -n "^host(" deploy.php
    DEPLOY_SSH_ALIAS="<selected-non-production-ssh-alias-from-Zaj-PROJECT>"
    PROD_SSH_ALIAS="<production-ssh-alias-from-Zaj-PROJECT>"
    ssh -o BatchMode=yes -o ConnectTimeout=8 "$DEPLOY_SSH_ALIAS" 'pwd && php -v | head -1'
    ssh -o BatchMode=yes -o ConnectTimeout=8 "$PROD_SSH_ALIAS" 'pwd && php -v | head -1'
    # Expected: host() rows match the deploy targets; both direct SSH checks exit 0
    • php -l is clean, dep list shows 20+ tasks, every host() grep-matches, and direct SSH reaches the selected aliases from Zaj-PROJECT.md.
  2. Verify the Deployer graph with a dry deploy.

    Terminal window
    DEPLOY_TARGET="<selected-nonproduction-deploy-target>" # from Zaj-PROJECT.md
    dep deploy "$DEPLOY_TARGET" --plan | tee /tmp/dep-plan.txt
    grep -E 'deploy:vendors|deploy:symlink' /tmp/dep-plan.txt
    # Expected: dry-run plan parses the selected target and reaches deploy:vendors + deploy:symlink
    • dep deploy "$DEPLOY_TARGET" --plan reaches the vendor install and symlink steps without mutating the server.
  3. Confirm the servers can reach GitHub and have the right tools.

    Terminal window
    dep run "ssh -T git@github.com 2>&1 | tee /tmp/github-ssh-test.txt" --selector "alias=$DEPLOY_TARGET"
    dep run "grep -E 'successfully authenticated|Hi .*successfully authenticated' /tmp/github-ssh-test.txt && echo 'GitHub deploy key OK'" --selector "alias=$DEPLOY_TARGET"
    dep run "$BIN_PHP -v | head -1" --selector "alias=$DEPLOY_TARGET" # match Zaj-PROJECT.md
    # Expected: GitHub banner confirms auth, and the server reports the confirmed deploy PHP version
    • ✅ GitHub authenticates from the server and the versioned PHP binary matches Zaj-PROJECT.md. Phase 5 owns the Composer platform pin.
SymptomCauseFix
dep: command not foundComposer bin not on PATHAdd to ~/.zshrc and source it
Cannot create symlinkNested shared dirsKeep only root folders (storage, not storage/app)
SSH timeoutWrong port/IPMatch ~/.ssh/config
PENDING MIGRATIONS DETECTEDUnapplied migrations on serverRun manually or deploy with --allow-pending

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

  • 🤖 Deployer installeddep --version returns Deployer 7.x; php -l deploy.php clean; dep list shows 20+ tasks.
  • 🤖 Shared state correctdeploy.php shares storage/ + .env via add() (never set([])), with retention, an artisan:storage:link hook (so public/storage exists per release), and migrate/cache/queue hooks.
  • 🤖 PHP confirmedbin/php set to the confirmed domain deploy PHP, Zaj-PROJECT.md records web + CLI PHP, and the Phase 5 Composer platform-pin carry-forward is explicit.
  • 🤖 First-deploy migrations off — the migrate hook is commented out.
  • 🔀 SSH + dry run — direct SSH reaches the selected aliases, server GitHub auth works, and dep deploy "$DEPLOY_TARGET" --plan reaches the symlink step for the selected non-production target.