Skip to content
prod e051e98
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, pinned server binaries) so a failed deploy never takes the site down and rollback is a symlink flip.

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 staging"] --> 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
    # Expected: a version line, or a fresh global install of deployer/deployer
    • dep is installed (globally via Composer).
  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, and after('deploy:symlink', …) for cache warm / queue restart hooks that should run after the current symlink flips

    • shared_dirs, shared_files, keep_releases, and the hooks are all defined.

  3. 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().
  4. 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. Pin the correct PHP (and Composer) per host so Deployer and your app run on the same version your composer.json requires.

  1. Discover the available PHP binaries on the server.

    Terminal window
    dep ssh staging 'ls /usr/bin/php* /opt/alt/php*/usr/bin/php 2>/dev/null | sort; command -v composer'
    # Expected: absolute paths to the PHP binaries present on the host, plus Composer
    • ✅ You have the real path to the PHP version composer.json requires.
  2. Pin bin/php to that path.

    set('bin/php', '/usr/bin/php8.x'); // match composer.json's required version
    • bin/php points at the PHP version composer.json requires.

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 hosts the Deployer-7 way.

    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 directly, then SSH-test each host.
    grep -n "^host(" deploy.php
    for h in $(grep -oP "(?<=^host\\(')[^']+" deploy.php); do
    printf " %s: " "$h"; dep ssh "$h" "echo OK" 2>&1 | tail -1
    done
    # Expected: each defined host grep-matches and answers "OK" over SSH
    • php -l is clean, dep list shows 20+ tasks, every host() grep-matches, and each answers OK.
  2. Verify SSH to both hosts and run a dry deploy.

    Terminal window
    dep ssh staging "echo 'Staging SSH OK'"
    dep ssh production "echo 'Production SSH OK'"
    dep deploy staging --plan # dry run — no changes made
    # Expected: both "SSH OK" lines, then a dry-run plan reaching the symlink step
    • ✅ Both hosts answer and dep deploy staging --plan reaches the symlink step.
  3. Confirm the servers can reach GitHub and have the right tools.

    Terminal window
    dep ssh staging "ssh -T git@github.com 2>&1 | tee /tmp/github-ssh-test.txt"
    dep ssh staging "grep -q 'successfully authenticated' /tmp/github-ssh-test.txt && echo 'GitHub deploy key OK'"
    dep ssh staging "php -v | head -1" # match composer.json
    # Expected: GitHub banner confirms auth, and the server reports the PHP version required by composer.json
    • ✅ GitHub authenticates from the server and PHP is 8.1+.
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 and migrate/cache/queue hooks.
  • 🤖 PHP pinnedbin/php set to the version composer.json requires.
  • 🤖 First-deploy migrations off — the migrate hook is commented out.
  • 🔀 SSH + dry run — both hosts reachable; dep deploy staging --plan reaches the symlink step.