Securing Your Laravel CI/CD Pipeline: A Practical DevSecOps Guide
With 70% of teams releasing continuously, your CI/CD pipeline is a high-value target. This guide covers securing GitHub Actions and GitLab CI for Laravel projects: secrets management, composer audit integration, SAST scanning, container security, deployment hardening, and artifact signing with practical YAML configs.
Seventy percent of development teams now practice some form of continuous delivery. Code gets merged, tests run, and changes deploy to production automatically, often multiple times per day. This speed is a competitive advantage, but it also means that your CI/CD pipeline is one of the most valuable targets in your infrastructure.
Think about what your pipeline has access to: your source code, your production secrets, your deployment credentials, your cloud infrastructure. A compromised pipeline does not just affect one server. It can push malicious code to every server, every container, every user.
This guide covers how to secure your Laravel CI/CD pipeline from end to end, with practical YAML configurations for GitHub Actions and GitLab CI.
Why CI/CD Pipelines Are High-Value Targets
CI/CD pipelines are attractive to attackers for several reasons:
- Broad access. Pipelines typically have credentials for your code repository, cloud provider, container registry, database, and deployment platform.
- Trusted execution. Code that passes through CI is trusted by downstream systems. A malicious commit that passes CI gets deployed automatically.
- Persistent credentials. Many teams store long-lived API keys and access tokens as CI secrets that never expire.
- Lateral movement. Compromising one pipeline often provides access to multiple environments (staging, production) and multiple services.
- Low visibility. Few teams actively monitor their CI/CD logs for suspicious activity.
Recent attacks on CI/CD systems include compromised GitHub Actions (the tj-actions/changed-files incident), stolen CircleCI secrets, and supply chain attacks through build dependencies. These are not theoretical risks. They are happening now.
Securing Your GitHub Actions Workflow
Let us build a secure GitHub Actions workflow for a Laravel application, step by step.
The Base Workflow with Security Checks
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
security-audit:
name: Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Run Composer audit
run: composer audit --locked --format=json | tee audit-results.json
- name: Check for known vulnerabilities
run: |
VULN_COUNT=$(cat audit-results.json | php -r 'echo count(json_decode(file_get_contents("php://stdin"), true)["advisories"] ?? []);')
if [ "$VULN_COUNT" -gt "0" ]; then
echo "::error::Found $VULN_COUNT known vulnerabilities in dependencies"
cat audit-results.json | php -r '$data = json_decode(file_get_contents("php://stdin"), true); foreach ($data["advisories"] ?? [] as $pkg => $advisories) { foreach ($advisories as $a) { echo "- $pkg: {$a["title"]} (CVE: " . ($a["cve"] ?? "N/A") . ")\n"; } }'
exit 1
fi
static-analysis:
name: Static Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2, phpstan
coverage: none
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Run PHPStan
run: vendor/bin/phpstan analyse --error-format=github --no-progress
- name: Run security-focused static analysis
run: |
# Check for common security anti-patterns
echo "Checking for raw SQL queries..."
if grep -rn "DB::raw\|->whereRaw\|->selectRaw\|->orderByRaw" app/ --include="*.php" | grep -v "// safe:"; then
echo "::warning::Found raw SQL queries. Review for SQL injection risks."
fi
echo "Checking for unsafe deserialization..."
if grep -rn "unserialize(" app/ --include="*.php"; then
echo "::error::Found unserialize() calls. Use json_decode() instead."
exit 1
fi
echo "Checking for eval usage..."
if grep -rn "\beval(" app/ --include="*.php"; then
echo "::error::Found eval() calls. This is a critical security risk."
exit 1
fi
secret-scanning:
name: Secret Detection
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test:
name: Tests
runs-on: ubuntu-latest
needs: [security-audit, static-analysis, secret-scanning]
services:
mysql:
image: mysql:8.0
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.3'
extensions: mbstring, pdo, pdo_mysql
coverage: xdebug
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- 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 --parallel
Key Security Decisions in This Workflow
Minimal permissions. The permissions block restricts the workflow's GitHub token to only what it needs. The default token has write access to many things. Always scope it down.
Security checks run before tests. The needs directive ensures that if dependencies have known vulnerabilities, the pipeline fails before running any application code.
Secret scanning with Gitleaks. This catches accidentally committed API keys, database passwords, and other sensitive values before they reach the main branch.
Pinned action versions. Using @v4 instead of @main or @latest prevents supply chain attacks through compromised actions.
Secrets Management
How you handle secrets in CI determines your blast radius if the pipeline is compromised.
The Rules
- Never hardcode secrets in YAML files. Not even in comments. Not even "for reference."
- Use encrypted secrets. GitHub Actions Secrets, GitLab CI/CD Variables (masked and protected).
- Scope secrets to environments. Production deployment keys should only be accessible from the
productionenvironment. - Use short-lived credentials. OIDC tokens instead of long-lived API keys wherever possible.
- Rotate secrets regularly. Set calendar reminders. Automate where possible.
Using OIDC for AWS (No Stored Credentials)
Instead of storing AWS access keys as secrets, use OIDC:
# .github/workflows/deploy.yml
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
environment: production # Requires approval
permissions:
id-token: write
contents: read
steps:
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::YOUR_ACCOUNT:role/github-deploy-role
aws-region: us-east-1
# No AWS keys stored anywhere. The token is issued on-demand
# and expires after the workflow completes.
Environment Protection Rules
Configure production environments to require approval:
# In your GitHub repository settings:
# Settings > Environments > production
# - Required reviewers: your-team-lead
# - Wait timer: 5 minutes
# - Deployment branches: main only
This means even if an attacker compromises a developer's account and pushes to main, the production deployment still requires a separate approval.
Container Security Scanning
If your Laravel app deploys in Docker containers, scan them before they reach production.
Dockerfile Security Best Practices
# Use specific version tags, never "latest"
FROM php:8.3-fpm-bookworm AS base
# Run as non-root
RUN groupadd -r laravel && useradd -r -g laravel laravel
# Install only required extensions
RUN docker-php-ext-install pdo pdo_mysql opcache
# Copy application code
COPY --chown=laravel:laravel . /var/www/html
# Remove development files
RUN rm -rf tests/ .env.example .git/ .github/ node_modules/
# Switch to non-root user
USER laravel
# Production PHP configuration
COPY docker/php/production.ini /usr/local/etc/php/conf.d/production.ini
Scanning with Trivy in GitHub Actions
container-scan:
name: Container Security Scan
runs-on: ubuntu-latest
needs: [test]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: 'myapp:${{ github.sha }}'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1'
- name: Upload Trivy scan results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
The exit-code: '1' setting makes the build fail if critical or high severity vulnerabilities are found. This prevents deploying containers with known vulnerabilities.
GitLab CI Configuration
If your team uses GitLab, here is the equivalent secure pipeline:
# .gitlab-ci.yml
stages:
- security
- test
- build
- deploy
variables:
COMPOSER_NO_INTERACTION: "1"
COMPOSER_ALLOW_SUPERUSER: "1"
composer-audit:
stage: security
image: php:8.3-cli
before_script:
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
script:
- composer install --no-progress
- composer audit --locked
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "main"'
secret-detection:
stage: security
image:
name: zricethezav/gitleaks:latest
entrypoint: [""]
script:
- gitleaks detect --source . --verbose --report-format json --report-path gitleaks-report.json
artifacts:
reports:
secret_detection: gitleaks-report.json
when: always
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
sast:
stage: security
image: php:8.3-cli
before_script:
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install --no-progress
script:
- vendor/bin/phpstan analyse --error-format=json --no-progress > phpstan-report.json || true
- |
ERROR_COUNT=$(php -r 'echo json_decode(file_get_contents("phpstan-report.json"), true)["totals"]["errors"] ?? 0;')
echo "PHPStan found $ERROR_COUNT errors"
if [ "$ERROR_COUNT" -gt "0" ]; then
cat phpstan-report.json
exit 1
fi
artifacts:
reports:
codequality: phpstan-report.json
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
test:
stage: test
image: php:8.3-cli
services:
- mysql:8.0
variables:
MYSQL_DATABASE: testing
MYSQL_ROOT_PASSWORD: password
DB_HOST: mysql
DB_PORT: "3306"
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
before_script:
- docker-php-ext-install pdo pdo_mysql
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install --no-progress
script:
- php artisan test --parallel
needs:
- composer-audit
- sast
deploy-production:
stage: deploy
image: php:8.3-cli
environment:
name: production
url: https://yourdomain.com
script:
- echo "Deploying to production..."
# Your deployment script here
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual # Require manual approval for production
needs:
- test
Deployment Hardening
The deployment step itself needs security attention.
Forge Deployment Security
If you deploy with Laravel Forge, secure the deployment script:
#!/bin/bash
set -euo pipefail
cd /home/forge/yourdomain.com
# Pull latest code
git pull origin main
# Install dependencies without dev packages
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
# Run migrations
php artisan migrate --force
# Clear and rebuild caches
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# Restart workers
php artisan queue:restart
# Restart PHP-FPM
sudo -S service php8.3-fpm reload
Key points:
set -euo pipefailstops the script on any error. Without this, a failedgit pullwould still run migrations.--no-devexcludes development dependencies from production.--optimize-autoloadergenerates a classmap for faster autoloading and eliminates filesystem checks.
Deployment Verification
Add a post-deployment check to verify the deployment succeeded:
# In your CI/CD pipeline, after deployment
verify-deployment:
name: Verify Production
runs-on: ubuntu-latest
needs: [deploy]
steps:
- name: Health check
run: |
for i in {1..5}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://yourdomain.com/health)
if [ "$STATUS" = "200" ]; then
echo "Health check passed"
exit 0
fi
echo "Attempt $i: Got status $STATUS, retrying..."
sleep 10
done
echo "::error::Health check failed after 5 attempts"
exit 1
- name: Security header check
run: |
HEADERS=$(curl -s -I https://yourdomain.com)
echo "$HEADERS"
if ! echo "$HEADERS" | grep -qi "strict-transport-security"; then
echo "::error::Missing HSTS header"
exit 1
fi
if ! echo "$HEADERS" | grep -qi "x-content-type-options"; then
echo "::error::Missing X-Content-Type-Options header"
exit 1
fi
Artifact Integrity and Signing
For teams with stricter compliance requirements, sign your build artifacts to verify they have not been tampered with.
Signing Deployment Artifacts
sign-artifact:
name: Sign Release
runs-on: ubuntu-latest
needs: [test, container-scan]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create release archive
run: |
tar -czf release-${{ github.sha }}.tar.gz \
--exclude='.git' \
--exclude='tests' \
--exclude='node_modules' \
--exclude='.env*' \
.
- name: Generate checksum
run: |
sha256sum release-${{ github.sha }}.tar.gz > release-${{ github.sha }}.tar.gz.sha256
cat release-${{ github.sha }}.tar.gz.sha256
- name: Sign with cosign
uses: sigstore/cosign-installer@v3
- name: Sign the artifact
run: |
cosign sign-blob \
--yes \
--output-signature release-${{ github.sha }}.sig \
release-${{ github.sha }}.tar.gz
Monitoring Your Pipeline
Security does not stop at configuration. Monitor your pipeline for anomalies.
What to Watch For
- Unexpected workflow runs. A workflow triggered by a branch you do not recognise.
- Modified workflow files. Changes to
.github/workflows/should be reviewed carefully. - Secret access from new branches. If a new branch suddenly accesses production secrets, investigate.
- Failed security checks that get bypassed. Someone force-merging despite failing security gates.
- Unusual deployment times. A production deployment at 3 AM when no one is working.
GitHub Actions Audit Logging
# Add to your workflow to log security-relevant events
audit-log:
name: Audit Pipeline Run
runs-on: ubuntu-latest
if: always()
needs: [deploy]
steps:
- name: Log pipeline metadata
run: |
echo "Pipeline triggered by: ${{ github.actor }}"
echo "Event: ${{ github.event_name }}"
echo "Branch: ${{ github.ref }}"
echo "Commit: ${{ github.sha }}"
echo "Workflow: ${{ github.workflow }}"
echo "Time: $(date -u)"
Your CI/CD Security Checklist
- Permissions are scoped to the minimum required for each workflow.
- Secrets use encrypted storage, not hardcoded values.
- OIDC replaces long-lived cloud credentials where possible.
- Dependency audit (
composer audit) runs on every commit. - Static analysis checks for security anti-patterns (eval, unserialize, raw SQL).
- Secret scanning (Gitleaks or equivalent) runs on every pull request.
- Container scanning (Trivy or equivalent) runs before image push.
- Production deployments require manual approval or review.
- Production secrets are only accessible from the main branch.
- Post-deployment verification checks health endpoints and security headers.
- Pipeline monitoring alerts on unexpected runs or configuration changes.
For continuous monitoring of your deployed application's security posture, run a free StackShield scan to check for exposed files, missing headers, debug mode, and 30+ other security issues. See our security checks for more detail, or explore monitoring plans for ongoing protection.
Your CI/CD pipeline is as critical as your production servers. Possibly more so, because a compromised pipeline can push malicious code to every environment you have. Treat your pipeline configuration as security-sensitive infrastructure, review changes to it carefully, and layer your defenses. The few hours you invest in hardening your pipeline will save you from the kind of breach that makes headlines.
Is your Laravel app exposed right now?
34% of Laravel apps we scan have at least one critical issue. Most teams don't find out until something breaks. Our free scan checks your live application in under 60 seconds.
Frequently Asked Questions
What is DevSecOps and why does it matter for Laravel teams?
DevSecOps integrates security practices into every stage of the development lifecycle rather than treating security as a separate, end-of-process gate. For Laravel teams, this means running security checks automatically in your CI/CD pipeline: dependency audits on every commit, static analysis on every pull request, container scanning before deployment, and secret detection throughout. The goal is to catch security issues minutes after they are introduced rather than weeks or months later during a manual review or, worse, after a breach.
How do I add composer audit to my GitHub Actions workflow?
Add a step to your workflow YAML that runs composer audit after installing dependencies. Use the --locked flag to audit your composer.lock file specifically. Set the step to fail the build if vulnerabilities are found by checking the exit code. You can also use the --format=json flag to generate machine-readable output for integration with security dashboards. The command is straightforward: "composer audit --locked" in a run step after your "composer install" step.
What secrets should never be stored in CI/CD configuration files?
Never store database credentials, API keys, SSH private keys, cloud provider access keys, Forge or Vapor API tokens, encryption keys, or any .env file values directly in your CI/CD YAML files. These files are typically committed to version control and visible to everyone with repository access. Use your CI platform encrypted secrets feature instead: GitHub Actions Secrets, GitLab CI Variables (masked and protected), or your cloud provider secret manager. Rotate secrets regularly and use short-lived tokens where possible.
Should I scan my Docker containers for vulnerabilities in CI?
Yes. Container images often include operating system packages with known vulnerabilities. Even if your PHP code is secure, a vulnerable system library in your Docker image can be exploited. Use tools like Trivy, Grype, or Snyk Container in your CI pipeline to scan images before they are pushed to a registry. Configure the scan to fail the build for critical and high severity vulnerabilities. Rebuild images regularly to pick up security patches in base images, and use specific version tags instead of "latest" for reproducibility.
How do I prevent CI/CD pipeline credential theft?
CI/CD credentials are increasingly targeted because they often have deployment access to production. Protect them by using short-lived credentials (OIDC tokens instead of long-lived API keys), restricting secret access to specific branches (only the main branch can access production deployment secrets), requiring approval gates for production deployments, auditing secret access logs, using environment-specific secrets (separate staging and production credentials), and enabling two-factor authentication for all team members with repository access. GitHub Actions supports OIDC natively for AWS, Azure, and GCP, eliminating the need to store cloud credentials as secrets.
Related Articles
Laravel Session Security: Cookies, Hijacking & config/session.php
A deep dive into Laravel session security. Learn how cookie flags, session drivers, and config/session.php settings protect against hijacking, fixation, and sidejacking attacks.
SecurityAutomated Security Testing in Laravel CI/CD Pipelines
How to add security gates to your Laravel CI/CD pipeline with GitHub Actions. Covers dependency scanning, static analysis, secret detection, and automated security monitoring.
SecurityLaravel Content Security Policy: Configure CSP Without Breaking Your App
Only 22% of Laravel apps have a Content Security Policy. Learn how to implement CSP with spatie/laravel-csp, handle Livewire and Vite nonces, and avoid the mistakes that break production.
Compare StackShield
Security Checklists
Laravel Production Deployment Security Checklist
A comprehensive security checklist for deploying Laravel applications to production. Covers environment config, server hardening, access control, and monitoring.
20 itemsLaravel API Security Checklist
Secure your Laravel API endpoints against common vulnerabilities. Covers authentication, input validation, rate limiting, and response security.
Stay Updated on Laravel Security
Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.