Deploy Laravel to AWS (Part 5): CI/CD with GitHub Actions & Zero-Downtime Deploy
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.
Deploy Strategy: Symlink Releases
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:
- New code is deployed to a fresh
releases/directory - All setup happens in the new directory while the old one keeps serving traffic
- The
currentsymlink is switched atomically (ln -sfn) - 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:
- ← 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)