Deploy Laravel with GitHub Actions on CloudPloy
Automating your Laravel deployment pipeline with GitHub Actions eliminates manual deployments, reduces human error, and ensures every push to production goes through the same tested, consistent process. This guide covers a complete CI/CD setup for Laravel on CloudPloy - from the initial workflow file through zero-downtime deployments and rollback strategies.
What you'll set up: automated testing on every pull request, staging deployments on merge to develop, and zero-downtime production deployments on merge to main.
Time required: 30-45 minutes
Difficulty: Intermediate
Prerequisites: CloudPloy account with a server, Laravel project in a GitHub repository, basic SSH knowledge
How GitHub Actions Works with CloudPloy
GitHub Actions runs your deployment workflow in a fresh Ubuntu container hosted by GitHub. The workflow connects to your CloudPloy server via SSH, pulls the latest code, installs dependencies, runs migrations, and restarts the application - all without manual steps.
For Laravel, the recommended pipeline is:
- Run PHPUnit tests and static analysis on every push
- Deploy to staging when code merges to the
developbranch - Deploy to production when code merges to
main
Step 1: Server Preparation
Before creating the workflow, prepare your CloudPloy server for automated deployments.
Create a Deployment User
Never deploy as root. Create a dedicated deployment user with limited permissions:
# On your CloudPloy server (connect via SSH as root first)
sudo adduser deploy
sudo usermod -aG www-data deploy
# Create the deployment directory structure for zero-downtime releases
sudo mkdir -p /var/www/laravel/releases
sudo mkdir -p /var/www/laravel/shared/storage
sudo chown -R deploy:www-data /var/www/laravel
sudo chmod -R 2775 /var/www/laravel Generate an SSH Key Pair for Deployments
Generate a dedicated SSH key pair specifically for GitHub Actions. Do this on your local machine:
# Generate an Ed25519 key (no passphrase - required for automation)
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_actions_deploy Add the public key to the deploy user's authorized keys on your server:
# On your CloudPloy server, as the deploy user:
mkdir -p /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
# Paste the public key (cat ~/.ssh/github_actions_deploy.pub) into:
nano /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh Configure Nginx for Zero-Downtime
The atomic symlink strategy uses a current symlink pointing to the active release. Configure Nginx to serve from /var/www/laravel/current/public:
server {
listen 80;
server_name yourdomain.com;
root /var/www/laravel/current/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
} Step 2: Configure GitHub Secrets
Go to your GitHub repository, click Settings > Secrets and variables > Actions and add these secrets:
| Secret Name | Value | Notes |
|---|---|---|
PRODUCTION_SSH_HOST | Your CloudPloy server IP | Found in CloudPloy dashboard under Servers |
PRODUCTION_SSH_USER | deploy | The deployment user created above |
PRODUCTION_SSH_KEY | Private key file contents | Full content of ~/.ssh/github_actions_deploy |
PRODUCTION_SSH_PORT | 22 | Or your custom SSH port |
STAGING_SSH_HOST | Staging server IP | Can reuse same server with different app path |
To get the private key: cat ~/.ssh/github_actions_deploy - copy the entire output including the -----BEGIN OPENSSH PRIVATE KEY----- header and footer lines.
Step 3: The Complete Workflow File
Create .github/workflows/deploy.yml in your Laravel repository. Note: GitHub Actions uses {{ expression }} syntax for variable substitution:
name: Laravel CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: laravel_test
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP 8.2
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, xml, ctype, json, bcmath, pdo, pdo_mysql
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: vendor
key: RUNNER_OS-composer-COMPOSER_HASH
restore-keys: RUNNER_OS-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Setup environment
run: |
cp .env.example .env
php artisan key:generate
sed -i 's/DB_DATABASE=laravel/DB_DATABASE=laravel_test/' .env
sed -i 's/DB_PASSWORD=/DB_PASSWORD=password/' .env
- name: Run migrations
run: php artisan migrate --force
- name: Run PHPUnit tests
run: php artisan test --parallel
deploy-staging:
name: Deploy to Staging
needs: test
if: github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
steps:
- name: Deploy to staging via SSH
uses: appleboy/ssh-action@v1.0.0
with:
host: STAGING_SSH_HOST_SECRET
username: PRODUCTION_SSH_USER_SECRET
key: PRODUCTION_SSH_KEY_SECRET
port: PRODUCTION_SSH_PORT_SECRET
script: |
cd /var/www/laravel-staging
git pull origin develop
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
deploy-production:
name: Deploy to Production (Zero-Downtime)
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy to production via SSH
uses: appleboy/ssh-action@v1.0.0
with:
host: PRODUCTION_SSH_HOST_SECRET
username: PRODUCTION_SSH_USER_SECRET
key: PRODUCTION_SSH_KEY_SECRET
port: PRODUCTION_SSH_PORT_SECRET
script: |
TIMESTAMP=$(date +%Y%m%d%H%M%S)
RELEASE_DIR=/var/www/laravel/releases/$TIMESTAMP
SHARED_DIR=/var/www/laravel/shared
# Clone into new release directory
git clone --depth=1 --branch=main git@github.com:yourorg/yourrepo.git $RELEASE_DIR
# Link shared .env and storage
ln -nfs $SHARED_DIR/.env $RELEASE_DIR/.env
rm -rf $RELEASE_DIR/storage
ln -nfs $SHARED_DIR/storage $RELEASE_DIR/storage
# Install and cache
cd $RELEASE_DIR
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Set permissions
chown -R deploy:www-data $RELEASE_DIR
# Atomic swap: update the current symlink
ln -nfs $RELEASE_DIR /var/www/laravel/current
# Reload PHP-FPM to clear OPcache
sudo systemctl reload php8.2-fpm
# Restart queue workers with new code
php artisan queue:restart
# Keep only the last 5 releases
ls -dt /var/www/laravel/releases/* | tail -n +6 | xargs rm -rf
In a real workflow file, replace STAGING_SSH_HOST_SECRET etc. with the actual GitHub Actions expression syntax: secrets.STAGING_SSH_HOST wrapped in curly braces as shown in the GitHub Actions documentation.
Step 4: Zero-Downtime Deployment Explained
The atomic symlink strategy works because the Linux ln -nfs operation is atomic - the symlink update happens instantaneously. Here is what happens during each deployment:
- Clone to new directory: New code goes into
releases/20260328140000/. Live traffic still hits the old release via thecurrentsymlink. - Link shared resources: The
.envfile andstorage/directory are symlinked from a shared location - user uploads and logs persist across deployments. - Install and cache: Composer, migrations, and all Laravel caches run in the new release directory while production traffic is unaffected.
- Atomic swap:
ln -nfspointscurrentat the new release. All new requests immediately go to new code. Zero downtime. - OPcache reload: PHP-FPM reload flushes OPcache so PHP uses the new bytecode.
- Cleanup: Old releases beyond 5 are deleted.
Rollback Strategy
# Roll back to the previous release instantly
PREVIOUS=$(ls -dt /var/www/laravel/releases/* | sed -n '2p')
ln -nfs $PREVIOUS /var/www/laravel/current
sudo systemctl reload php8.2-fpm
php artisan queue:restart
echo "Rolled back to: $PREVIOUS" Step 5: Managing Environment Variables Securely
Option A: Encode .env as a Single Secret
# On your local machine - encode production .env
base64 -i .env.production
# Copy the output and store it as GitHub secret PRODUCTION_ENV In the deployment script, decode it:
# In your SSH deployment script:
echo "$PRODUCTION_ENV" | base64 -d > /var/www/laravel/shared/.env Option B: Compose .env from Individual Secrets
# In your deployment step's script section:
cat > /var/www/laravel/shared/.env << EOF
APP_NAME=Laravel
APP_ENV=production
APP_KEY=$APP_KEY_SECRET
APP_DEBUG=false
APP_URL=https://yourdomain.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=laravel_prod
DB_USERNAME=laravel
DB_PASSWORD=$DB_PASSWORD_SECRET
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
EOF Option A is simpler for updates (re-encode one file). Option B is more explicit about which variables exist. Both are secure - GitHub secrets are encrypted at rest and masked in logs.
Step 6: Safe Database Migrations
Always Back Up Before Migrating
# Add this before php artisan migrate in your deployment script
BACKUP_FILE=/var/www/backups/pre-deploy-$(date +%Y%m%d%H%M%S).sql.gz
mysqldump -h 127.0.0.1 -u laravel -p"$DB_PASSWORD" laravel_prod | gzip > $BACKUP_FILE Write Backwards-Compatible Migrations
For zero-downtime deployments, migrations must work with both the old and new version of your code simultaneously. The rules:
- Adding a nullable column: safe - old code ignores it, new code uses it
- Renaming a column: NOT safe directly - add new column, backfill, update code, then drop old column in a second deployment
- Dropping a column: remove code references first, deploy, then drop the column
- Adding an index on a large table: can lock the table - use
--algorithm=INPLACE --lock=NONEin the migration
Troubleshooting Common Issues
SSH Connection Refused
- Verify the private key has no passphrase:
ssh-keygen -y -f ~/.ssh/github_actions_deployshould not prompt for input - Test the connection manually:
ssh -i ~/.ssh/github_actions_deploy deploy@YOUR_SERVER_IP - Confirm the public key is in
/home/deploy/.ssh/authorized_keysas a single unbroken line - Check sshd allows public key auth:
grep PubkeyAuthentication /etc/ssh/sshd_config
Composer Memory Exhaustion
# Add to deployment script if composer fails on small servers
COMPOSER_MEMORY_LIMIT=-1 composer install --no-dev --optimize-autoloader PHP Showing Old Code After Deployment
This means OPcache was not cleared. The deploy user needs passwordless sudo for the reload command:
# Add to /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload php8.2-fpm Queue Workers Running Old Code
php artisan queue:restart signals workers to exit gracefully after their current job. They restart automatically via Supervisor with new code. Verify Supervisor is configured with autorestart=true for your queue worker process.
Permission Errors on Deployed Files
# Fix after deployment
cd /var/www/laravel/current
sudo chown -R deploy:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache Advanced: Slack Deployment Notifications
# Add these steps at the end of your deploy-production job:
- name: Notify on success
if: success()
uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: SLACK_WEBHOOK_SECRET
SLACK_MESSAGE: "Production deployment succeeded"
SLACK_COLOR: good
- name: Notify on failure
if: failure()
uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: SLACK_WEBHOOK_SECRET
SLACK_MESSAGE: "FAILED: Production deployment - check GitHub Actions"
SLACK_COLOR: danger Frequently Asked Questions
Can I deploy multiple Laravel apps on one CloudPloy server?
Yes. Each application gets its own directory (e.g., /var/www/app1, /var/www/app2), its own Nginx server block, its own database, and its own current symlink. Use separate GitHub secrets per app if they use different deploy users or ports.
What happens if a migration fails mid-deployment?
The deployment script exits with a non-zero status. Because the symlink swap happens after migrations, the live site continues serving the old release uninterrupted. Fix the migration in a new commit and re-deploy.
Should I use GitHub Environments for production protection?
Yes - especially for teams. Go to Settings > Environments > New environment, name it production, add required reviewers, and restrict to the main branch. Production deployments will pause for manual approval before proceeding.
How do I run this workflow for the first time?
On first run, manually create the shared/.env on the server and run php artisan migrate once to establish the database baseline. After that, all subsequent deployments use the automated workflow.
Can I use this with Laravel Octane?
Yes, but replace systemctl reload php8.2-fpm with php artisan octane:reload since Octane keeps the application in memory rather than relying on PHP-FPM's file-based request handling.
Related guides: Laravel Deployment Overview | All Laravel Help Articles | Contact Support
Last updated: 2026-03-28