All Articles
14 Jul 2026 10 min read 2 views
GitHub

Auto-Deploy Laravel to AWS with GitHub Actions (CI/CD Guide)

Learn how to build a zero-downtime GitHub Actions pipeline that auto-deploys your Laravel app to AWS EC2. Full YAML workflow + rollback strategy included.

Tushar Modi.
Tushar Modi.
July 14, 2026 · Jaipur, India
10 min 2
Category GitHub
Published Jul 14, 2026
Read 10 min
Views 2
Updated Jul 14, 2026
Auto-Deploy Laravel to AWS with GitHub Actions (CI/CD Guide)

Auto-Deploy Laravel to AWS with GitHub Actions: A Zero-Downtime CI/CD Pipeline

If you're still deploying with git pull over SSH and crossing your fingers, this one's for you. Here's how to build a complete GitHub Actions pipeline that tests, builds, and deploys your Laravel app to an AWS EC2 instance automatically — with zero downtime, instant rollback, and no third-party deployment SaaS in the loop.

Table of Contents

  1. Why Manual Deployments Are Killing Your Velocity
  2. What We're Building
  3. Prerequisites
  4. Step 1: Server-Side Setup
  5. Step 2: Repository Secrets
  6. Step 3: The Complete Workflow File
  7. Step 4: Understanding the Symlink Swap
  8. Step 5: Instant Rollback
  9. Leveling Up: Staging Environments and OIDC Auth
  10. Handling Migrations Safely
  11. Adding Deploy Notifications
  12. Common Pitfalls to Avoid
  13. FAQ
  14. Wrapping Up

Why Manual Deployments Are Killing Your Velocity

Every manual deploy is a chance to forget a migration, skip a cache clear, or push untested code straight to production. It works fine when it's just you and a side project. It stops working the moment you have a team, a client with an SLA, or a codebase big enough that "I'll just remember to run artisan optimize" becomes a lie you tell yourself.

Automating this with GitHub Actions means every push to main goes through the exact same tested, repeatable path — no exceptions, no "it worked on my machine," no 2 AM "why is the site down" moments. And once it's set up, it costs you nothing per deploy. You push, you get a coffee, it's live.

What We're Building

The pipeline runs two jobs on every push to main:

Job 1 — Test:

  • Spins up a fresh MySQL container
  • Installs Composer dependencies
  • Runs migrations against the test database
  • Runs the full PHPUnit/Pest suite

Job 2 — Deploy (only runs if tests pass):

  • Installs production dependencies (--no-dev, optimized autoloader)
  • Builds frontend assets with Vite/npm
  • Rsyncs the build into a timestamped release folder on EC2
  • Links shared .env and storage/
  • Runs migrations
  • Rebuilds Laravel's config/route/view caches
  • Atomically swaps a current symlink to the new release
  • Reloads PHP-FPM
  • Prunes old releases, keeping the last 5 for rollback

This is the same release-folder pattern tools like Envoyer and Deployer use under the hood — you're just building it yourself, for free, with full control over every step.

Prerequisites

  • An EC2 instance with PHP 8.3, Composer, Node.js, and Nginx already configured
  • A dedicated deploy user with SSH key access (never use root for this)
  • Your app's .env file already sitting on the server (never commit it to the repo)
  • A security group that allows SSH (port 22) from GitHub's runner IP ranges, or better, from a bastion/VPN

Step 1: Server-Side Setup

Before touching GitHub Actions, get the folder structure right on EC2. This is the foundation the whole pipeline depends on:


bash

/var/www/myapp/
├── releases/
│   ├── 20260709120000/
│   ├── 20260710093000/
│   └── 20260711143200/
├── shared/
│   ├── .env
│   └── storage/
└── current -> releases/20260711143200/

Create it once, manually:


bash

sudo mkdir -p /var/www/myapp/{releases,shared/storage}
sudo chown -R deployer:www-data /var/www/myapp

Point your Nginx root at /var/www/myapp/current/public — not at a fixed release folder. This one config detail is what makes zero-downtime deploys possible later.


