Too much work per run
Optimize security scans without slowing down PRs
By Keith Mazanec, Founder, CostOps ยท Updated January 31, 2026
A developer opens a PR. CodeQL spins up, builds a database from the full codebase, and runs hundreds of security queries. Fifteen minutes pass before the first test result even appears. On a team pushing 200 PRs a month, that's 150+ hours of runner time just for security scanning, most of it redundant with the full scan that already ran on main. The fix is a tiered approach: full scans on merge, lightweight diff-aware scans on PRs.
Symptoms
How to tell if security scans are inflating your CI bill
Open your Actions tab and look at the workflows with CodeQL, Trivy, or security in the name. If any of these patterns match, you're overspending:
-
Security steps dominate PR runtime. Your security scanning workflow takes 15–30 minutes per run, and it triggers on every PR push. The actual test and build steps finish in 5 minutes, but the pipeline waits on CodeQL.
-
Full scans run on every commit. The same CodeQL database gets built from scratch on every push to a PR branch, even though only a handful of files changed. The scan finds the same zero issues it found on the last push.
-
Low finding rate relative to scan time. Security scans rarely surface actionable findings on PRs because the code was already scanned when it merged to main. You're paying 15 minutes per run for near-zero signal.
-
Vulnerability DB downloads on every run. Tools like Trivy or Grype download their vulnerability database fresh each time because there's no caching configured. That's 30–60 seconds of wasted network time per job.
Metrics
Quantify the security scan tax
CodeQL is the most common offender. A typical scan on a mid-size JavaScript/TypeScript codebase takes 15–20 minutes. On Java/Kotlin repositories, it can exceed 60 minutes. Here's the math for a team running CodeQL on every PR push:
Before optimization
At $0.006/min (Linux 2-core)
After optimization (scan on main only)
Save $43.20/mo · $518/year · per scan workflow
That's on a standard Linux 2-core runner. If you're running CodeQL on a larger 8-core runner at $0.022/min to speed it up, the before cost jumps to $237.60/mo and the savings to $158.40/mo. On Java/Kotlin codebases where scans routinely hit 45–60 minutes, multiply accordingly.
Fix 1
Move full CodeQL scans off PRs
The default CodeQL starter workflow runs on both push to main and pull_request. On PRs, CodeQL builds a full database and runs every query against the entire codebase, even though only a few files changed. For most teams, the finding rate on PRs is near zero because the same code was already scanned when it merged.
Remove the pull_request trigger. Run CodeQL on push to main and on a weekly schedule. This catches newly disclosed vulnerabilities in existing code without blocking every PR.
name: CodeQL on: push: branches: [main] pull_request: branches: [main] schedule: - cron: '0 2 * * 1'
name: CodeQL on: push: branches: [main] schedule: - cron: '0 2 * * 1' # No pull_request trigger # PR security handled by lightweight # scanner (see Fix 2)
GitHub Team + Code Security CodeQL for private repositories requires at least GitHub Team with the Code Security add-on ($30/active committer/month). Public repositories can use CodeQL for free on any plan.
One caveat: removing the PR trigger means CodeQL won't annotate PRs with inline findings. If your team relies on PR annotations from CodeQL, consider keeping the PR trigger but only on the default query suite (not security-extended), and exclude known slow queries. See Fix 3 for how to tune this.
Fix 2
Use a diff-aware scanner on PRs
Once full CodeQL moves off PRs, replace it with a lighter tool that only scans changed files. Tools like Semgrep run in diff-aware mode automatically on pull_request events, analyzing only the lines that changed and reporting only newly introduced findings. A typical diff-aware scan finishes in 1–3 minutes instead of 15–20.
name: Security (PR) on: pull_request: jobs: semgrep: runs-on: ubuntu-latest container: image: semgrep/semgrep steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Needed for diff detection - run: semgrep ci env: SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
The semgrep ci command automatically detects the pull_request event context and runs in diff-aware mode. It compares the PR head against the merge base and only reports findings introduced in the diff. No configuration needed for this behavior since it's the default.
For container image scanning on PRs, use Trivy with severity filtering to skip low-priority findings:
trivy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: aquasecurity/trivy-action@master with: scan-type: 'fs' severity: 'CRITICAL,HIGH' # Skip LOW/MEDIUM on PRs skip-dirs: 'test,vendor,node_modules' exit-code: '1' cache: true # Cache vuln DB across runs
This two-scanner approach gives you fast PR feedback (1–3 minutes) plus comprehensive coverage on main (15–20 minutes). Developers don't wait on security scans, and nothing ships without a full CodeQL pass.
Fix 3
Reduce CodeQL scan time with config tuning
If you need to keep CodeQL on PRs (for compliance or inline annotations), you can reduce its runtime by excluding test code, generated files, and known-slow queries. The default query suite is already optimized for speed, so don't upgrade to security-extended on PRs unless you have a specific reason.
name: "Optimized CodeQL config" paths-ignore: - test/** - '**/*.test.js' - '**/*.spec.ts' - '**/node_modules/**' - '**/vendor/**' - '**/generated/**' - '**/dist/**' query-filters: - exclude: id: js/regex-injection # Known to take 150+ min - exclude: id: js/improper-code-sanitization # Can double total scan time
Reference this config from your CodeQL workflow's init step:
- uses: github/codeql-action/init@v4 with: languages: javascript-typescript config-file: .github/codeql/codeql-config.yml queries: security-extended # Use 'default' on PRs for speed
For interpreted languages (JavaScript, TypeScript, Python, Ruby), set build-mode: none in your matrix to skip the autobuild step entirely. CodeQL extracts source directly without compilation for these languages:
strategy: matrix: include: - language: javascript-typescript build-mode: none # Skip autobuild for JS/TS
One caveat: the query-filters exclusions trade coverage for speed. The js/regex-injection and js/improper-code-sanitization queries are legitimate security checks. Run them on the scheduled weekly scan where time is not an issue, and exclude them only from the PR or push-to-main triggers.
Fix 4
Cache vulnerability databases across runs
Tools like Trivy and Grype download their vulnerability database on every run unless explicitly cached. The Trivy DB is roughly 40 MB compressed, and downloading it adds 30–60 seconds per job. On 600 PR pushes per month, that's 5–10 hours of wasted download time.
Pre-fetch the database on a daily schedule and cache it. Then configure your PR scans to skip the download and use the cached copy.
name: Update Trivy DB Cache on: schedule: - cron: '0 0 * * *' # Daily at midnight workflow_dispatch: jobs: update-cache: runs-on: ubuntu-latest steps: - name: Get current date id: date run: echo "date=$(date +%Y-%m-%d)" >> $GITHUB_OUTPUT - name: Download Trivy DB run: | mkdir -p .cache/trivy/db oras pull ghcr.io/aquasecurity/trivy-db:2 tar -xf db.tar.gz -C .cache/trivy/db - uses: actions/cache/save@v4 with: path: .cache/trivy key: trivy-db-${{ steps.date.outputs.date }}
Then in your PR workflow, restore the cache and skip the download:
- uses: actions/cache/restore@v4 with: path: .cache/trivy key: trivy-db-${{ steps.date.outputs.date }} restore-keys: trivy-db- # Falls back to yesterday's DB - uses: aquasecurity/trivy-action@master with: scan-type: 'fs' severity: 'CRITICAL,HIGH' env: TRIVY_SKIP_DB_UPDATE: true TRIVY_SKIP_JAVA_DB_UPDATE: true TRIVY_CACHE_DIR: .cache/trivy
The restore-keys prefix ensures that even if today's cache hasn't been built yet, yesterday's DB is used. For most vulnerability databases, a one-day-old copy is perfectly adequate for PR scanning.
Reference
Tiered security scanning strategy
The most effective approach splits scanning across three tiers based on trigger event. Each tier balances thoroughness against developer wait time:
| Trigger | Scanners | Duration |
|---|---|---|
| pull_request | Semgrep (diff-aware), Trivy (cached, HIGH+CRITICAL) | 1–3 min |
| push to main | CodeQL (default suite), full Trivy, dependency review | 10–20 min |
| schedule (weekly) | CodeQL (security-extended), full SAST, compliance audit | 20–45 min |
This model matches GitHub's own recommendation: run comprehensive analysis on main and schedule, and use the fastest viable scanner on PRs. The weekly run with security-extended catches newly disclosed CVEs in code that hasn't changed, which a PR-only strategy would miss entirely.
Reference
Typical CodeQL scan durations
These are real-world numbers from public GitHub Issues. Use them to estimate your own scan cost:
| Language | Codebase | Typical Scan | Worst Case |
|---|---|---|---|
| JavaScript/TS | ~10K files | 10–20 min | 150+ min |
| Java/Kotlin | ~5K files | 30–60 min | 240+ min |
| Python | ~10K files | 15–30 min | 720+ min |
| Go | ~5K files | 5–15 min | N/A |
Worst-case durations are triggered by specific queries (like js/regex-injection) or CodeQL version regressions. If you see your scan time jump from 10 to 90+ minutes, check if a recent CodeQL version update caused a regression. Pinning to a known-good version is a valid short-term fix.
Related guides
Scheduled Jobs Don't Earn Their Keep
Audit scheduled cron workflows to ensure they deliver value for their cost.
Slow Failures: Expensive Before Cheap
Reorder jobs so cheap lint checks run before expensive scans and builds.
Speed Up Slow CI Pipelines
Find bottleneck jobs and add targeted caching and parallelism that actually saves.
Underpowered Runners Cost More
When a larger runner finishes faster, the total cost can actually decrease.