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 Self-Hosted GitHub Actions Runner on Kubernetes (2026)
GitHub-hosted runners are slow and expensive at scale. Here's how to set up self-hosted GitHub Actions runners on Kubernetes with auto-scaling using Actions Runner Controller.
5 DevOps Portfolio Projects That Actually Get You Hired in 2026
Not just another list of project ideas. These are the specific projects that hiring managers at top companies are looking for — with exactly what to build and how to present them.