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.
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
- A push to
mainstarts the workflow. - The test job runs on a GitHub-hosted runner: install dependencies, build assets, run the test suite.
- 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
pushto a protected branch, never onpull_requestfrom 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
dockeron 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.
More articles
Running a Laravel queue worker in Docker Compose without migration races
Put your queue worker in its own container, make it wait for a healthy database, stop it from re-running migrations, and restart it cleanly on every deploy.
· 4 min read
Technical SEO for a Laravel and Livewire portfolio: what I actually implemented
A practical checklist from building this site: per-page meta, canonical URLs, Open Graph, JSON-LD, a dynamic sitemap and Search Console — with the code and the things I deliberately skipped.
· 4 min read
Hosting a Laravel app on one AWS EC2 instance with Docker, Caddy and Cloudflare
The exact setup behind this portfolio: Docker Compose on a single EC2 instance, Caddy for HTTPS, Cloudflare in front, and a security group that only lets Cloudflare in.
· 4 min read