Deploy Laravel to AWS (Part 5): CI/CD with GitHub Actions & Zero-Downtime Deploy

· 8 min read

In Part 4, we set up ALB, CloudFront, and SSL. The infrastructure is complete. Now let's automate the deploy process so you never SSH into production to deploy again.

We set up the directory structure in Part 2:

/var/www/laravel/
├── current → releases/20260330_120000/    (symlink, atomic switch)
├── releases/
│   ├── 20260330_120000/                   (latest)
│   ├── 20260329_150000/                   (previous)
│   └── ...
└── shared/
    ├── .env
    └── storage/

Zero-downtime because:

  1. New code is deployed to a fresh releases/ directory
  2. All setup happens in the new directory while the old one keeps serving traffic
  3. The current symlink is switched atomically (ln -sfn)
  4. PHP-FPM is reloaded (graceful — finishes existing requests first)

Laravel Envoy (Deploy Script)

Install Envoy in your Laravel project:

composer require laravel/envoy --dev

Create Envoy.blade.php in the project root:

@servers(['production' => 'ec2-user@your-domain.com'])

@setup
    $repository = 'git@github.com:your/repo.git';
    $branch = $branch ?? 'main';
    $appDir = '/var/www/laravel';
    $releasesDir = $appDir . '/releases';
    $sharedDir = $appDir . '/shared';
    $release = date('Ymd_His');
    $newReleaseDir = $releasesDir . '/' . $release;
    $keepReleases = 5;
@endsetup

@story('deploy')
    clone
    composer
    shared
    build
    optimize
    migrate
    activate
    cleanup
    health-check
@endstory

@task('clone')
    echo "🚀 Cloning repository ({{ $branch }})..."
    git clone --depth 1 --branch {{ $branch }} {{ $repository }} {{ $newReleaseDir }}
@endtask

@task('composer')
    echo "📦 Installing Composer dependencies..."
    cd {{ $newReleaseDir }}
    composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
@endtask

@task('shared')
    echo "🔗 Linking shared resources..."
    cd {{ $newReleaseDir }}

    # Link .env
    ln -sf {{ $sharedDir }}/.env .env

    # Link storage (remove the one from git, link shared)
    rm -rf storage
    ln -sf {{ $sharedDir }}/storage storage

    # Ensure storage structure exists
    mkdir -p {{ $sharedDir }}/storage/{app/public,framework/{cache/data,sessions,views},logs}

    # Fix permissions
    chmod -R 775 {{ $sharedDir }}/storage
    chown -R ec2-user:nginx {{ $sharedDir }}/storage
@endtask

@task('build')
    echo "🏗️ Building frontend assets..."
    cd {{ $newReleaseDir }}
    npm ci --prefer-offline
    npm run build

    # Remove node_modules after build (not needed at runtime)
    rm -rf node_modules
@endtask

@task('optimize')
    echo "⚡ Optimizing Laravel..."
    cd {{ $newReleaseDir }}
    php artisan config:cache
    php artisan route:cache
    php artisan view:cache
    php artisan event:cache
@endtask

@task('migrate')
    echo "🗄️ Running migrations..."
    cd {{ $newReleaseDir }}
    php artisan migrate --force
@endtask

@task('activate')
    echo "🔄 Activating new release..."

    # Atomic symlink switch
    ln -sfn {{ $newReleaseDir }} {{ $appDir }}/current

    # Reload PHP-FPM (graceful restart)
    sudo systemctl reload php-fpm

    # Restart queue workers (pick up new code)
    cd {{ $appDir }}/current
    php artisan queue:restart

    echo "✅ Release {{ $release }} is live!"
@endtask

@task('cleanup')
    echo "🧹 Cleaning old releases..."
    cd {{ $releasesDir }}
    ls -dt */ | tail -n +{{ $keepReleases + 1 }} | xargs -r rm -rf
    echo "Kept last {{ $keepReleases }} releases."
@endtask

@task('health-check')
    echo "🏥 Running health check..."
    sleep 3
    HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/up)
    if [ "$HTTP_STATUS" != "200" ]; then
        echo "❌ Health check failed (HTTP $HTTP_STATUS)! Rolling back..."
        cd {{ $releasesDir }}
        PREVIOUS=$(ls -dt */ | sed -n '2p' | tr -d '/')
        if [ -n "$PREVIOUS" ]; then
            ln -sfn {{ $releasesDir }}/$PREVIOUS {{ $appDir }}/current
            sudo systemctl reload php-fpm
            echo "⏪ Rolled back to $PREVIOUS"
        fi
        exit 1
    fi
    echo "✅ Health check passed!"
