Skip to content

Laravel CI/CD with GitHub Actions and a self-hosted runner on EC2

Run tests on GitHub, then deploy from a runner that lives on the server itself — no SSH keys in secrets, no inbound deploy port, and a deploy that never gets cancelled halfway.

Gohar Nawaz 4 min read
GitHub Actions and Docker icons with the title Laravel CI/CD with a self-hosted runner

Every push to main on this portfolio is tested and deployed automatically. There is no SSH key stored in GitHub, no deploy port open to the internet, and no manual git pull on the server.

The trick is a self-hosted runner installed on the production server itself. This article explains the pipeline, why I chose that design, and the security trade-offs you must understand before copying it.

The pipeline at a glance

  1. A push to main starts the workflow.
  2. The test job runs on a GitHub-hosted runner: install dependencies, build assets, run the test suite.
  3. If tests pass, the deploy job runs on the self-hosted runner on the server: pull the new code and rebuild the containers.

The workflow

name: CI/CD

on:
  push:
    branches: [main]

concurrency:
  group: production-deploy
  cancel-in-progress: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.4"
          extensions: mbstring, pdo_sqlite, sqlite3, gd, zip
          coverage: none

      - run: cp .env.example .env
      - run: composer install --no-interaction --prefer-dist --optimize-autoloader
      - run: php artisan key:generate

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci
      - run: npm run build
      - run: php artisan test

  deploy:
    needs: test
    runs-on: self-hosted
    steps:
      - name: Deploy to production
        working-directory: /home/ec2-user/app
        run: |
          git pull --ff-only
          docker compose -f compose.yaml -f compose.prod.yaml up -d --build
          docker image prune -f

Why a self-hosted runner for the deploy

The usual approach is an SSH step: store a private key in GitHub secrets and let the workflow log in to the server. It works, but it has costs:

  • A powerful credential lives in GitHub secrets.
  • Port 22 has to accept connections from GitHub's IP ranges, which are large and change.

With a runner on the server, the runner makes an outbound connection to GitHub and waits for jobs. The server needs no extra inbound port, and there is no deploy key to rotate or leak. That fits well with a firewall that only allows Cloudflare in, as described in the hosting article.

The details that make it safe

needs: test is the gate. A failing test means the deploy job never starts.

cancel-in-progress: false is deliberate. For tests, cancelling an old run is fine. For a deploy, cancelling halfway can leave containers in a half-updated state. With the concurrency group, a newer deploy waits for the current one to finish instead of killing it.

git pull --ff-only refuses to create merge commits on the server, so the checkout there is always an exact copy of main. If someone edited files on the server by hand, the pull fails loudly instead of silently merging.

up -d --build rebuilds the image and recreates only the containers that changed. Because workers are recreated too, queue workers always run the new code — see running a Laravel queue worker in Docker Compose.

docker image prune -f removes dangling images, so repeated deploys do not slowly fill the disk.

The security trade-off you must understand

A self-hosted runner executes workflow code on your production machine. That is acceptable only if you control what can trigger it:

  • Trigger on push to a protected branch, never on pull_request from forks. Pull-request code from strangers would run on your server.
  • Keep the repository private, or at least never expose the runner to untrusted contributors.
  • Treat Docker access as root access. A user who can run docker on the host effectively has root. Run the runner as a dedicated user and keep the machine single-purpose.
  • Keep the runner updated and run it as a system service so it survives reboots.

If those conditions do not describe your project, use the SSH approach with a dedicated deploy key instead. The trade-offs are different, not better or worse.

Make the tests worth trusting

A pipeline is only as good as its test job. Run the suite against an in-memory SQLite database so tests are fast and need no external services, and use RefreshDatabase so every test starts from a migrated, empty schema. A test that passes only because of leftover data will eventually deploy a bug.

Rollback is a revert

I do not keep a separate rollback script. To undo a bad release I revert the commit and push; the same pipeline tests it and deploys the previous behaviour. One path for shipping and for undoing means the path stays well exercised.

Checklist

  • Tests gate the deploy.
  • Deploys are serialised and never cancelled mid-flight.
  • The server only ever fast-forwards to main.
  • The runner is triggered by pushes to a protected branch only.
  • Rolling back means reverting a commit.

The same ideas — test automatically, deploy from one branch, keep secrets out of code, make every release repeatable — translate to Azure Pipelines, GitLab CI or any other tool.