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

· 5 min read

In Part 4, we configured ALB, CloudFront, and SSL. Infrastructure is complete. Now let's automate deployments and set up monitoring.

Note: This part combines Console (monitoring/alarms) with code (CI/CD pipeline). GitHub Actions and Envoy require file-based configuration.

Laravel Envoy (Deploy Script)

composer require laravel/envoy --dev

Create Envoy.blade.php at 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 }}
    ln -sf {{ $sharedDir }}/.env .env
    rm -rf storage
    ln -sf {{ $sharedDir }}/storage storage
    mkdir -p {{ $sharedDir }}/storage/{app/public,framework/{cache/data,sessions,views},logs}
    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
    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..."
    ln -sfn {{ $newReleaseDir }} {{ $appDir }}/current
    sudo systemctl reload php-fpm
    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
@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

GitHub Actions Workflow

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
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, xml, curl, zip, gd, intl, bcmath, pdo_mysql, redis
      - run: composer install --prefer-dist --no-interaction
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci && npm run build
      - run: cp .env.example .env && php artisan key:generate
      - run: php artisan test
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          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
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
      - run: composer global require laravel/envoy
      - name: Setup SSH
        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
      - run: envoy run deploy
      - name: Invalidate CloudFront
        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/*" "/"

Monitoring via Console: CloudWatch

Create SNS Topic

  1. Search β†’ "SNS" β†’ Topics β†’ Create topic
  2. Type: Standard, Name: laravel-alerts
  3. Create β†’ Create subscription β†’ Protocol: Email β†’ your email
  4. Confirm via email

CloudWatch Alarm: High CPU

  1. Search β†’ "CloudWatch" β†’ Alarms β†’ Create alarm
  2. Select metric β†’ EC2 β†’ Per-Instance β†’ laravel-web-01 β†’ CPUUtilization
  3. Threshold: Greater than 80, Period: 5 min
  4. Notification: laravel-alerts
  5. Name: laravel-ec2-high-cpu

CloudWatch Alarm: ALB 5xx

  1. Create alarm β†’ ApplicationELB β†’ laravel-alb β†’ HTTPCode_Target_5XX_Count
  2. Threshold: Sum > 10, Period: 1 min
  3. Name: laravel-alb-5xx

CloudWatch Alarm: RDS Low Storage

  1. Create alarm β†’ RDS β†’ laravel-mysql β†’ FreeStorageSpace
  2. Threshold: Less than 2,000,000,000 (2 GB)
  3. Name: laravel-rds-low-storage

Dashboard (Optional)

  1. CloudWatch β†’ Dashboards β†’ Create dashboard
  2. Name: laravel-production
  3. Add widgets: EC2 CPU, ALB RequestCount, RDS FreeStorageSpace, ElastiCache CacheHitRate

Final Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        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 + Dashboard                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Monthly Cost

Service Config 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

Series Summary

Part Content Console or CLI?
Part 0 Account, IAM, billing Console
Part 1 VPC, subnets, security groups, IAM roles Console
Part 2 EC2 launch (Console) + server setup (SSH) Both
Part 3 RDS, S3, ElastiCache Console + SSH config
Part 4 ACM, ALB, Route 53, CloudFront Console
Part 5 CI/CD, monitoring Code + Console

From a visual interface to a complete production infrastructure with zero-downtime deployments on AWS.


Series Navigation:

Comments