nginx

server {
    listen 80;
    server_name myapp.com;
    root /var/www/myapp/current/public;

    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

Step 2: Add Repository Secrets

Go to Settings → Secrets and variables → Actions and add:

SecretValueEC2_HOSTYour instance's public IP or domainEC2_USERDeploy user (e.g. deployer)EC2_SSH_KEYPrivate key contents (PEM format)EC2_APP_PATHe.g. /var/www/myappSLACK_WEBHOOK_URL(optional) for deploy notifications

Generate a dedicated deploy key rather than reusing your personal SSH key:


bash

ssh-keygen -t ed25519 -f deploy_key -C "github-actions-deploy" -N ""
# Add deploy_key.pub to ~/.ssh/authorized_keys on the server
# Paste the contents of deploy_key (private) into the EC2_SSH_KEY secret

Step 3: The Complete Workflow File

Create .github/workflows/deploy.yml:


yaml

name: Deploy to AWS

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: testing
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_mysql, bcmath
          coverage: none

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Install Composer dependencies
        run: composer install --prefer-dist --no-progress

      - name: Copy .env for testing
        run: cp .env.testing .env

      - name: Generate key
        run: php artisan key:generate

      - name: Run migrations
        run: php artisan migrate --force

      - name: Run tests
        run: php artisan test

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'

      - name: Install dependencies (production)
        run: composer install --optimize-autoloader --no-dev

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Build frontend assets
        run: |
          npm ci
          npm run build

      - name: Set release timestamp
        run: echo "RELEASE=$(date +%Y%m%d%H%M%S)" >> $GITHUB_ENV

      - name: Setup SSH
        uses: webfactory/[email protected]
        with:
          ssh-private-key: ${{ secrets.EC2_SSH_KEY }}

      - name: Create release folder
        run: |
          ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} \
            "mkdir -p ${{ secrets.EC2_APP_PATH }}/releases/${{ env.RELEASE }}"

      - name: Sync build to release folder
        run: |
          rsync -az --exclude='.git' --exclude='node_modules' --exclude='tests' ./ \
            ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:${{ secrets.EC2_APP_PATH }}/releases/${{ env.RELEASE }}

      - name: Link shared files, migrate, and cut over
        run: |
          ssh -o StrictHostKeyChecking=no ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} '
            set -e
            RELEASE_PATH=${{ secrets.EC2_APP_PATH }}/releases/${{ env.RELEASE }}
            cd $RELEASE_PATH

            ln -nfs ${{ secrets.EC2_APP_PATH }}/shared/.env .env
            ln -nfs ${{ secrets.EC2_APP_PATH }}/shared/storage storage

            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache

            ln -nfs $RELEASE_PATH ${{ secrets.EC2_APP_PATH }}/current
            sudo systemctl reload php8.3-fpm

            cd ${{ secrets.EC2_APP_PATH }}/releases
            ls -1t | tail -n +6 | xargs -r rm -rf
          '

      - name: Notify Slack
        if: always()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: repo,message,commit,author
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Step 4: Understanding the Symlink Swap

The current symlink is the trick that gets you zero downtime, and it's worth understanding exactly why it works.

Nginx's root directive resolves the path on every request, following symlinks as it goes. Because current is a symlink and not a real directory, repointing it with ln -nfs is a single atomic filesystem operation — it either fully succeeds or doesn't happen at all. There's no window where half the old release and half the new release are being served simultaneously, which is exactly the failure mode you get with in-place git pull deploys.

Every deploy builds a completely fresh, isolated release folder. Nothing about the live site is touched until that very last ln -nfs line runs. If the rsync fails, if a migration errors out, if the build breaks — your production site keeps serving the last known-good release, untouched.

Step 5: Instant Rollback

Because old releases are kept (the script above retains the last 5), rolling back doesn't require a redeploy or waiting on CI. SSH in and repoint the symlink:


bash

ln -nfs /var/www/myapp/releases/20260710093000 /var/www/myapp/current
sudo systemctl reload php8.3-fpm

