We earn commissions when you shop through the links below.
If you’ve been deploying Laravel apps by SSH-ing into a server and running git pull manually, this guide is for you. Knowing how to set up CI/CD for Laravel on GitHub Actions is one of those skills that pays dividends immediately — you get automated testing, consistent deployments, and way less room for human error. I’ve set this up across several production Laravel apps and I’ll walk you through exactly what works.
Why GitHub Actions for Laravel?
GitHub Actions is free for public repos and has a generous free tier for private ones. More importantly, it lives right next to your code. No third-party CI service to authenticate, no webhook configuration to debug. You push a commit, the pipeline runs. It’s that direct.
The workflow I’ll show you covers:
- Running PHPUnit tests with a real MySQL database
- Linting with Pint or PHP CS Fixer
- Deploying to a VPS on merge to
main
Project Prerequisites
You’ll need:
- A Laravel app hosted on GitHub
- A server running PHP 8.x — I use DigitalOcean droplets for most of my Laravel projects, they’re reliable and cheap for this kind of work
- SSH access to that server
- Composer installed on the server
Step 1: Create the Workflow File
GitHub Actions workflows live in .github/workflows/. Create a file called ci.yml in that directory. Here’s a complete workflow that runs tests on every push and deploys on merges to main:
name: Laravel CI/CD
on:
push:
branches: [ "*" ]
pull_request:
branches: [ "main" ]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: laravel_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, bcmath, pdo_mysql
coverage: none
- name: Copy .env
run: cp .env.example .env.testing
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Generate app key
run: php artisan key:generate --env=testing
- name: Run migrations
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: laravel_test
DB_USERNAME: root
DB_PASSWORD: root
run: php artisan migrate --env=testing --force
- name: Run tests
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: laravel_test
DB_USERNAME: root
DB_PASSWORD: root
run: php artisan test --parallel
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /var/www/your-app
git pull origin main
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
This is a real, production-ready workflow. Let me break down the important parts.
Step 2: Configure GitHub Secrets
The deploy job references three secrets. Go to your repo on GitHub, then Settings → Secrets and variables → Actions and add:
SERVER_HOST— your server’s IP or domainSERVER_USER— the SSH user (e.g.,deployerorwww-data)SERVER_SSH_KEY— the private SSH key (paste the full contents of your~/.ssh/id_rsa)
For the SSH key, generate a dedicated deploy key on your server:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key
cat ~/.ssh/deploy_key.pub >> ~/.ssh/authorized_keys
cat ~/.ssh/deploy_key # paste this into SERVER_SSH_KEY secret
Never commit SSH keys or any credentials to your repo. The secrets vault in GitHub Actions is the right place for all of this.
Step 3: Prepare Your Server
Before the first automated deploy, your server needs a working checkout of the repo. SSH in and clone it manually once:
cd /var/www
git clone git@github.com:your-org/your-app.git your-app
cd your-app
cp .env.example .env
# Fill in .env with production values
php artisan key:generate
php artisan migrate
After this initial setup, every push to main will trigger the automated deploy job — no more manual SSH sessions.
Step 4: Add Code Quality Checks (Optional but Recommended)
I usually add a linting step before the test job runs. If you’re using Laravel Pint:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run Pint
run: ./vendor/bin/pint --test
Then update the test job to needs: lint so linting always runs first. The pipeline fails fast on style issues before wasting time running migrations and tests.
Step 5: Caching Composer Dependencies
If your pipeline feels slow, add Composer caching. This cuts install time significantly on subsequent runs:
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
Insert these steps before the Install dependencies step. The cache key is based on composer.lock, so it invalidates automatically when you add or update packages.
Deployment Alternatives
If you don’t want to manage your own VPS, Railway is worth considering for Laravel deployments — it supports PHP natively, handles environment variables cleanly, and deploys automatically from GitHub without you needing to manage SSH keys or server config at all. You’d replace the deploy job with a simple Railway CLI call or just let Railway’s GitHub integration handle it.
Common Issues and Fixes
MySQL connection refused: The health check in the service definition handles this, but make sure you’re connecting to 127.0.0.1 not localhost. PHP’s MySQL driver treats them differently.
Permission errors on deploy: Make sure the SSH user owns the app directory and has write permissions. Running deploys as root works but isn’t best practice — create a dedicated deployer user with access only to the web directory.
App downtime during deploy: The basic script above has a brief window where the app could serve errors during migrations. For zero-downtime deploys, look into tools like Deployer or Envoyer, which use an atomic symlink strategy.
Queue workers not restarting: The queue:restart command signals workers to restart gracefully after they finish their current job. Make sure you’re running Supervisor or a similar process manager — otherwise queue workers keep running old code.
Wrapping Up
That’s the full picture of how to set up CI/CD for Laravel on GitHub Actions. You’ve got automated testing with a real MySQL service, code quality checks, Composer caching, and SSH-based deployment to a VPS — all triggered automatically on every push.
The workflow file I’ve shown here is close to what I run in production. Start with it as-is, then layer in additional steps as your project grows: static analysis with Larastan, browser tests with Dusk, or deployment notifications via Slack.
If you want to go deeper on Laravel architecture and DevOps patterns, Udemy has solid Laravel courses that cover testing and deployment workflows in much more depth than a single blog post can.
Once you’ve done this once, you’ll never want to go back to manual deployments. The feedback loop alone — knowing within two minutes whether your PR breaks anything — is worth the setup time.