From 60 Seconds to 14: Optimizing Laravel Forge Deployments
Nearly 80% of every deployment was npm. A conditional frontend build cut deploy time by 76% -- from over a minute to 14 seconds.
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 Vitepackage.json/package-lock.json-- dependency changesvite.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
tinkercommand to count legacy clip URLs -- the migration happened months ago, count has been 0 ever since videos:scan-spacesrunning 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
- Measure before optimizing. The slowest part wasn't what I expected -- it wasn't PHP or database migrations, it was npm.
- Don't run what hasn't changed.
git diffis free.npm ciis not. - 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.
- Store state in files, not git internals. Reflogs and
HEAD@{1}are fragile in CI/CD. A simple.deploy_headfile is portable and reliable.
Built while working on Democracy.cc.
Back to danielships ->