@endtask

@task('rollback')
    echo "⏪ Rolling back to previous release..."
    cd {{ $releasesDir }}
    PREVIOUS=$(ls -dt */ | sed -n '2p' | tr -d '/')
    if [ -z "$PREVIOUS" ]; then
        echo "❌ No previous release found!"
        exit 1
    fi
    ln -sfn {{ $releasesDir }}/$PREVIOUS {{ $appDir }}/current
    sudo systemctl reload php-fpm
    cd {{ $appDir }}/current
    php artisan queue:restart
    echo "✅ Rolled back to $PREVIOUS"
@endtask

Test Envoy Locally

# Deploy
php vendor/bin/envoy run deploy

# Deploy specific branch
php vendor/bin/envoy run deploy --branch=feature/new-feature

# Rollback
php vendor/bin/envoy run rollback

GitHub Actions — Automated CI/CD

Create .github/workflows/deploy.yml:

name: Deploy to Production

on:
  push:
    branches: [main]

concurrency:
  group: production-deploy
  cancel-in-progress: false

jobs:
  tests:
    name: Run Tests
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: testing
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3
      redis:
        image: redis:7
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, xml, curl, zip, gd, intl, bcmath, pdo_mysql, redis
          coverage: none

      - name: Install Composer dependencies
        run: composer install --prefer-dist --no-interaction

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install NPM dependencies
        run: npm ci

      - name: Build assets
        run: npm run build

      - name: Prepare Laravel
        run: |
          cp .env.example .env
          php artisan key:generate
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password

      - name: Run tests
        run: php artisan test
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: testing
          DB_USERNAME: root
          DB_PASSWORD: password
          REDIS_HOST: 127.0.0.1

  deploy:
    name: Deploy to Production
    needs: tests
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring

      - name: Install Envoy
        run: composer global require laravel/envoy

      - name: Setup SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -H your-domain.com >> ~/.ssh/known_hosts

      - name: Deploy with Envoy
        run: envoy run deploy

      - name: Invalidate CloudFront cache
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: ap-northeast-1
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/build/*" "/"

      - name: Notify on success
        if: success()
        run: |
          echo "✅ Deployed successfully!"
          # Optional: Send Slack/Discord notification
          # curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
          #   -H 'Content-type: application/json' \
          #   -d '{"text":"🚀 Production deployed: ${{ github.sha }}"}'

      - name: Notify on failure
        if: failure()
        run: |
          echo "❌ Deploy failed!"
          # curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
          #   -H 'Content-type: application/json' \
          #   -d '{"text":"❌ Production deploy FAILED: ${{ github.sha }}"}'

GitHub Secrets to Configure

Go to Repository → Settings → Secrets and variables → Actions:

Secret Value
SSH_PRIVATE_KEY Content of laravel-production.pem
AWS_ACCESS_KEY_ID IAM user for CI/CD (CloudFront invalidation)
AWS_SECRET_ACCESS_KEY IAM user secret
CLOUDFRONT_DISTRIBUTION_ID Your CloudFront distribution ID

EC2: Allow GitHub Actions SSH

Add the GitHub Actions SSH public key to the EC2 instance:

# On EC2
echo "ssh-rsa AAAA..." >> ~/.ssh/authorized_keys

Or use the same key pair — put the private key in GitHub Secrets.

EC2: Allow Envoy to Reload PHP-FPM

Envoy runs as ec2-user but systemctl reload php-fpm needs sudo. Allow it without password:

sudo visudo

Add:

ec2-user ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php-fpm
ec2-user ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart php-fpm

Deploy Flow Visualization

Developer pushes to main
         │
         ▼
GitHub Actions: Run Tests
         │ (pass)
         ▼
GitHub Actions: Deploy
         │
         ├── SSH into EC2
         ├── git clone (new release dir)
         ├── composer install --no-dev
         ├── npm ci && npm run build
         ├── Link shared .env + storage
         ├── php artisan migrate
         ├── php artisan config:cache
         ├── ln -sfn current → new release  ← ZERO DOWNTIME
         ├── systemctl reload php-fpm
         ├── Health check /up
         │   ├── 200 → ✅ Done
         │   └── !200 → ⏪ Auto-rollback
         │
         ▼
CloudFront cache invalidation
         │
         ▼
✅ Live!

Monitoring: CloudWatch Alarms

Set up basic monitoring to catch issues:

CPU Alarm

aws cloudwatch put-metric-alarm \
  --alarm-name "laravel-ec2-high-cpu" \
  --metric-name CPUUtilization \
  --namespace AWS/EC2 \
  --statistic Average \
  --period 300 \
  --threshold 80 \
  --comparison-operator GreaterThanThreshold \
  --evaluation-periods 2 \
  --dimensions Name=InstanceId,Value=i-xxxx \
  --alarm-actions arn:aws:sns:ap-northeast-1:xxxx:alerts

ALB 5xx Alarm

aws cloudwatch put-metric-alarm \
  --alarm-name "laravel-alb-5xx" \
  --metric-name HTTPCode_Target_5XX_Count \
  --namespace AWS/ApplicationELB \
  --statistic Sum \
  --period 60 \
  --threshold 10 \
  --comparison-operator GreaterThanThreshold \
  --evaluation-periods 1 \
  --dimensions \
    Name=LoadBalancer,Value=app/laravel-alb/xxxx \
  --alarm-actions arn:aws:sns:ap-northeast-1:xxxx:alerts

RDS Free Storage Alarm

aws cloudwatch put-metric-alarm \
  --alarm-name "laravel-rds-low-storage" \
  --metric-name FreeStorageSpace \
  --namespace AWS/RDS \
  --statistic Average \
  --period 300 \
  --threshold 2000000000 \
  --comparison-operator LessThanThreshold \
  --evaluation-periods 1 \
  --dimensions Name=DBInstanceIdentifier,Value=laravel-mysql \
  --alarm-actions arn:aws:sns:ap-northeast-1:xxxx:alerts

Log Management

Centralize Laravel Logs

Configure Laravel to log to stderr so CloudWatch can capture it:

// config/logging.php
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['daily', 'stderr'],
    ],
    'stderr' => [
        'driver' => 'monolog',
        'handler' => StreamHandler::class,
        'with' => ['stream' => 'php://stderr'],
        'level' => 'error',
    ],
],

CloudWatch Agent (Optional)

Install the CloudWatch agent to ship logs:

sudo dnf install -y amazon-cloudwatch-agent

# Configure
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-config-wizard

Complete Architecture (Final)

┌──────────────────────────────────────────────────────────────┐
│                        GitHub                                 │
│  Push to main → Actions (test) → Actions (deploy via SSH)    │
└─────────────────────────────┬────────────────────────────────┘
                              │ SSH + Envoy
                              ▼
┌──────────────────────────────────────────────────────────────┐
│                       AWS Cloud                               │
│                                                               │
│  Route 53 → CloudFront (CDN) → ALB (SSL) → EC2              │
│                                              │                │
│                                    ┌─────────┼──────────┐    │
│                                    │         │          │    │
│                                    ▼         ▼          ▼    │
│                                  RDS     ElastiCache    S3   │
│                                 MySQL      Redis       Files │
│                                                               │
│  CloudWatch: Alarms + Logs                                    │
└──────────────────────────────────────────────────────────────┘

Cost Summary (Monthly)

Service Spec Cost
EC2 t3.small ~$19
RDS db.t3.micro, Multi-AZ ~$30
ALB Minimal traffic ~$18
CloudFront 50GB transfer ~$4
ElastiCache cache.t3.micro ~$13
S3 10GB ~$0.25
Route 53 1 zone $0.50
CloudWatch Basic alarms ~$1
Total ~$86/month

Quick Reference: Daily Commands

# Deploy (from local)
php vendor/bin/envoy run deploy

# Deploy specific branch
php vendor/bin/envoy run deploy --branch=hotfix/critical

# Rollback
php vendor/bin/envoy run rollback

# Check server
ssh laravel-prod
sudo systemctl status php-fpm
sudo supervisorctl status
tail -f /var/www/laravel/shared/storage/logs/laravel.log

# Database access
mysql -h laravel-mysql.xxxx.rds.amazonaws.com -u admin -p

Series Recap

Part What We Did
Part 1 VPC, subnets, security groups, IAM roles
Part 2 EC2, Amazon Linux 2023, Nginx, PHP-FPM
Part 3 RDS MySQL, S3 storage, ElastiCache Redis
Part 4 ALB, CloudFront CDN, ACM SSL, Route 53
Part 5 GitHub Actions CI/CD, Envoy, zero-downtime deploy

From a single git push to a fully automated, zero-downtime deployment on AWS.


Series Navigation:

Comments