Build a DevSecOps Pipeline with Automated Security Gates
Build a GitHub Actions pipeline that scans containers, Terraform, and dependencies, blocking deployment on critical findings.
Domains & Technologies
Blueprint Walkthrough
Architecture Overview & Problem Statement
Architecture Overview
In 2021, the SolarWinds breach compromised thousands of organizations after malicious code was inserted into a software build pipeline, and attackers had months of access before anyone noticed. A DevSecOps pipeline with automated security gates would have caught anomalies far earlier.
DevSecOps moves security checks left — into the development pipeline, before code ever reaches production. Every pull request gets scanned: containers are checked for known vulnerabilities, infrastructure code is checked for misconfigurations, and dependency trees are checked for packages with known CVEs. If a critical finding is detected, the pipeline fails, the code does not deploy, and the developer gets a clear report explaining exactly what to fix.
[ Developer opens a Pull Request ] | v[ GitHub Actions triggers ] | v[ Gate 1: Unit tests must pass ] | v[ Gate 2: Trivy scans image for CVEs ] | v[ Gate 3: Checkov scans Terraform ] | v[ Gate 4: Snyk scans dependencies ] | v[ Gate 5: Gitleaks scans for secrets ] | +-------+-------+ | |All gates pass Any gate fails | | v v[ Deploy ] [ PR blocked with report ]At companies like Razorpay, where a single leaked API key or unencrypted S3 bucket carries direct financial and compliance risk, this exact pattern — scan on every PR, block on critical findings — is table stakes for the platform team.
TipThis is one of the most directly employable skills in this project catalog — most serious engineering teams in 2026 expect their CI/CD pipeline to already have security gates built in, not bolted on later.
Problem Solved
Without automated scanning, a team can unknowingly ship a container built on a base image with a known remote-code-execution vulnerability, or Terraform that leaves an S3 bucket world-readable, or a requirements.txt pinned to a Flask version with a known XSS flaw. None of these get caught until a scanner — or an attacker — finds them.
This project solves that by putting real, automated checks directly in the pull request path. A CVE (Common Vulnerabilities and Exposures) is a publicly known security flaw, scored from CRITICAL down to LOW. Scanning tools compare what you're about to ship against databases of known CVEs and known-bad infrastructure patterns, and the pipeline can be configured to physically block a merge when something CRITICAL is found — not just log a warning nobody reads.
Trivy (by Aqua Security) scans containers, filesystems, and repos for CVEs. Checkov statically analyzes Infrastructure as Code — Terraform, CloudFormation, Kubernetes YAML — against hundreds of security rules. Snyk scans your dependency tree for vulnerable packages. Together with a secret scanner (Gitleaks), they cover the four places vulnerabilities most commonly hide: the container, the infrastructure code, the dependency tree, and the Git history itself.
SecurityA security gate that only reports findings without ever blocking a merge is not a gate — it's a dashboard. That distinction is the core lesson of this project.
Milestone 1: Create the Application and Infrastructure
Concept
We'll intentionally plant a few real misconfigurations in this milestone's Terraform — this isn't sloppiness, it's so you can watch the scanners actually catch something real in Milestone 2, instead of trusting a green pipeline you've never seen turn red.
Steps
mkdir devsecops-projectcd devsecops-project cat > app.py << 'EOF'from flask import Flask, jsonify app = Flask(__name__) @app.route('/health')def health(): return jsonify({'status': 'ok'}), 200 @app.route('/api/orders')def orders(): return jsonify({ 'orders': [ {'id': 'ORD-001', 'status': 'delivered', 'amount': 450}, {'id': 'ORD-002', 'status': 'processing', 'amount': 230} ] }) if __name__ == '__main__': app.run(host='0.0.0.0', port=8080)EOF cat > requirements.txt << 'EOF'flask==3.0.3gunicorn==21.2.0pytest==8.2.0EOF cat > Dockerfile << 'EOF'FROM python:3.12-slimWORKDIR /app COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt COPY app.py . RUN groupadd -r appgroup && \ useradd -r -g appgroup appuserRUN chown -R appuser:appgroup /appUSER appuser EXPOSE 8080CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]EOFCreate a Terraform file with a few intentional misconfigurations, modeled on an S3 logging bucket like the ones Swiggy's platform team would provision for order-event logs:
mkdir terraform cat > terraform/main.tf << 'EOF'## S3 bucket for application logsresource "aws_s3_bucket" "logs" { bucket = "devops-network-app-logs-2026"} ## No server-side encryption enabled - Checkov catches this as CKV_AWS_19## No bucket versioning enabled - Checkov catches this as CKV_AWS_52 resource "aws_security_group" "app" { name = "app-security-group" vpc_id = "vpc-12345678" ## SSH open to the entire internet - Checkov catches this as CKV_AWS_25 ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }}EOFCommon MistakeRunning the container as root because it's "just a local test." Even for practice projects, build the habit of a dedicated non-root user from the first Dockerfile you write — retrofitting it later on a real production image is far more disruptive.
Milestone 2: Run the Scanners Locally First
Concept
Running scanners locally before they ever touch CI does two things: it lets you fix obvious issues fast without waiting on a pipeline run, and it builds your intuition for what each tool actually reports — so when the pipeline fails later, you already know how to read the output.
Steps
# Trivy (macOS)brew install aquasecurity/trivy/trivy# Trivy (Linux)curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ | sh -s -- -b /usr/local/bin # Checkovpip install checkov # Snyk CLInpm install -g snykScan the built image:
docker build -t devsecops-app:local . trivy image \ --severity CRITICAL,HIGH \ --exit-code 0 \ devsecops-app:localdevsecops-app:local (debian 12.5)Total: 2 (HIGH: 1, CRITICAL: 1) Library Vulnerability Severity Fixed Versionlibssl3 CVE-2024-XXXXX CRITICAL 3.0.13-1zlib1g CVE-2023-XXXXX HIGH 1:1.2.13-1Scan the Terraform:
checkov -d terraform/ --framework terraformCheck: CKV_AWS_19 - S3 server side encryptionFAILED for resource: aws_s3_bucket.logs Check: CKV_AWS_52 - S3 bucket versioningFAILED for resource: aws_s3_bucket.logs Check: CKV_AWS_25 - SSH open to 0.0.0.0/0FAILED for resource: aws_security_group.app Passed checks: 3, Failed checks: 3Checkov found exactly the three issues planted in Milestone 1 — now you know precisely what the CI pipeline will catch before it ever runs in GitHub.
TipRun scanners locally as a pre-commit habit, not just before opening a PR. Catching a CRITICAL finding on your own machine takes seconds; catching the same finding after a 5-minute CI run wastes real time across a whole team.
Milestone 3: Build the GitHub Actions Security Pipeline
Concept
The pipeline below deliberately splits each scanner into two steps: one that reports (uploads a SARIF file to GitHub's Security tab, always runs) and one that enforces (fails the job on CRITICAL findings). Keeping these separate means you always get visibility into every finding, even the ones that don't block the merge — instead of an all-or-nothing gate that either floods you with noise or hides everything below CRITICAL.
Steps
mkdir -p .github/workflows cat > .github/workflows/security-pipeline.yml << 'EOF'name: Security Pipeline on: push: branches: [main] pull_request: branches: [main] jobs: unit-tests: name: Unit Tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' - run: pip install -r requirements.txt - run: pytest tests/ -v --tb=short trivy-scan: name: Container Security Scan runs-on: ubuntu-latest needs: unit-tests steps: - uses: actions/checkout@v4 - name: Build image for scanning run: docker build -t app:${{ github.sha }} . - name: Trivy scan (report) uses: aquasecurity/trivy-action@master with: image-ref: app:${{ github.sha }} format: sarif output: trivy-results.sarif severity: CRITICAL,HIGH - name: Upload results to Security tab uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: trivy-results.sarif - name: Fail build on CRITICAL vulnerabilities uses: aquasecurity/trivy-action@master with: image-ref: app:${{ github.sha }} format: table severity: CRITICAL exit-code: '1' checkov-scan: name: Infrastructure Security Scan runs-on: ubuntu-latest needs: unit-tests steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' - run: pip install checkov - name: Checkov report (non-blocking) run: | checkov -d terraform/ \ --framework terraform \ --output sarif \ --output-file-path checkov-results.sarif \ --soft-fail - name: Upload results to Security tab uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: checkov-results.sarif - name: Fail on critical Checkov findings run: | checkov -d terraform/ \ --framework terraform \ --check CKV_AWS_18,CKV_AWS_19,CKV_AWS_52 \ --compact snyk-scan: name: Dependency Security Scan runs-on: ubuntu-latest needs: unit-tests steps: - uses: actions/checkout@v4 - name: Run Snyk uses: snyk/actions/python@master continue-on-error: true env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: args: --severity-threshold=high --file=requirements.txt - name: Upload results to Security tab uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: snyk.sarif secret-scan: name: Secret Scanning runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Run Gitleaks uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} deploy: name: Deploy to Production runs-on: ubuntu-latest needs: [trivy-scan, checkov-scan, snyk-scan, secret-scan] if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ap-south-1 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - name: Build, tag, and push image env: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY: orders-api IMAGE_TAG: ${{ github.sha }} run: | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAGEOFCommon MistakeUsing
--exit-code 0for every Trivy step means findings get logged but never block anything. The pattern above is deliberate: one step reports (exit code 0, uploads SARIF), a separate step enforces (exit code 1 on CRITICAL). Collapsing these into a single step usually means someone accidentally disables the block while trying to "just see the report."
Milestone 4: Fix the Security Issues Checkov Found
Concept
Each fix below maps directly to one Checkov rule ID from Milestone 2. Reading the rule ID alongside the fix is a habit worth keeping — in a real Checkov report against hundreds of rules, being able to trace "CKV_AWS_19 failed" straight to "add an encryption block" is what separates fast triage from guesswork.
Steps
cat > terraform/main.tf << 'EOF'resource "aws_s3_bucket" "logs" { bucket = "devops-network-app-logs-2026"} ## Fixes CKV_AWS_19resource "aws_s3_bucket_server_side_encryption_configuration" "logs" { bucket = aws_s3_bucket.logs.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } }} ## Fixes CKV_AWS_52resource "aws_s3_bucket_versioning" "logs" { bucket = aws_s3_bucket.logs.id versioning_configuration { status = "Enabled" }} ## Fixes CKV_AWS_53/54/55/56resource "aws_s3_bucket_public_access_block" "logs" { bucket = aws_s3_bucket.logs.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true} ## Fixes CKV_AWS_25resource "aws_security_group" "app" { name = "app-security-group" vpc_id = "vpc-12345678" ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["10.0.0.0/8"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }}EOFcheckov -d terraform/ --framework terraform --compactPassed checks: 9, Failed checks: 0, Skipped checks: 0SecurityEven the fixed security group still allows SSH from the entire VPC CIDR (
10.0.0.0/8), not just a specific bastion host. That's an acceptable tradeoff for a learning project, but in a real environment — the kind Razorpay's infra team would run — you'd narrow this further to a specific bastion or VPN CIDR range only.
Milestone 5: Post a Security Report Comment on the PR
Concept
Uploading SARIF results to the Security tab is useful, but most engineers live in the pull request, not the Security tab. Posting a short summary comment directly on the PR puts the result where the reviewer is already looking — that small ergonomic difference is often what determines whether a team actually reads security findings or ignores them.
Steps
cat >> .github/workflows/security-pipeline.yml << 'EOF' security-summary: name: Post Security Summary to PR runs-on: ubuntu-latest needs: [trivy-scan, checkov-scan] if: github.event_name == 'pull_request' steps: - name: Post summary comment uses: actions/github-script@v7 with: script: | const body = `## Security Scan Results | Scan | Status | |------|--------| | Container (Trivy) | ${{ needs.trivy-scan.result == 'success' && 'Passed' || 'Failed' }} | | Infrastructure (Checkov) | ${{ needs.checkov-scan.result == 'success' && 'Passed' || 'Failed' }} | View full results in the Security tab of this repository.`; github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: body });EOFTipExtend this same pattern to the Snyk and Gitleaks jobs once you're comfortable with it — a single comment summarizing all four gates is more useful to a reviewer than four separate ones.
Validation & Testing
Verification Steps
# 1. Confirm the pipeline runs and all gates executegit add .git commit -m "Add DevSecOps security pipeline"git push origin main# Check github.com/your-username/your-repo/actions # 2. Confirm Checkov passes locally after fixescheckov -d terraform/ --framework terraform --compact# Expected: Failed checks: 0 # 3. Confirm Trivy still reports correctlytrivy image --severity CRITICAL app:local# Expected: exits 1 only if a CRITICAL CVE is present # 4. Confirm a PR triggers the security summary comment# Open a test PR and check for the automated commentTipDeliberately reintroduce one of the fixed misconfigurations in a test branch and open a PR — watching the pipeline correctly fail is the best way to confirm your gates actually gate, rather than just report.
Common Mistakes Recap
| Mistake | Why It Breaks | Fix |
|---|---|---|
--exit-code 0 everywhere |
Findings logged but never block | Use exit-code 1 in the enforcement step only |
--soft-fail left on permanently |
Misconfigurations never actually block | Set a deadline to remove it after adoption |
| Not caching the Trivy DB | Adds 2-3 min to every run | Cache the DB directory between CI runs |
| Scanning only the final image | Can't tell base image vs. app code source | Scan the base image separately too |
Common MistakeEnabling every Checkov rule from day one produces hundreds of failures at once and overwhelms a team that isn't ready for it. Start with the highest-severity, easiest-to-fix rules — encryption, public access blocks — using the
--checkflag to scope to specific rule IDs, then expand incrementally as issues get resolved.
Videos & Guides
Trivy Documentation
Official reference for Trivy's image, filesystem, and repository scanning modes used in this project's container gate.
Checkov Documentation
Official reference for Checkov's Terraform policy checks, including the CKV_AWS rule IDs used in this project.
Snyk CLI Documentation
Reference for configuring Snyk's dependency scanning and severity thresholds used in this project's dependency gate.