DevOps9 min read

Building a Secure CI/CD Pipeline

DevSecOps for solo developers and small teams — without the enterprise overhead

Liban Abdullahi·
260 views
Building a Secure CI/CD Pipeline

Most DevSecOps guides are written for teams with a dedicated security engineer, a CISO, and a budget for enterprise tooling. If you're a solo developer or a team of three shipping a SaaS product, those guides are technically correct and practically useless.

This post is different. It covers how to get 80% of the security value of a mature DevSecOps pipeline using GitHub Actions, free and open-source tools, and about a day of setup. The same pipeline I use on every project I ship at CraftRipple Studio.


Why this matters now

The numbers aren't comfortable reading. A Snyk report from early 2026 found that 64% of secrets leaked in 2022 were still active in 2026 — organizations that detect but don't remediate carry the same underlying risk.

Supply chain attacks have grown sharply. The EU Cyber Resilience Act entered enforcement in late 2025, and even if you're not directly regulated, your clients increasingly are. A security incident on a product you built is your problem too.

The practical reality: if you only do CI/CD without security controls, you'll ship fast and argue later — the "later" is usually an audit scramble, a production incident, or a growing pile of exceptions nobody remembers approving.

The good news: you don't need an enterprise stack. You need a well-ordered pipeline and the right free tools.


The mental model: layers, not a single scan

The mistake most developers make is treating security as one step — running a scanner before deploy and hoping for the best. A useful pipeline has four distinct layers:

LayerWhen it runsWhat it catches
Pre-commitBefore code reaches GitSecrets, credential leaks
Pull request gateOn every PRCode vulnerabilities, dependency issues
Build gateOn every buildContainer vulnerabilities, IaC misconfigs
RuntimePost-deploy, continuousCVEs in deployed versions, live misconfigs

Each layer is cheap to pass and expensive to bypass — which is exactly what you want.


Layer 1: Pre-commit — stop secrets before they leave your machine

The highest ROI security control in existence is catching a hardcoded API key before it ever reaches your Git history. Once it's in history, rotating it is mandatory and painful. Prevention is free.

Set up Gitleaks as a pre-commit hook:

# Install pre-commit (once per machine)
pip install pre-commit

# Add to your repo: .pre-commit-config.yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks
# Install the hook into your repo (once per repo)
pre-commit install

That's it. Every git commit now scans the staged diff for credentials, API keys, private keys, and tokens against a ruleset of 150+ patterns. It fails loudly if anything matches.

If you have existing test fixtures that trigger false positives, add a .gitleaksignore file:

# .gitleaksignore
# path:hash format — suppress specific known findings
tests/fixtures/mock_credentials.py:abc123def456

Be surgical about what you suppress. Never disable scanning entirely.


Layer 2: Pull request gate — SAST, dependencies, and IaC

This layer runs automatically on every pull request. It's the backbone of a secure pipeline: catches code-level vulnerabilities, vulnerable dependencies, and infrastructure misconfigurations before they merge.

Here's the full GitHub Actions workflow:

# .github/workflows/security.yml
name: Security Scan

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  # SAST — static code analysis
  sast:
    name: Semgrep
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: returntocorp/semgrep-action@v1
        with:
          config: "auto"  # auto-detects language and applies relevant rulesets

  # Dependency scan — known CVEs in your packages
  dependencies:
    name: Trivy Dependencies
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Scan filesystem
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: fs
          scan-ref: .
          severity: CRITICAL,HIGH
          exit-code: 1  # fail the build on critical findings

  # IaC scan — Terraform, Docker Compose, K8s manifests
  iac:
    name: Checkov IaC
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: bridgecrewio/checkov-action@v12
        with:
          directory: .
          soft_fail: true  # warn for now, harden to false once baseline is clean

A few notes on these tool choices:

Semgrep runs on the PR diff, not the whole codebase. It's fast (typically under 60 seconds) and has language-specific rulesets for Python, TypeScript, React, and most modern stacks. The auto config is a solid default.

Trivy handles both dependency scanning (known CVEs in package.json, requirements.txt, go.mod) and container scanning. One tool, two jobs. exit-code: 1 on CRITICAL means the pipeline hard-fails — developers get a specific finding, not a vague warning they can ignore.

Checkov scans Terraform, Docker Compose, Kubernetes manifests, and GitHub Actions workflows themselves. Misconfigured infrastructure is one of the most common real-world breach vectors. Start with soft_fail: true until you've established a clean baseline, then flip it.


Layer 3: Build gate — container scanning and Nuclei

Once your code passes the PR gate and gets built into a container, you need to scan the image itself. Base images accumulate CVEs over time — your code might be clean while your node:20 base image is carrying critical vulnerabilities.

# Add to .github/workflows/security.yml

  container-scan:
    name: Container Scan
    runs-on: ubuntu-latest
    needs: dependencies  # only runs if dep scan passed
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      - name: Scan container image
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: image
          image-ref: myapp:${{ github.sha }}
          severity: CRITICAL,HIGH
          exit-code: 1

For web applications, I also run Nuclei against a staging deployment on every push to main. Nuclei runs the community template library — thousands of checks for misconfigurations, exposed endpoints, insecure headers, and known CVEs — against your live app:

  nuclei-scan:
    name: Nuclei Web Scan
    runs-on: ubuntu-latest
    needs: container-scan
    steps:
      - uses: projectdiscovery/nuclei-action@v3
        with:
          target: https://staging.yourapp.com
          args: >-
            -severity medium,high,critical
            -t exposures/
            -t misconfiguration/
            -t technologies/
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

