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>"
}
}]
}