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

Domains
DEVSECOPSTRIVYSECURITY
Technologies
CHECKOV

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.

◈ DIAGRAM
[ 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.

Tip

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

Security

A 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
Bash
mkdir devsecops-project
cd 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.3
gunicorn==21.2.0
pytest==8.2.0
EOF
cat > Dockerfile << 'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN groupadd -r appgroup && \
useradd -r -g appgroup appuser
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
EOF

Create 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:

Bash
mkdir terraform
cat > terraform/main.tf << 'EOF'
## S3 bucket for application logs
resource "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"]
}
}
EOF
Common Mistake

Running 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
Bash
# 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
# Checkov
pip install checkov
# Snyk CLI
npm install -g snyk

Scan the built image:

Bash
docker build -t devsecops-app:local .
trivy image \
--severity CRITICAL,HIGH \
--exit-code 0 \
devsecops-app:local
TEXT
devsecops-app:local (debian 12.5)
Total: 2 (HIGH: 1, CRITICAL: 1)
Library Vulnerability Severity Fixed Version
libssl3 CVE-2024-XXXXX CRITICAL 3.0.13-1
zlib1g CVE-2023-XXXXX HIGH 1:1.2.13-1

Scan the Terraform:

Bash
checkov -d terraform/ --framework terraform
TEXT
Check: CKV_AWS_19 - S3 server side encryption
FAILED for resource: aws_s3_bucket.logs
Check: CKV_AWS_52 - S3 bucket versioning
FAILED for resource: aws_s3_bucket.logs
Check: CKV_AWS_25 - SSH open to 0.0.0.0/0
FAILED for resource: aws_security_group.app
Passed checks: 3, Failed checks: 3

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

Tip

Run 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
Bash
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_TAG
EOF
Common Mistake

Using --exit-code 0 for 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
Bash
cat > terraform/main.tf << 'EOF'
resource "aws_s3_bucket" "logs" {
bucket = "devops-network-app-logs-2026"
}
## Fixes CKV_AWS_19
resource "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_52
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
## Fixes CKV_AWS_53/54/55/56
resource "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_25
resource "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"]
}
}
EOF
Bash
checkov -d terraform/ --framework terraform --compact
TEXT
Passed checks: 9, Failed checks: 0, Skipped checks: 0
Security

Even 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
Bash
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
});
EOF
Tip

Extend 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
Bash
# 1. Confirm the pipeline runs and all gates execute
git 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 fixes
checkov -d terraform/ --framework terraform --compact
# Expected: Failed checks: 0
# 3. Confirm Trivy still reports correctly
trivy 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 comment
Tip

Deliberately 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 Mistake

Enabling 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 --check flag to scope to specific rule IDs, then expand incrementally as issues get resolved.