Continuous Integration and Continuous Deployment (CI/CD) is the practice of automatically testing and deploying code changes. Instead of manually running tests, SSHing into a server, and triggering a deployment, a CI/CD pipeline handles every step from the moment you push code to the moment it is live in production.
For Laravel applications, this means: push to GitHub, tests run automatically, and if they pass, the application is deployed to your Deploynix server without any manual intervention. If something goes wrong after deployment, rollback is one API call away.
This guide walks through building a complete CI/CD pipeline using GitHub Actions for testing and the Deploynix API for deployment. By the end, you will have a production-ready pipeline that tests your code, deploys on success, and provides a safety net for failures.
The Pipeline Overview
Here is what the complete pipeline does:
- Developer pushes code to a feature branch
- Tests run automatically on the pull request (GitHub Actions)
- Pull request is reviewed and merged to the
mainbranch - Deployment triggers automatically when
mainis updated (Deploynix API via GitHub Actions) - Health check verifies the deployment succeeded
- Rollback triggers automatically if the health check fails
Each step is automated. The developer's only manual action is pushing code and reviewing pull requests.
Part 1: GitHub Actions for Testing
GitHub Actions run in response to repository events (push, pull request, schedule). We will configure a workflow that runs your Laravel test suite on every pull request.
The Test Workflow
Create .github/workflows/test.yml in your repository:
name: Tests
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
redis:
image: valkey/valkey:8
ports:
- 6379:6379
options: >-
--health-cmd="valkey-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, dom, fileinfo, mysql, redis
coverage: none
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: Copy environment file
run: cp .env.example .env
- name: Generate application key
run: php artisan key:generate
- name: Run migrations
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
run: php artisan migrate --no-interaction
- name: Run tests
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
run: php artisan test --compact
- name: Run Pint (code style)
run: vendor/bin/pint --test
What This Workflow Does
Services: Starts MySQL 8.4 and Valkey 8 containers alongside your test runner. This gives your tests a real database and cache server, matching your production environment.
PHP setup: Installs PHP 8.4 with the extensions your Laravel application needs.
Caching: Caches Composer dependencies between runs. The first run downloads everything, subsequent runs restore from cache, saving 30-60 seconds.
Tests: Runs your Pest test suite. The --compact flag provides concise output.
Code style: Runs Laravel Pint in test mode to verify code formatting. This does not fix anything — it just reports whether the code matches the project's style.
Pull Request Protection
Configure branch protection rules on your main branch to require the test workflow to pass before merging:
- Go to your repository Settings > Branches
- Add a branch protection rule for
main - Enable "Require status checks to pass before merging"
- Select the "test" job as a required check
Now, no code can be merged to main without passing tests. This is your first safety net.
Part 2: The Deploynix API
Deploynix provides a RESTful API authenticated with Sanctum tokens. You create an API token in the Deploynix dashboard with specific scopes that control what the token can do.
Creating an API Token
- Log in to your Deploynix dashboard at deploynix.io
- Navigate to your API token settings
- Create a new token with the scopes you need:
-
site:deploy— trigger deployments and rollback -
site:read— read site information -
server:read— read server information - Copy the token — it is shown only once
Storing the Token in GitHub
Add your Deploynix API token as a GitHub repository secret:
- Go to your repository Settings > Secrets and variables > Actions
- Click "New repository secret"
- Name:
DEPLOYNIX_API_TOKEN - Value: your API token
Also add your server and site identifiers:
-
DEPLOYNIX_SERVER_ID— your server's ID -
DEPLOYNIX_SITE_ID— your site's ID
These are visible in the Deploynix dashboard URL when viewing your server or site.
Part 3: The Deployment Workflow
Create .github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deployment
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, dom, fileinfo, mysql
coverage: none
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: Copy environment file
run: cp .env.example .env
- name: Generate application key
run: php artisan key:generate
- name: Run migrations
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
run: php artisan migrate --no-interaction
- name: Run tests
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
run: php artisan test --compact
deploy:
runs-on: ubuntu-latest
needs: test
if: success()
steps:
- name: Trigger deployment
run: |
RESPONSE=$(curl -s -w "\n%{http_code}" \
-X POST \
-H "Authorization: Bearer ${{ secrets.DEPLOYNIX_API_TOKEN }}" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
"https://deploynix.io/api/v1/servers/${{ secrets.DEPLOYNIX_SERVER_ID }}/sites/${{ secrets.DEPLOYNIX_SITE_ID }}/deploy")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -n -1)
echo "HTTP Status: $HTTP_CODE"
echo "Response: $BODY"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "Deployment trigger failed with HTTP $HTTP_CODE"
exit 1
fi
echo "Deployment triggered successfully"
- name: Wait for deployment
run: |
echo "Waiting 60 seconds for deployment to complete..."
sleep 60
- name: Health check
run: |
MAX_ATTEMPTS=6
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time 10 \
"https://your-app-domain.com/up")
echo "Health check attempt $((ATTEMPT + 1)): HTTP $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "Health check passed"
exit 0
fi
ATTEMPT=$((ATTEMPT + 1))
sleep 10
done
echo "Health check failed - application may be down"
exit 1
notify:
runs-on: ubuntu-latest
needs: [test, deploy]
if: always()
steps:
- name: Notify on success
if: needs.deploy.result == 'success'
run: |
echo "Deployment succeeded for commit ${{ github.sha }}"
# Add Slack/Discord/email notification here
- name: Notify on failure
if: needs.deploy.result == 'failure' || needs.test.result == 'failure'
run: |
echo "Pipeline failed for commit ${{ github.sha }}"
# Add Slack/Discord/email notification here
Key Design Decisions
Concurrency control: The concurrency block ensures only one deployment runs at a time. If you push twice quickly, the second push waits for the first deployment to complete rather than creating a race condition.
Tests run again before deploy: Even though tests ran on the pull request, they run again on the merge to main. This catches issues from merge conflicts or from changes that are individually correct but conflict when combined.
Health check: After deployment, the pipeline hits a health endpoint to verify the application is responding. Laravel provides a /up health endpoint by default. If the health check fails, the pipeline reports failure and you can investigate.
Notification job: Runs regardless of success or failure (if: always()) and sends the appropriate notification based on the outcome.
Part 4: Automated Rollback
If a deployment causes issues, you want to rollback quickly. Here is a workflow you can trigger manually:
Create .github/workflows/rollback.yml:
name: Rollback
on:
workflow_dispatch:
inputs:
deployment_id:
description: 'Deployment ID to rollback to (leave empty for previous)'
required: false
type: string
jobs:
rollback:
runs-on: ubuntu-latest
steps:
- name: Trigger rollback
run: |
echo "Triggering rollback..."
RESPONSE=$(curl -s -w "\n%{http_code}" \
-X POST \
-H "Authorization: Bearer ${{ secrets.DEPLOYNIX_API_TOKEN }}" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"release": "${{ github.event.inputs.deployment_id }}"}' \
"https://deploynix.io/api/v1/servers/${{ secrets.DEPLOYNIX_SERVER_ID }}/sites/${{ secrets.DEPLOYNIX_SITE_ID }}/rollback")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -n -1)
echo "HTTP Status: $HTTP_CODE"
echo "Response: $BODY"
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
echo "Rollback failed"
exit 1
fi
echo "Rollback triggered successfully"
- name: Health check after rollback
run: |
sleep 30
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--max-time 10 \
"https://your-app-domain.com/up")
if [ "$HTTP_CODE" = "200" ]; then
echo "Application is healthy after rollback"
else
echo "Warning: Application returned HTTP $HTTP_CODE after rollback"
exit 1
fi
This workflow is triggered manually from the GitHub Actions tab. You can optionally specify a deployment ID to rollback to, or leave it empty to rollback to the previous deployment.
Part 5: Environment-Specific Pipelines
Most teams need separate pipelines for staging and production. Here is how to structure that:
Staging (Deploy on Push to main)
Staging deployments happen automatically on every push to main. This gives your team a preview environment that always reflects the latest merged code.
# In deploy.yml, modify the deploy job:
deploy-staging:
runs-on: ubuntu-latest
needs: test
if: success()
environment: staging
steps:
- name: Deploy to staging
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.DEPLOYNIX_API_TOKEN_STAGING }}" \
-H "Accept: application/json" \
"https://deploynix.io/api/v1/servers/${{ secrets.DEPLOYNIX_SERVER_ID_STAGING }}/sites/${{ secrets.DEPLOYNIX_SITE_ID_STAGING }}/deploy"
Production (Deploy on Release Tag)
Production deployments are triggered by creating a release tag:
name: Deploy Production
on:
release:
types: [published]
jobs:
deploy-production:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to production
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.DEPLOYNIX_API_TOKEN_PRODUCTION }}" \
-H "Accept: application/json" \
"https://deploynix.io/api/v1/servers/${{ secrets.DEPLOYNIX_SERVER_ID_PRODUCTION }}/sites/${{ secrets.DEPLOYNIX_SITE_ID_PRODUCTION }}/deploy"
Use GitHub Environments to configure separate secrets for staging and production, and to add approval requirements for production deployments.
Part 6: Best Practices
Keep Tests Fast
Slow tests kill CI/CD adoption. If your test suite takes 15 minutes, developers stop waiting for it and merge without checking results. Target under 5 minutes for your full suite.
- Run tests in parallel (Pest supports this with
--parallel) - Use SQLite in-memory databases for tests that do not need MySQL-specific features
- Mock external API calls instead of hitting real endpoints
- Cache Composer and npm dependencies between runs
Use Separate API Tokens
Create separate Deploynix API tokens for staging and production, each with only the scopes needed. If your staging token is compromised, it cannot affect production.
Monitor Deployment Frequency
Track how often you deploy. Healthy teams deploy daily or more. If deployment frequency drops, investigate — it usually means the pipeline is slow, flaky, or someone is afraid of deployments.
Do Not Skip Tests
It is tempting to add a "deploy without tests" shortcut for urgent fixes. Do not. Urgent fixes deployed without tests are how you turn one incident into two.
Tag Production Releases
Use semantic versioning (v1.2.3) for production releases. This gives you a clear history of what was deployed when, and makes rollback to a specific version straightforward.
The Complete Flow
Here is what the developer experience looks like with this pipeline in place:
- Developer creates a feature branch and pushes code
- Tests run automatically on the pull request
- If tests fail, the developer is notified and fixes the issue
- Pull request is reviewed and approved by a team member
- Pull request is merged to
main - Tests run again on the merge commit
- If tests pass, the Deploynix API is called to trigger deployment
- The pipeline monitors deployment status until completion
- A health check verifies the application is responding
- The team is notified of the result
The entire process from merge to live deployment takes 3-5 minutes, with zero manual steps after the merge.
Conclusion
CI/CD is not optional for professional Laravel applications. The combination of GitHub Actions for testing and the Deploynix API for deployment gives you a complete, automated pipeline that catches bugs before they reach production and deploys changes without manual intervention.
The pipeline described in this guide — test on PR, deploy on merge, health check after deployment, manual rollback as a safety net — covers the needs of the vast majority of Laravel applications. Start with this foundation, customize the health checks and notifications for your specific needs, and add staging/production environment separation when your team is ready.
The goal of CI/CD is not automation for its own sake. It is confidence. Confidence that your tests catch regressions, confidence that deployments are consistent, and confidence that you can rollback quickly if something goes wrong. With this pipeline in place, deploying becomes a non-event — which is exactly what it should be.
Top comments (0)