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.
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
# .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 5mStep 3: OPA Policies for Kubernetes
# 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])
}# 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])
}# 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
# 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% → FAILRun SonarQube locally with Docker:
# 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-tokenStep 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
| Threat | Tool | When |
|---|---|---|
| Committed secrets (API keys, passwords) | Gitleaks | Every push |
| Insecure Dockerfile patterns | Checkov | Every push |
| Code vulnerabilities (SQL injection, XSS) | SonarQube SAST | Every PR |
| Low test coverage | SonarQube | Every PR |
| Vulnerable npm/pip packages | npm audit | Every build |
| Container image CVEs | Trivy | Every build |
| Unsigned/tampered images | Cosign verify | Before deploy |
| Kubernetes misconfigs (privileged, no limits) | Conftest + OPA | Before deploy |
Resources to Go Deeper
- KodeKloud DevSecOps Course — Hands-on security pipeline labs with Trivy, Vault, and OPA
- OWASP DevSecOps Guideline — Framework for building security into pipelines
- Sigstore / Cosign Docs — Keyless image signing and verification
- DigitalOcean $200 credit — Build and test this pipeline on a real Kubernetes cluster
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
Build a Complete CI/CD Pipeline with GitHub Actions + ArgoCD + EKS (2026)
A full project walkthrough — from a simple app to a production-grade GitOps pipeline with automated builds, image scanning, and deployments to AWS EKS using ArgoCD.
Build a DevSecOps Pipeline from Scratch (2026 Project Walkthrough)
A complete end-to-end DevSecOps pipeline with SAST, container scanning, secrets detection, DAST, and supply chain security using open-source tools.
Build an AI GitHub PR Review Bot with Claude API (2026)
Build a GitHub Actions workflow that automatically reviews every pull request using Claude AI — catches bugs, security issues, and bad patterns before human review.