# 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.

**Author:** Matt King | **Published:** June 16, 2026 | **Category:** Security

---

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

```yaml
# .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

1. **Never hardcode secrets in YAML files.** Not even in comments. Not even "for reference."
2. **Use encrypted secrets.** GitHub Actions Secrets, GitLab CI/CD Variables (masked and protected).
3. **Scope secrets to environments.** Production deployment keys should only be accessible from the `production` environment.
4. **Use short-lived credentials.** OIDC tokens instead of long-lived API keys wherever possible.
5. **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:

```yaml
# .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:

```yaml
# 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

```dockerfile
# 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

```yaml
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:

```yaml
# .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:

```bash
#!/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 pipefail` stops the script on any error. Without this, a failed `git pull` would still run migrations.
- `--no-dev` excludes development dependencies from production.
- `--optimize-autoloader` generates a classmap for faster autoloading and eliminates filesystem checks.

### Deployment Verification

Add a post-deployment check to verify the deployment succeeded:

```yaml
# 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

```yaml
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

```yaml
# 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

1. **Permissions** are scoped to the minimum required for each workflow.
2. **Secrets** use encrypted storage, not hardcoded values.
3. **OIDC** replaces long-lived cloud credentials where possible.
4. **Dependency audit** (`composer audit`) runs on every commit.
5. **Static analysis** checks for security anti-patterns (eval, unserialize, raw SQL).
6. **Secret scanning** (Gitleaks or equivalent) runs on every pull request.
7. **Container scanning** (Trivy or equivalent) runs before image push.
8. **Production deployments** require manual approval or review.
9. **Production secrets** are only accessible from the main branch.
10. **Post-deployment verification** checks health endpoints and security headers.
11. **Pipeline monitoring** alerts on unexpected runs or configuration changes.

For continuous monitoring of your deployed application's security posture, [run a free StackShield scan](/free-scan) to check for exposed files, missing headers, debug mode, and 30+ other security issues. See our [security checks](/security-checks/ci-cd-security) for more detail, or explore [monitoring plans](/pricing) 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.*

---

## 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.

