Most Laravel outages we get called about are not caused by bad code. They are caused by deploys: a git pull that swaps files mid-request, a migration that locks a table at 5pm, a queue worker still running last week's job class, an .env change nobody wrote down. The fix is a boring, repeatable pipeline that runs the same way every time.
This tutorial builds that pipeline: a GitHub Actions workflow that tests every pull request, then deploys main with an atomic release swap, safe migrations, correctly restarted workers and warm caches. It works on a plain VPS, and the same principles apply if you deploy with Forge, Envoyer or Laravel Cloud.
What "zero downtime" actually requires
Four things, in order of how often they are missed:
- Atomic switchover. The webroot is a symlink to a release directory. You build the new release beside the old one and repoint the symlink in one operation. No request ever sees a half-updated tree.
- Backwards-compatible migrations. During the swap, old and new code briefly run against the same schema. A migration that drops or renames a column breaks the code still serving traffic.
- Worker and cron discipline. Queue workers hold your old code in memory until they are told to restart. Scheduled tasks must not fire against a half-deployed release.
- Cache correctness. Config, route, view and event caches must be rebuilt for the new release before it goes live, not after.
Step 1: Test on every pull request
Put this in .github/workflows/ci.yml. It runs Pest, Pint and PHPStan against a real database.
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: testing
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping" --health-interval=10s
--health-timeout=5s --health-retries=5
redis:
image: redis:7
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, pdo_mysql, redis, intl, bcmath
coverage: none
- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('**/composer.lock') }}
- run: composer install --prefer-dist --no-interaction --no-progress
- name: Prepare environment
run: |
cp .env.example .env
php artisan key:generate
- name: Build assets
run: |
npm ci
npm run build
- run: vendor/bin/pint --test
- run: vendor/bin/phpstan analyse --no-progress
- run: php artisan test --parallel
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: ''
REDIS_HOST: 127.0.0.1
Two details worth keeping: --parallel (Pest and PHPUnit both support it, and it turns a six-minute suite into ninety seconds), and building assets in CI. If npm run build fails, you want to know before the deploy job starts, not during it.
Step 2: Lay out releases on the server
Set the server up once, by hand:
/var/www/app
├── current -> releases/20260901120000
├── releases/
├── shared/
│ ├── .env
│ └── storage/
shared/.env and shared/storage survive deploys; each release symlinks to them. Point your web server's document root at /var/www/app/current/public and make sure PHP-FPM is not caching the resolved real path (opcache.revalidate_path matters here, or restart FPM as part of the deploy — see below).
Create a deploy user with an SSH key, give it permission to reload PHP-FPM via a narrowly scoped sudoers entry, and add the private key to GitHub as the SSH_PRIVATE_KEY secret along with SSH_HOST and SSH_USER.
Step 3: The deploy script
Keep the logic in a script in the repository, not buried in YAML — you will want to run it by hand one day.
#!/usr/bin/env bash
# deploy/release.sh
set -euo pipefail
APP_DIR=/var/www/app
RELEASE="$APP_DIR/releases/$(date +%Y%m%d%H%M%S)"
REPO=git@github.com:acme/app.git
BRANCH="${1:-main}"
git clone --depth 1 --branch "$BRANCH" "$REPO" "$RELEASE"
cd "$RELEASE"
ln -sfn "$APP_DIR/shared/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage"
ln -sfn "$APP_DIR/shared/storage" "$RELEASE/storage"
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
# Assets were built in CI and uploaded as an artifact.
tar -xzf /tmp/build.tar.gz -C "$RELEASE"
php artisan migrate --force --isolated
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan storage:link
# Atomic switch.
ln -sfn "$RELEASE" "$APP_DIR/current"
sudo systemctl reload php8.4-fpm
php "$APP_DIR/current/artisan" queue:restart
# Keep the last five releases.
ls -1dt "$APP_DIR"/releases/* | tail -n +6 | xargs -r rm -rf
Notes on the parts that are easy to get wrong:
--isolatedmakes only one server run the migrations when you deploy to several web nodes; the others wait. Without it, two concurrentmigratecalls can both try to create the same table.--forceis required in production because migrations are destructive-by-default guarded. Pair it with a database backup taken immediately before the deploy.queue:restartdoes not kill workers; it signals them to exit gracefully after the current job, and Supervisor or systemd starts them again on the new release. Run it after the symlink swap, so restarted workers boot the new code.event:cacheis easy to forget and silently costs you discovery time on every request in production.
Step 4: Wire it into GitHub Actions
deploy:
needs: tests
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
group: production-deploy
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build assets
run: |
npm ci
npm run build
tar -czf build.tar.gz public/build
- name: Add SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts
- name: Upload build
run: scp build.tar.gz ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/tmp/build.tar.gz
- name: Release
run: ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'bash /var/www/app/deploy/release.sh main'
The concurrency block with cancel-in-progress: false is what stops two merges a minute apart from racing each other through the release script.
Step 5: Migrations that survive a rolling deploy
The rule: schema changes ship one deploy ahead of the code that needs them, and column removals ship one deploy behind. Renaming users.name to users.full_name is three deploys, not one:
- Add
full_name, backfill it in a queued job, and write to both columns. - Switch all reads to
full_name. Deploy. Verify. - Stop writing
name, then drop it.
For backfills, never do it inline in the migration on a large table. Use a chunked job:
User::whereNull('full_name')->chunkById(500, function ($users) {
foreach ($users as $user) {
$user->update(['full_name' => $user->name]);
}
});
On MySQL 8, adding a nullable column or an index is online; changing a column type or adding a NOT NULL column with a default on a very large table is not always. Check ALGORITHM=INPLACE support for the change you are making, or use pt-online-schema-change. On PostgreSQL, always add constraints as NOT VALID first and validate separately.
Step 6: Scheduler, maintenance mode and rollback
- Scheduler. One cron entry, pointed at
current:* * * * * cd /var/www/app/current && php artisan schedule:run >> /dev/null 2>&1. Because it resolves through the symlink, it picks up the new release automatically. - Maintenance mode. If you truly need it,
php artisan down --render="errors::503" --secret=<token>lets your team browse the site while visitors see the 503, and pre-renders the page so it works even while caches rebuild. A properly ordered release script means you rarely need it. - Rollback. Because releases are directories, rollback is one command: repoint
currentto the previous release, reload FPM,queue:restart. Write it asdeploy/rollback.shnow, while nothing is on fire. Note that rollback does not undo migrations — which is exactly why step 5's rule exists.
Health checks and knowing it worked
Add a health endpoint and hit it from the workflow after the swap, failing the job if it does not return 200:
// bootstrap/app.php
->withRouting(
health: '/up',
)
- name: Smoke test
run: |
curl --fail --retry 5 --retry-delay 3 https://app.example.com/up
Then send a deploy marker to whatever you use for monitoring — Nightwatch, Sentry, a Slack webhook — so that a spike in errors can be lined up against a release. Pair this with the instrumentation from our Laravel observability guide and you can answer "did the deploy do this?" in seconds.
If you would rather not maintain this
Forge plus Envoyer gives you the same release-directory model with a UI; Laravel Cloud handles builds, releases, workers and scheduling as a managed platform. Both are good answers, and the constraints in steps 3 and 5 still apply — atomic swaps do not make an incompatible migration safe.
We design and run deployment pipelines like this for client teams, including migrating hand-rolled git pull deploys onto something you can trust on a Friday afternoon. See our Laravel Cloud, Forge and deployment engineering service, or get in touch with your current setup and we will tell you what we would change first.