perkun.eu Services Portfolio Blog About Contact PL
← Blog

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.