Tous les articles
CI/CDDevOpsLaravel

GitHub Actions CI/CD: Zero-Downtime Deploy for Laravel and Next.js on a VPS

//
·8 min de lecture

A complete GitHub Actions workflow: Composer and npm caching, automated tests, SSH deploy via rsync with atomic symlinks for zero downtime, Artisan migrations, and Slack deployment notifications.

A solid CI/CD pipeline does three things: catch regressions before they reach production, deploy fast without downtime, and roll back reliably if something breaks. This guide builds a GitHub Actions workflow that checks all three boxes for a monorepo containing a Laravel API and a Next.js frontend.

>Workflow structure

yaml
# .github/workflows/deploy.yml
name: CI → Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true       # cancel superseded runs

jobs:
  test-laravel:
    uses: ./.github/workflows/_test-laravel.yml
  test-nextjs:
    uses: ./.github/workflows/_test-nextjs.yml
  deploy:
    needs: [test-laravel, test-nextjs]
    if: github.ref == 'refs/heads/main'
    uses: ./.github/workflows/_deploy.yml
    secrets: inherit

>Laravel test job with caching

yaml
# .github/workflows/_test-laravel.yml
jobs:
  laravel:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_DB: testing
          POSTGRES_USER: laravel
          POSTGRES_PASSWORD: secret
        options: --health-cmd pg_isready

    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_pgsql, redis, pcntl, zip
          coverage: pcov

      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - run: composer install --no-interaction --prefer-dist

      - name: Run tests
        env:
          DB_CONNECTION: pgsql
          DB_HOST: localhost
          DB_DATABASE: testing
          DB_USERNAME: laravel
          DB_PASSWORD: secret
        run: php artisan test --parallel --coverage --min=80

>Next.js test job with caching

yaml
# .github/workflows/_test-nextjs.yml
jobs:
  nextjs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Type check
        run: npx tsc --noEmit

      - name: Lint
        run: npx eslint . --max-warnings 0

      - name: Build
        run: npm run build

      - name: Unit tests
        run: npm test -- --coverage

>Zero-downtime deploy with atomic symlinks

yaml
# .github/workflows/_deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy via rsync
        uses: easingthemes/ssh-deploy@v5
        with:
          SSH_PRIVATE_KEY: ${{ secrets.VPS_SSH_KEY }}
          REMOTE_HOST: ${{ secrets.VPS_HOST }}
          REMOTE_USER: deploy
          SOURCE: "."
          TARGET: /var/www/releases/${{ github.sha }}
          EXCLUDE: ".git,node_modules,tests"

      - name: Activate release
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: deploy
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            RELEASE=/var/www/releases/${{ github.sha }}
            CURRENT=/var/www/current

            # Symlink shared storage and .env
            ln -sfn /var/www/shared/.env        $RELEASE/.env
            ln -sfn /var/www/shared/storage      $RELEASE/storage

            # Install & build
            cd $RELEASE
            composer install --no-dev --optimize-autoloader --no-interaction
            php artisan config:cache && php artisan route:cache

            # Atomic cutover (single rename — no downtime)
            ln -sfn $RELEASE $CURRENT

            # Migrate after cutover
            php artisan migrate --force

            # Reload PHP-FPM
            sudo systemctl reload php8.3-fpm

            # Keep last 5 releases
            ls -dt /var/www/releases/* | tail -n +6 | xargs rm -rf

>Slack notification

yaml
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            {
              "text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *${{ github.ref_name }}* — ${{ github.sha }}",
              "blocks": [{
                "type": "section",
                "text": {
                  "type": "mrkdwn",
                  "text": "${{ job.status == 'success' && '✅ Deployed' || '❌ Deploy failed' }} — <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>"
                }
              }]
            }