This catches things static analysis can't: missing security headers, open redirects, exposed .env files, directory listing, and misconfigured CORS policies. In my homelab I run this as part of an N8N automation pipeline against bug bounty targets — the same templates work equally well against your own staging environment.


Layer 4: Dependency updates — automate what humans forget

The weakest link in most small-team pipelines isn't the initial scan — it's that nobody updates dependencies after launch. A package that was clean in January can have a critical CVE by March.

Enable Dependabot in your repo:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    open-pull-requests-limit: 5
    labels:
      - dependencies
      - security

  - package-ecosystem: docker
    directory: /
    schedule:
      interval: weekly

Dependabot opens PRs automatically when new versions are available. Your existing security workflow runs on those PRs, so a dependency update that introduces a regression gets caught before it merges.


Secrets management in production

Pre-commit hooks stop new secrets from leaking. But what about secrets that need to exist — database URLs, API keys, tokens?

Never put them in environment variables baked into images, .env files committed to the repo, or hardcoded in config files. In practice:

For Vercel/Netlify deployments: use the platform's environment variable management. Secrets are injected at build time and never stored in the repo.

For AWS deployments: use Parameter Store with encrypted SecureString parameters:

# fetch at startup — not hardcoded, not in env vars
import boto3

def get_secret(name: str) -> str:
    client = boto3.client('ssm', region_name='eu-west-1')
    response = client.get_parameter(Name=name, WithDecryption=True)
    return response['Parameter']['Value']

DATABASE_URL = get_secret('/prod/myapp/database-url')

For GitHub Actions itself: use repository secrets (Settings → Secrets → Actions). Never interpolate secrets directly into run: commands — they'll appear in logs. Use env: instead:

# Don't do this
- run: curl -H "Authorization: ${{ secrets.API_KEY }}" https://api.example.com

# Do this
- run: curl -H "Authorization: $API_KEY" https://api.example.com
  env:
    API_KEY: ${{ secrets.API_KEY }}

Pipeline permissions: least privilege in GitHub Actions

Agentic AI workflows and CI/CD pipelines that autonomously run commands and deploy changes need the same RBAC and audit-trail rigor as human operators. The same applies to your regular GitHub Actions workflows.

By default, GitHub Actions workflows get broad permissions. Lock them down:

# Set at the workflow level
permissions:
  contents: read      # read the repo
  # Only add what each job actually needs:
  # security-events: write  # for uploading SARIF results
  # packages: write         # for pushing to GHCR
  # id-token: write         # for OIDC-based cloud auth

Use OIDC for cloud authentication instead of long-lived access keys. AWS, GCP, and Azure all support it:

- name: Configure AWS credentials via OIDC
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
    aws-region: eu-west-1
    # No AWS_ACCESS_KEY_ID needed — OIDC token is short-lived

This eliminates an entire class of credential exposure — there's no static secret to leak.


Handling false positives without disabling everything

The fastest way to kill a security pipeline is to make it noisy. Developers start ignoring alerts, then start suppressing them, then the pipeline becomes security theater.

The practical pattern is: fast checks early, strict checks late. Shift left what's cheap to run and cheap to fix, then enforce tougher controls close to deploy where they actually protect production.

A tiered approach that works:

  • CRITICAL findings → hard block. Pipeline fails, PR cannot merge.
  • HIGH findings → soft block. PR can merge with a documented exception (tracked in an issue).
  • MEDIUM findings → warning in PR comment. Tracked but non-blocking.
  • LOW/INFO → suppressed from PR view. Reviewed in weekly triage.

In Trivy and Semgrep, this maps directly to severity flags. Start strict on CRITICAL only, then expand as the team develops confidence in the signal quality.


The complete picture

Here's how the layers compose end-to-end:

Developer writes code[Pre-commit] Gitleaks — catches credentials before push
    ↓
[Pull Request] Semgrep + Trivy filesystem + Checkov
    — catches code vulns, dep CVEs, IaC misconfigs
    ↓
[Build] Trivy container scan + Nuclei web scan on staging
    — catches image vulns, live misconfigs, exposed endpoints
    ↓
[Ongoing] Dependabot weekly PRs
    — catches newly disclosed CVEs in dependencies

The full setup takes about half a day on a fresh repo. On an existing project, plan for an extra sprint to establish a clean baseline on IaC and dependency findings before enabling hard failures.


What this doesn't replace

This pipeline handles the automated, repeatable checks. It doesn't replace:

  • Threat modeling for critical features — spending 30 minutes thinking through how a new auth flow could be abused before writing it
  • Manual code review for security-sensitive code paths (authentication, authorization, payment flows)
  • Penetration testing before major releases, especially for regulated industries

Think of the automated pipeline as the floor, not the ceiling. It catches the commodity vulnerabilities so your manual review time goes toward the interesting ones.


Liban Abdullahi is the founder of CraftRipple Studio, a Brussels-based development studio specialising in secure full-stack web applications. He runs a Wazuh SIEM and Nuclei scanning homelab, participates in bug bounty on Intigriti, and is studying for a Bachelor in Data Science, Protection and Security.

Work with CraftRipple Studio if you need security built into your pipeline from day one.

Tags

#development#security#CI/CD#DevSecOps#GitHub Actions