All Articles

Build a DevSecOps Pipeline with Trivy, SonarQube, and OPA from Scratch (2026)

Step-by-step project walkthrough: add security scanning, code quality gates, and policy enforcement to a GitHub Actions pipeline. Real configs, production-ready.

DevOpsBoysApr 2, 20267 min read
Share:Tweet

Security belongs in the pipeline, not as an afterthought after deployment. This walkthrough builds a DevSecOps pipeline that catches vulnerabilities at every stage — before they ever reach production.

By the end, you'll have a GitHub Actions pipeline that:

  • Scans code for secrets and hardcoded credentials (Gitleaks)
  • Checks code quality and coverage (SonarQube)
  • Scans Docker images for CVEs (Trivy)
  • Validates Kubernetes manifests against security policies (OPA/Conftest)
  • Signs container images (Cosign)
  • Blocks deployment if any check fails

The Pipeline Architecture

Code Push → PR Opened
       │
       ▼
┌─────────────────────────────────────────────────────┐
│ Stage 1: Pre-commit Security                         │
│  ├── Gitleaks (secret scanning)                     │
│  └── Checkov (IaC misconfiguration scan)            │
├─────────────────────────────────────────────────────┤
│ Stage 2: Build & SAST                                │
│  ├── SonarQube (code quality + SAST)                │
│  └── Dependency check (npm audit / safety)          │
├─────────────────────────────────────────────────────┤
│ Stage 3: Container Security                          │
│  ├── Docker build                                   │
│  ├── Trivy image scan (CVE scan)                    │
│  └── Cosign sign (keyless image signing)            │
├─────────────────────────────────────────────────────┤
│ Stage 4: Policy Enforcement                          │
│  ├── Conftest (OPA policies on K8s manifests)       │
│  └── Kubesec (K8s security scoring)                 │
├─────────────────────────────────────────────────────┤
│ Stage 5: Deploy to Staging                           │
│  └── Helm upgrade (only if all stages pass)         │
└─────────────────────────────────────────────────────┘

Step 1: Repository Structure

myapp/
├── .github/
│   └── workflows/
│       └── devsecops.yml
├── policy/
│   └── k8s/
│       ├── deny-privileged.rego
│       ├── require-limits.rego
│       └── deny-root-user.rego
├── k8s/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── ingress.yaml
├── Dockerfile
├── src/
└── sonar-project.properties

Step 2: The Full Pipeline YAML

yaml
# .github/workflows/devsecops.yml
name: DevSecOps Pipeline
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
env:
  IMAGE_NAME: myapp
  REGISTRY: ghcr.io/${{ github.repository_owner }}
 
permissions:
  contents: read
  packages: write
  id-token: write        # For Cosign keyless signing
  security-events: write # For SARIF uploads to GitHub Security tab
 
jobs:
  # ──────────────────────────────────────────
  # STAGE 1: Secrets & IaC Scanning
  # ──────────────────────────────────────────
  secret-scan:
    name: Secret Scanning
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for gitleaks
 
      - name: Gitleaks — scan for secrets
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        # Fails build if any secrets found in git history
 
      - name: Checkov — IaC security scan
        uses: bridgecrewio/checkov-action@master
        with:
          directory: .
          framework: dockerfile,kubernetes
          output_format: sarif
          output_file_path: checkov-results.sarif
 
      - name: Upload Checkov results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: checkov-results.sarif
 
  # ──────────────────────────────────────────
  # STAGE 2: Code Quality & SAST
  # ──────────────────────────────────────────
  code-quality:
    name: SonarQube Analysis
    runs-on: ubuntu-latest
    needs: secret-scan
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
 
      - name: Run tests with coverage
        run: |
          npm install
          npm test -- --coverage --coverageReporters=lcov
 
      - name: SonarQube Scan
        uses: SonarSource/sonarqube-scan-action@master
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
        # Uses sonar-project.properties for config
 
      - name: Dependency audit
        run: |
          npm audit --audit-level=high
          # Fails if HIGH or CRITICAL vulnerabilities in dependencies
 
  # ──────────────────────────────────────────
  # STAGE 3: Build & Container Scan
  # ──────────────────────────────────────────
  build-and-scan:
    name: Build, Scan & Sign Image
    runs-on: ubuntu-latest
    needs: [secret-scan, code-quality]
    outputs:
      image-digest: ${{ steps.build.outputs.digest }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Build Docker image (don't push yet)
        uses: docker/build-push-action@v6
        id: build
        with:
          context: .
          load: true  # Load locally for scanning
          tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Trivy — scan image for CVEs
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.IMAGE_NAME }}:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: HIGH,CRITICAL
          exit-code: '1'          # Fail build on HIGH/CRITICAL
          ignore-unfixed: true    # Skip unfixed CVEs (can't patch them)
 
      - name: Upload Trivy results to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif
 
      - name: Push image after scan passes
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
 
      - name: Install Cosign
        uses: sigstore/cosign-installer@v3
 
      - name: Sign image with Cosign (keyless)
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
        # Uses OIDC — no private key needed
        # Signature stored in Rekor transparency log
 
  # ──────────────────────────────────────────
  # STAGE 4: Kubernetes Policy Enforcement
  # ──────────────────────────────────────────
  policy-check:
    name: K8s Policy Enforcement
    runs-on: ubuntu-latest
    needs: build-and-scan
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Install Conftest
        run: |
          curl -L https://github.com/open-policy-agent/conftest/releases/download/v0.50.0/conftest_0.50.0_Linux_x86_64.tar.gz | tar xz
          sudo mv conftest /usr/local/bin/
 
      - name: Conftest — validate K8s manifests
        run: |
          conftest test k8s/ --policy policy/k8s/
          # Fails if any manifest violates OPA policies
 
      - name: Kubesec — K8s security scoring
        run: |
          curl -sSL https://github.com/controlplaneio/kubesec/releases/download/v2.14.0/kubesec_linux_amd64.tar.gz | tar xz
          ./kubesec scan k8s/deployment.yaml | jq '.[0] | if .score < 5 then error("Security score too low: \(.score)") else . end'
 
  # ──────────────────────────────────────────
  # STAGE 5: Deploy to Staging
  # ──────────────────────────────────────────
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: [build-and-scan, policy-check]
    if: github.ref == 'refs/heads/main'
    environment: staging
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1
 
      - name: Update kubeconfig
        run: aws eks update-kubeconfig --name staging-cluster --region us-east-1
 
      - name: Verify image signature before deploy
        run: |
          cosign verify \
            --certificate-identity-regexp="https://github.com/${{ github.repository }}/" \
            --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
 
      - name: Helm deploy to staging
        run: |
          helm upgrade --install myapp ./chart \
            --namespace staging \
            --set image.repository=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} \
            --set image.tag=${{ github.sha }} \
            --wait \
            --timeout 5m

