The Problem

Every time I pushed a backend change -- fixing a typo in a controller, tweaking a query, updating a config value -- my deployment took over a minute. For a production app serving Miami Beach residents real-time city government data, that felt wrong. A one-line PHP fix shouldn't take longer to deploy than it took to write.

Where Was the Time Going?

I broke down my Forge deploy script and timed each section:

Step Time
git fetch + git reset --hard ~2s
composer install ~5s
npm ci (1,098 packages) ~30s
npm run build (Vite) ~17s
php artisan migrate ~1s
Cache clearing + rebuilding ~3s
PHP-FPM reload ~1s
Total ~60s

Nearly 80% of every deployment was spent installing JavaScript dependencies and building frontend assets -- even when I hadn't touched a single frontend file.

The Fix: Conditional Frontend Builds

The idea is simple: track what commit was deployed last, diff it against the new commit, and only run npm ci + npm run build if frontend-related files actually changed.

Storing the Previous Deploy Hash

The naive approach is git rev-parse HEAD@{1}, but that relies on the reflog, which git reset --hard can make unreliable. Instead, I write the deployed commit hash to a file:

# Read previous deploy's commit hash
PREV_HEAD=$(cat .deploy_head 2>/dev/null || echo "")

# ... git fetch, reset, composer install ...

# Save current commit for next deploy
git rev-parse HEAD > .deploy_head

The Conditional Check

if [ -z "$PREV_HEAD" ] || git diff --name-only "$PREV_HEAD" HEAD \
    | grep -qE '^(resources/|package\.json|package-lock\.json|vite\.config|tailwind\.config|postcss\.config)'; then
    echo "Frontend files changed, rebuilding assets..."
    npm ci
    npm run build
else
    echo "No frontend changes, skipping npm build"
fi

This checks if any files changed in:

  • resources/ -- Vue components, JS, CSS, Blade templates processed by Vite
  • package.json / package-lock.json -- dependency changes
  • vite.config / tailwind.config / postcss.config -- build configuration

If none of those changed, skip the entire frontend build.

The Safety Net

On the first deploy after adding this optimization (or if .deploy_head is missing for any reason), $PREV_HEAD is empty, so the [ -z "$PREV_HEAD" ] check triggers a full build. You never end up with stale assets.

Cleaning Up Legacy Cruft

The conditional build wasn't the only improvement. My original deploy script had accumulated lots of one-time migration code that ran (uselessly) on every deploy:

  • One-time data imports guarded by flag files (.meetings_data_imported) -- the import ran once in 2025, but the conditional check still executed every deploy
  • Video seeders behind similar flags -- ran once, never again
  • Commented-out commands from migrations we'd already completed
  • A video URL format checker that ran a tinker command to count legacy clip URLs -- the migration happened months ago, count has been 0 ever since
  • videos:scan-spaces running on every deploy -- moved to a scheduled task where it belongs

Removing all of this cut ~50 lines from the script and eliminated several unnecessary artisan/tinker invocations.

The Result

For backend-only deployments (which are ~80% of my pushes):

Step Time
git fetch + git reset --hard ~2s
composer install ~5s
npm ci skipped
npm run build skipped
php artisan migrate ~1s
Cache clearing + rebuilding ~3s
PHP-FPM reload ~1s
Total ~14s

That's a 76% reduction -- from over a minute to 14 seconds. Frontend deploys still take the full ~60 seconds, but that's the correct tradeoff: you only pay for what you change.

The Final Script

#!/bin/bash
cd /home/forge/democracy.cc

cp .env .env.backup 2>/dev/null || true
PREV_HEAD=$(cat .deploy_head 2>/dev/null || echo "")
git fetch origin $FORGE_SITE_BRANCH
git reset --hard origin/$FORGE_SITE_BRANCH
[ -f .env.backup ] && mv .env.backup .env

$FORGE_COMPOSER install --no-dev --no-interaction --prefer-dist --optimize-autoloader

if [ -z "$PREV_HEAD" ] || git diff --name-only "$PREV_HEAD" HEAD \
    | grep -qE '^(resources/|package\.json|package-lock\.json|vite\.config|tailwind\.config|postcss\.config)'; then
    echo "Frontend files changed, rebuilding assets..."
    npm ci
    npm run build
else
    echo "No frontend changes, skipping npm build"
fi

git rev-parse HEAD > .deploy_head

if [ -f artisan ]; then
    $FORGE_PHP artisan migrate --force
    $FORGE_PHP artisan cache:clear
    $FORGE_PHP artisan config:cache
    $FORGE_PHP artisan route:cache
    $FORGE_PHP artisan view:cache
    $FORGE_PHP artisan queue:restart
    chmod -R 775 storage bootstrap/cache
fi

touch /tmp/fpmlock 2>/dev/null || true
( flock -w 10 9 || exit 1
    echo 'Reloading PHP FPM...'; sudo -S service $FORGE_PHP_FPM reload ) 9</tmp/fpmlock

Takeaways

  1. Measure before optimizing. The slowest part wasn't what I expected -- it wasn't PHP or database migrations, it was npm.
  2. Don't run what hasn't changed. git diff is free. npm ci is not.
  3. Clean up your deploy scripts periodically. One-time migration code accumulates silently. If it's behind a flag file that's already been created, it's dead weight.
  4. Store state in files, not git internals. Reflogs and HEAD@{1} are fragile in CI/CD. A simple .deploy_head file is portable and reliable.