CloudPloy

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:

  1. Run PHPUnit tests and static analysis on every push
  2. Deploy to staging when code merges to the develop branch
  3. 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:

  1. Clone to new directory: New code goes into releases/20260328140000/. Live traffic still hits the old release via the current symlink.
  2. Link shared resources: The .env file and storage/ directory are symlinked from a shared location - user uploads and logs persist across deployments.
  3. Install and cache: Composer, migrations, and all Laravel caches run in the new release directory while production traffic is unaffected.
  4. Atomic swap: ln -nfs points current at the new release. All new requests immediately go to new code. Zero downtime.
  5. OPcache reload: PHP-FPM reload flushes OPcache so PHP uses the new bytecode.
  6. 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=NONE in the migration

Troubleshooting Common Issues

SSH Connection Refused

  • Verify the private key has no passphrase: ssh-keygen -y -f ~/.ssh/github_actions_deploy should 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_keys as 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