That's it. You're back to a known-good state in the time it takes to run two commands. Worth scripting this into a small rollback.sh on the server so it's a one-liner in an emergency.

Leveling Up: Staging Environments and OIDC Auth

Once the single-environment pipeline is solid, two upgrades are worth making:

Staging environment via matrix/environment config — duplicate the deploy job with a staging branch trigger and separate STAGING_EC2_HOST/STAGING_EC2_APP_PATH secrets, using GitHub Environments to require manual approval before production deploys.

Ditch long-lived SSH keys for AWS OIDC — if you're deploying to services like ECS, Elastic Beanstalk, or S3/CloudFront instead of raw EC2, GitHub Actions supports OpenID Connect federation with AWS IAM. Instead of storing a static access key as a secret, GitHub requests short-lived credentials scoped to a specific IAM role for the duration of the job. This removes an entire class of risk — there's no long-lived AWS credential sitting in your repo secrets at all.


yaml

- name: Configure AWS credentials via OIDC
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
    aws-region: ap-south-1

For pure EC2 + SSH setups like the one in this guide, a well-scoped deploy-only SSH key is still the pragmatic choice — just make sure it can't do anything beyond writing to the release folder and running the deploy script.

Handling Migrations Safely

Migrations are the riskiest part of any automated deploy because they're not easily reversible in production. A few habits that save you from a bad night:

  • Always write a working down() method, even if you think you'll never need it
  • For anything destructive (dropping a column, renaming a table), split it into two deploys — first add the new structure and dual-write, then remove the old structure in a later deploy once you've confirmed nothing depends on it
  • Take an automated RDS/EC2 database snapshot before migrations run on production, either via a scheduled AWS Backup plan or a step in the workflow that calls the AWS CLI
  • Never run migrate:fresh or migrate:refresh anywhere near a production deploy script — migrate --force only

Adding Deploy Notifications

The Slack step included in the workflow above posts a pass/fail status with the commit and author to a channel of your choice, using a webhook URL stored as a secret. This matters more than it sounds like — the first time a deploy silently fails at 11 PM because a migration conflicted, you'll be glad the whole team saw it in Slack instead of finding out from a user complaint the next morning.

Common Pitfalls to Avoid

  • Committing .env to the repo — always keep it in shared/ on the server and symlink it in per release
  • Forgetting --force on migrations — Laravel blocks migrations in production without it, by design, and it's easy to lose an hour wondering why nothing happened
  • Not excluding storage/ from rsync — this can wipe user uploads and logs on every deploy if the shared symlink isn't set up correctly first
  • Skipping the test job to save time — the 60–90 seconds it costs is far cheaper than a broken production deploy
  • Using root or a shared personal SSH key — always use a dedicated, narrowly-scoped deploy user and key
  • No rollback plan — if you can't answer "how do I undo this deploy in under a minute," the pipeline isn't finished yet

FAQ

Does this work with Laravel Forge or Vapor already set up? You don't need this if you're already on Forge, Vapor, or Envoyer — they solve the same problem as a managed service. This pipeline is for teams that want the same guarantees without a monthly subscription, or that need more control over the exact deploy steps.

What about zero-downtime with queue workers? Add php artisan queue:restart as a step after the cutover — Laravel's queue workers pick up new code on their next job after a restart signal, without dropping in-flight jobs.

Can this deploy to multiple EC2 instances behind a load balancer? Yes — loop the rsync/SSH steps over a list of hosts stored as a secret, or use an AWS Systems Manager Run Command / CodeDeploy target group instead of raw SSH once you're past 2–3 servers.

Wrapping Up

This pipeline is intentionally dependency-light — no third-party deployment SaaS, no Kubernetes, just GitHub Actions, SSH, and a battle-tested release pattern. It scales comfortably for most Laravel apps running on a single EC2 instance or a small fleet behind a load balancer, and every piece of it is something you fully own and can debug at 2 AM without waiting on a vendor's support ticket.

Got a deployment setup you're trying to automate, or hit a wall with something similar? Drop a comment or reach out — always happy to talk shop.