11/27/2023
How to deploy Laravel with CI/CD without paying for expensive services
TL;DR: GitHub Actions (free for public repos, 2000 minutes/month for private) + your own server = complete CI/CD for Laravel without Forge, Vapor, or Envoyer.
When Laravel Forge costs $19/month, Vapor charges per deployment, and Envoyer adds another $10 — for a small project or freelancer, the total quickly reaches $30–50 per month just for deployment infrastructure. GitHub Actions is free for open source projects and has a generous free tier for private repositories. Your own server (a $5 VPS or a NAS) replaces managed platforms.
The problem with paid services
Laravel Forge is excellent when you’re managing dozens of servers for dozens of clients — automatic nginx configuration, SSL, queue workers, backups. But if you have one server and one project, Forge is an overpriced abstraction layer. Vapor (serverless on AWS Lambda) makes sense for apps with very uneven traffic, where scaling to zero has value — for stable projects it’s cost without benefit. Envoyer solves zero-downtime deployment through directory symlink rotation — useful, but achievable on your own.
GitHub Actions covers these needs without a subscription.
CI workflow
The .github/workflows/ci.yml file, triggered on every Pull Request to the main branch:
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: testing
ports: ['3306:3306']
options: --health-cmd="mysqladmin ping" --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, bcmath, pdo_mysql
- name: Install dependencies
run: composer install --prefer-dist --no-interaction
- name: Copy .env
run: cp .env.example .env.testing
- name: Generate key
run: php artisan key:generate --env=testing
- name: Run tests
run: php artisan test --env=testing
Every PR blocks merging until the tests pass — a simple safety net at zero cost.
Deploy workflow
The .github/workflows/deploy.yml file, triggered on push to main:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SERVER_PORT }}
script: |
cd /var/www/my-project
git pull origin main
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
The appleboy/ssh-action action logs into the server and runs the script. The whole process takes 2–3 minutes from push to a working new version.
GitHub Secrets
In the repository settings (Settings → Secrets and variables → Actions), add the secrets: SERVER_HOST (server IP or domain), SERVER_USER (e.g. deploy), SERVER_PORT (22 or 222 for QNAP), SSH_KEY (private SSH key).
Generating a dedicated SSH key for deployment:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""
# Add the private key as secret SSH_KEY
cat ~/.ssh/deploy_key
# Add the public key to ~/.ssh/authorized_keys on the server
cat ~/.ssh/deploy_key.pub
Using a dedicated key (not your personal one) is good practice — if the repository is ever compromised, you can revoke only that key.
Docker deployment
An alternative workflow for Docker-based projects builds the image, pushes it to GitHub Container Registry (GHCR — free), and deploys it to the server:
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
docker pull ghcr.io/${{ github.repository }}:latest
docker compose up -d --no-deps --build app
docker exec app php artisan migrate --force
This version is more isolated (each deployment is a fresh container) and easier to roll back (just docker pull the previous tag).
Summary
GitHub Actions covers 90% of CI/CD needs for Laravel projects — tests on PRs, automatic deploy on push, failure notifications. For advanced scenarios like blue-green deployment, automatic rollback on migration errors, or managing dozens of servers — that’s when paid tools start to make economic sense. For one or a few projects, your own YAML workflow is simpler, cheaper, and gives you full control.