Before I set up CI/CD on my projects, deploying felt like a ritual. SSH into the server, pull the code, run migrations, hope nothing breaks. Sometimes it did break. Sometimes I forgot a step. Sometimes I deployed code that had not been tested.
CI/CD removes the human from the deployment process. Every push runs the tests. If they pass, the code deploys automatically. If they fail, nothing goes to production.
Here is how to set it up with GitHub Actions — it is free for public repos and has a generous free tier for private ones.
The workflow file
Create .github/workflows/laravel.yml in your project:
name: Laravel CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: testing
MYSQL_ROOT_PASSWORD: password
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s
steps:
- uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo, pdo_mysql, redis
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Prepare environment
run: |
cp .env.example .env.testing
php artisan key:generate --env=testing
- name: Run migrations
run: php artisan migrate --env=testing --force
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 --parallel
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/myapp
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
echo "Deployment complete"Setting up secrets
In your GitHub repository, go to Settings → Secrets and variables → Actions. Add:
SERVER_HOST— your server's IP addressSERVER_USER— the SSH usernameSSH_PRIVATE_KEY— your private SSH key
Never put these values directly in the workflow file.
What happens now
Every time you push to main:
- 1GitHub spins up a fresh Ubuntu server
- 2Installs PHP and your dependencies
- 3Runs your migrations against a test database
- 4Runs all your tests
- 5If everything passes, SSHes into your server and deploys
If any test fails, the deploy step is skipped. Broken code never reaches production.
Zero-downtime deployments
The simple deployment above has a brief moment where the app is in an inconsistent state (new code, old cache). For zero-downtime, look into Deployer or Laravel Envoy — they deploy to a new directory and switch a symlink atomically.
But for most projects, the simple approach is fine. The deployment takes about 30 seconds and happens at a time you choose (when you push to main).
Once you have CI/CD, you will never want to go back to manual deployments.




