+1 (415) 599-8902

Zero-Downtime Laravel Deploys with GitHub Actions

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

  • --isolated makes only one server run the migrations when you deploy to several web nodes; the others wait. Without it, two concurrent migrate calls can both try to create the same table.
  • --force is required in production because migrations are destructive-by-default guarded. Pair it with a database backup taken immediately before the deploy.
  • queue:restart does 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:cache is 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:

  1. Add full_name, backfill it in a queued job, and write to both columns.
  2. Switch all reads to full_name. Deploy. Verify.
  3. 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 current to the previous release, reload FPM, queue:restart. Write it as deploy/rollback.sh now, 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.