Step 3: OPA Policies for Kubernetes

rego
# policy/k8s/deny-privileged.rego
package main
 
deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  container.securityContext.privileged == true
  msg := sprintf("Container '%s' must not run as privileged", [container.name])
}
 
deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  container.securityContext.allowPrivilegeEscalation == true
  msg := sprintf("Container '%s' must not allow privilege escalation", [container.name])
}
rego
# policy/k8s/require-limits.rego
package main
 
deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.resources.limits.memory
  msg := sprintf("Container '%s' must have memory limits set", [container.name])
}
 
deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.resources.limits.cpu
  msg := sprintf("Container '%s' must have CPU limits set", [container.name])
}
rego
# policy/k8s/deny-root-user.rego
package main
 
deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  container.securityContext.runAsUser == 0
  msg := sprintf("Container '%s' must not run as root (UID 0)", [container.name])
}
 
warn[msg] {
  input.kind == "Deployment"
  not input.spec.template.spec.securityContext.runAsNonRoot
  msg := "Deployment should set runAsNonRoot: true at pod level"
}

Step 4: SonarQube Configuration

properties
# sonar-project.properties
sonar.projectKey=myapp
sonar.projectName=My Application
sonar.sources=src
sonar.tests=src
sonar.test.inclusions=**/*.test.js,**/*.spec.js
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.qualitygate.wait=true
 
# Quality gate thresholds (fail PR if these aren't met)
# Configure in SonarQube UI:
# - Coverage < 70% → FAIL
# - Reliability Rating < A → FAIL
# - Security Rating < A → FAIL
# - Duplicated Lines > 3% → FAIL

Run SonarQube locally with Docker:

bash
# Start SonarQube
docker run -d --name sonarqube -p 9000:9000 sonarqube:lts-community
 
# Scan
npx sonar-scanner \
  -Dsonar.host.url=http://localhost:9000 \
  -Dsonar.login=your-token

Step 5: Add a .trivyignore for Accepted Risks

# .trivyignore
# CVE-2024-XXXXX — false positive, our usage is not vulnerable
# Justification: we don't use the affected code path
# Reviewed by: security team on 2026-04-01
CVE-2024-XXXXX

# OS-level CVE in base image, no patch available yet
# Base image: node:20.12-alpine3.19
# Tracking: https://github.com/myorg/myapp/issues/123
CVE-2024-YYYYY

Every entry in .trivyignore should have a comment explaining why it's accepted. Treat it like technical debt — review quarterly.


What the Pipeline Catches

ThreatToolWhen
Committed secrets (API keys, passwords)GitleaksEvery push
Insecure Dockerfile patternsCheckovEvery push
Code vulnerabilities (SQL injection, XSS)SonarQube SASTEvery PR
Low test coverageSonarQubeEvery PR
Vulnerable npm/pip packagesnpm auditEvery build
Container image CVEsTrivyEvery build
Unsigned/tampered imagesCosign verifyBefore deploy
Kubernetes misconfigs (privileged, no limits)Conftest + OPABefore deploy

Resources to Go Deeper

Newsletter

Stay ahead of the curve

Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam — just practical engineering content.

Related Articles

Comments