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
- Search β "SNS" β Topics β Create topic
- Type: Standard, Name:
laravel-alerts - Create β Create subscription β Protocol: Email β your email
- Confirm via email
CloudWatch Alarm: High CPU
- Search β "CloudWatch" β Alarms β Create alarm
- Select metric β EC2 β Per-Instance β
laravel-web-01β CPUUtilization - Threshold: Greater than 80, Period: 5 min
- Notification:
laravel-alerts - Name:
laravel-ec2-high-cpu
CloudWatch Alarm: ALB 5xx
- Create alarm β ApplicationELB β
laravel-albβ HTTPCode_Target_5XX_Count - Threshold: Sum > 10, Period: 1 min
- Name:
laravel-alb-5xx
CloudWatch Alarm: RDS Low Storage
- Create alarm β RDS β
laravel-mysqlβ FreeStorageSpace - Threshold: Less than 2,000,000,000 (2 GB)
- Name:
laravel-rds-low-storage
Dashboard (Optional)
- CloudWatch β Dashboards β Create dashboard
- Name:
laravel-production - 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:
- β Part 0: Prerequisites
- β Part 1: Architecture & VPC
- β Part 2: EC2 & Amazon Linux 2023
- β Part 3: RDS, S3 & ElastiCache
- β Part 4: ALB, CloudFront & SSL
- Part 5: CI/CD & Zero-Downtime Deploy (You are here)