//PART 9 OF 10
Part 9: An Exception Process for Security Findings You Can't Fix Yet
· 21 min read
Indonesia//TUTORIAL · 9 OF 10 — A Secure CI/CD Pipeline with Go, GitHub Actions, Vault, and Dokploy
- 01Part 1: Why I Built a Secure CI/CD Pipeline, and Getting the Tools Ready
- 02Part 2: Vault Secrets, Policies, and an AppRole per Environment
- 03Part 3: A Small Go App That Reads Its Secrets from a File and Refuses to Start on Bad Config
- 04Part 4: A Vault Agent Sidecar and Docker Compose, Secrets Without the App Knowing Vault Exists
- 05Part 5: The GitHub Repo and the PR Workflow as a Security Gate
- 06Part 6: Rulesets That Block Merges, and Five Demo PRs That Must Fail
- 07Part 7: Setting Up the Dev and Production Environments in Dokploy
- 08Part 8: The Deploy Workflow, Build Once, Scan, SBOM, GHCR, Then Promote to Production
- 09Part 9: An Exception Process for Security Findings You Can't Fix Yet
- 10Part 10: CI Gets Its Dokploy Token from Vault through GitHub OIDC, and Wrapping Up the Series
By Part 8 the pipeline is strict. A secret, a CVE, a Dockerfile misconfig or dangerous code turns the PR red and blocks the merge. The deploy stops too if the image has a CRITICAL CVE that already has a fix.
The real world isn't that tidy. Sometimes a CVE in the base image has no patch yet, and the feature still has to ship. Sometimes a scanner gets it wrong: a fixed test string in the code looks like an API key. Without an official path for these cases, people find a shortcut: turn the scanner off, lower the severity, or quietly add an ignore. Once that happens, the gate is just for show.
In this part I build an exception process: an official way to accept one finding, for a limited time, with the name of the person responsible and the date the permission runs out. It's also the classic auditor question, especially in a bank: "This finding you didn't fix, who approved it, and until when?" After this part, the answer lives in Git history.
The concept first: a healthy exception
The analogy I use is a sick note. There's a letter, a reason, someone who signs it, and it's valid until a certain date. After that date you're absent without leave again. It's not a lifetime pass.
From that I get four rules:
Rule | Why | Enforced by |
|---|---|---|
Through a PR, stored in the repo | Git history becomes the audit trail: who, when, why | The rulesets from Part 6: every change goes through a PR |
Owner + reason | Someone is responsible for keeping an eye on it |
|
Expires within 90 days | The risk is accepted temporarily. After that, fix it or renew it with a fresh reason | The script + Trivy's own |
Narrow: 1 CVE, 1 fingerprint, 1 rule on 1 line | A broad exception (a whole rule, a whole folder) can hide new findings nobody ever assessed | The script bans broad suppressions |
The end result looks like this:
Scanner finding in a PR │ ├─ can it be fixed? ──► fix it. Done. │ └─ not yet (no patch / false positive) │ ▼ Exception PR: 1 entry = 1 finding, plus a line above it: exception: owner=@user exp=YYYY-MM-DD reason="..." │ ▼ "exceptions" check (scripts/check-exceptions.sh), required by the ruleset ✗ no owner/reason/date ✗ already expired ✗ more than 90 days ✗ nosemgrep without a rule id ✗ gitleaks:allow, .semgrepignore, .gitleaks.toml, trivy.yaml │ ▼ merge → recorded in Git history │ ▼ every Monday: "exception-expiry" workflow → expired? red Trivy also reports the finding again by itself after exp:Why it needs its own script
I first assumed the scanners' built-in features would be enough. After trying each one locally, they weren't:
Scanner | How to suppress | Has an expiry? |
|---|---|---|
Trivy |
| Yes. After |
Semgrep |
| No |
Gitleaks | A fingerprint in | No |
Something that surprised me: Semgrep also accepts a nosemgrep without a rule id, and that silences every rule on that line. So "owner, reason, date" for Semgrep and Gitleaks can only be enforced by an extra check. While I was at it, the script gives every scanner the same exception format.
Note
govulncheck and dependency-review have no exception on purpose
govulncheck only reports when your code actually calls the vulnerable function. That's real risk: upgrade, or stop calling it. dependency-review only checks dependencies the PR adds, so the fix is to pick another version. Not every gate needs a way around it.
Write the policy down first
An unwritten rule is just a habit. I put the policy in docs/exception-process.md, in English because the repo is public. Trimmed here; I left out the examples and limits sections:
# Security finding exceptions The PR gates block a merge on any finding. Sometimes a finding has to be accepted for a while: a CVEwith no fixed version yet, or a scanner false positive. This is the only allowed way to do that. ## Rules 1. **Fix first.** An exception is for a finding that cannot be fixed now, not one that is inconvenient.2. **Through a PR.** Exceptions live in the repo, so Git history records who added one, when and why. The PR description links an issue with the details.3. **One finding per entry.** One CVE ID, one Gitleaks fingerprint, or one `nosemgrep` with a rule id. Never a whole rule, a whole severity, or a whole path.4. **Owner, reason and expiry.** Every entry has this line directly above it: ``` exception: owner=@github-user exp=YYYY-MM-DD reason="at least 10 characters, link to the issue" ``` `exp` is at most **90 days** away. To keep an exception longer, renew it in a new PR with a fresh reason.5. **Real secrets are never excepted.** A Gitleaks finding that is a real credential is revoked and rotated. Gitleaks exceptions are for false positives only. ## Per scanner | Scanner | Where | Entry || --- | --- | --- || Trivy (image, config) | `.trivyignore` | `CVE-2026-12345 exp:2026-12-31` or `DS-0002 exp:2026-12-31` || Gitleaks | `.gitleaksignore` | Fingerprint `<commit>:<file>:<rule-id>:<line>` || Semgrep | the source line | `// nosemgrep: <rule-id>`, with the exception line directly above || govulncheck | none | No suppression || dependency-review | none | No suppression | Not allowed, because they suppress findings without an owner or expiry: `.semgrepignore`, `.gitleaks.toml`,`.trivyignore.yaml`, `trivy.yaml`, a bare `nosemgrep`, and `gitleaks:allow`.Rule 5 matters most. If Gitleaks finds a real secret, the answer is never an exception. Revoke it, rotate it, done. Gitleaks exceptions are for false positives only.
The official exception files
I create both files now, even though they only hold a comment header. The reason is practical: the workflow will point at .trivyignore explicitly, and if the file is missing, trivy-action stops with cannot find ignorefile.
# Trivy exceptions (image CVEs and config checks). Rules: docs/exception-process.md# Each entry needs this line directly above it, and the same date as Trivy's exp:# # exception: owner=@github-user exp=YYYY-MM-DD reason="why it is accepted, link to the issue"# CVE-YYYY-NNNNN exp:YYYY-MM-DD# Gitleaks exceptions, for false positives only. A real leaked secret is rotated, never ignored.# Rules: docs/exception-process.md. Each fingerprint needs this line directly above it:# # exception: owner=@github-user exp=YYYY-MM-DD reason="why it is not a secret, link to the issue"# <commit>:<file>:<rule-id>:<line>Trivy and Gitleaks both treat lines starting with # as comments. I tested it, so the header doesn't except anything.
The enforcer: scripts/check-exceptions.sh
This is the core of the part. The script reads every place a suppression can hide and rejects anything that breaks the rules. I'm showing it in full because every piece has a reason:
#!/usr/bin/env bash# Enforces docs/exception-process.md: every scanner suppression carries an owner, a reason and an expiry# no more than MAX_DAYS away. Run from the repo root. Exits 1 on any violation.set -euo pipefail MAX_DAYS="${MAX_DAYS:-90}"WARN_DAYS="${WARN_DAYS:-14}"TODAY="${TODAY:-$(date -u +%F)}"META_RE='exception: owner=@[A-Za-z0-9-]+ exp=([0-9]{4}-[0-9]{2}-[0-9]{2}) reason="[^"]{10,}"'NOSEM_RE='nosemgrep: ?[A-Za-z0-9_.-]+'errors=0 fail() { echo "::error file=$1,line=$2::$3"; errors=$((errors + 1)); } # GNU date on the runner, BSD date on macOS. valid_date prints nothing for an impossible date.valid_date() { date -u -d "$1" +%F 2>/dev/null || date -u -j -f %F "$1" +%F 2>/dev/null || true; }add_days() { date -u -d "$1 + $2 days" +%F 2>/dev/null || date -u -j -v+"$2"d -f %F "$1" +%F; } LIMIT="$(add_days "$TODAY" "$MAX_DAYS")"WARN_FROM="$(add_days "$TODAY" "$WARN_DAYS")" # check_meta FILE LINE TEXT: TEXT is the line directly above the suppression. Sets $exp when it is valid.check_meta() { exp="" if [[ ! "$3" =~ $META_RE ]]; then fail "$1" "$2" "suppression needs 'exception: owner=@user exp=YYYY-MM-DD reason=\"...\"' on the line directly above" return 1 fi exp="${BASH_REMATCH[1]}" if [ "$(valid_date "$exp")" != "$exp" ]; then fail "$1" "$2" "exp=$exp is not a real date" return 1 elif [[ "$exp" < "$TODAY" ]]; then fail "$1" "$2" "exception expired on $exp: fix the finding or renew it in a new PR" return 1 elif [[ "$exp" > "$LIMIT" ]]; then fail "$1" "$2" "exp=$exp is more than $MAX_DAYS days away (latest allowed $LIMIT)" return 1 elif [[ "$exp" < "$WARN_FROM" ]]; then echo "::warning file=$1,line=$2::exception expires on $exp" fi} # Plain ignore files: every entry needs the exception line directly above it.# shellcheck disable=SC2094 # fail only prints the file name, it never writes to the filecheck_ignore_file() { local file="$1" n=0 prev="" line [ -f "$file" ] || return 0 while IFS= read -r line || [ -n "$line" ]; do n=$((n + 1)) case "$line" in '' | '#'*) prev="$line"; continue ;; esac # Trivy only re-reports a finding after its own exp: date, so it must match the exception line. if check_meta "$file" "$n" "$prev" && [ "$file" = .trivyignore ] && [[ "$line" != *" exp:$exp" ]]; then fail "$file" "$n" "Trivy entry must end with ' exp:$exp'" fi prev="$line" done < "$file"} # These files suppress findings silently, with no owner or expiry, so they are not allowed at all.for f in .semgrepignore .gitleaks.toml .trivyignore.yaml trivy.yaml; do if [ -e "$f" ]; then fail "$f" 1 "not allowed: use the exception process in docs/exception-process.md" fidone check_ignore_file .trivyignorecheck_ignore_file .gitleaksignore # Inline suppressions. gitleaks:allow is banned because a .gitleaksignore fingerprint pins one finding, not a whole line.while IFS=: read -r file n text; do if [[ "$text" == *gitleaks:allow* ]]; then fail "$file" "$n" "gitleaks:allow is not allowed: add the finding's fingerprint to .gitleaksignore" continue fi if [[ ! "$text" =~ $NOSEM_RE ]]; then fail "$file" "$n" "nosemgrep must name the rule id (nosemgrep: <rule-id>)" continue fi prev="" [ "$n" -gt 1 ] && prev="$(sed -n "$((n - 1))p" "$file")" check_meta "$file" "$n" "$prev" || truedone < <(git grep -n -I -E 'nosemgrep|gitleaks:allow' -- . ':!*.md' ':!scripts/check-exceptions.sh' || true) if [ "$errors" -gt 0 ]; then echo "$errors exception problem(s). See docs/exception-process.md" >&2 exit 1fiecho "exceptions OK (today $TODAY, latest allowed exp $LIMIT)"The parts that matter:
Part | What it does |
|---|---|
| The regex for the exception line: |
| Prints |
| Try GNU |
| A string comparison. |
14-day warning | Under 14 days left prints a yellow |
| The entry must end with |
Banned files |
|
| Finds |
Why ban gitleaks:allow when it's an official Gitleaks feature? Because that comment ignores one line, forever, no matter what the line later contains. A fingerprint in .gitleaksignore is much narrower: one finding in one commit.
Warning
`git grep` only sees tracked files
A new file you haven't git add-ed isn't checked. In CI everything is committed, so that's fine. When testing locally, git add first. This is exactly why I once had exceptions OK locally and a red check in CI.
Check: run it from the repo root.
chmod +x scripts/check-exceptions.shgit add -Abash scripts/check-exceptions.shexceptions OK (today 2026-10-31, latest allowed exp 2027-01-29)Then a quick negative test: a bare nosemgrep must be rejected.
printf 'package demo\n\nconst a = "x" // nosemgrep\n' > internal/tmp_test_nosem.gogit add internal/tmp_test_nosem.gobash scripts/check-exceptions.sh; echo "exit=$?"git rm -q --cached internal/tmp_test_nosem.go && rm internal/tmp_test_nosem.go::error file=internal/tmp_test_nosem.go,line=3::nosemgrep must name the rule id (nosemgrep: <rule-id>)1 exception problem(s). See docs/exception-process.mdexit=1Beyond that I ran 24 other test cases: no exception line, a blank line in between, a mismatched Trivy date, exp equal to today, exactly 90 days (passes), 91 days (rejected), a reason that's too short, gitleaks:allow, .semgrepignore, and so on. All passed on macOS (bash 3.2) and Ubuntu (bash 5.2, same as the GitHub runner). The script also passes shellcheck.
Wiring it into the workflows
The exceptions job on PRs
In pr-checks.yml I add one job between semgrep and govulncheck:
exceptions: runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Check scanner exceptions run: bash scripts/check-exceptions.shIt needs no extra permissions, the workflow-level contents: read is enough.
Trivy reads .trivyignore explicitly
Trivy picks up .trivyignore by itself. I still add trivyignores: .trivyignore to all five trivy-action steps: four in pr-checks.yml (config SARIF, config gate, image SARIF, image gate) and the gate in deploy.yml.
- name: Trivy image (gate) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: version: v0.76.1 trivyignores: .trivyignore scan-type: image # ...the rest is the same as in Part 5Three reasons:
With
trivyignoresset, the action prints the ignore file into the log (Found ignorefile '.trivyignore':). Every run shows which exceptions were active. Good for audits.If the file is missing, the action fails instead of quietly scanning without exceptions.
The gate in
deploy.ymlreads the same file. An exception approved in a PR also applies when the deploy builds the image. One source of truth.
The weekly exception-expiry run
PR checks only run when there's a PR. If nobody opens one for two months, an expired exception goes unnoticed. So I add a scheduled workflow:
name: exception-expiry # PR checks only run when someone opens a PR. This weekly run catches exceptions that expire in between,# and GitHub emails the failure.on: schedule: - cron: "0 1 * * 1" # Monday 01:00 UTC = 08:00 WIB workflow_dispatch: permissions: contents: read jobs: exception-expiry: runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Check scanner exceptions run: bash scripts/check-exceptions.shPart | Meaning |
|---|---|
| Minute 0, 01:00 UTC, every Monday. That's 08:00 in Jakarta (WIB) |
| Can be run by hand: |
Branch checked | Scheduled workflows always run on the default branch, which is |
Notification | On failure, GitHub emails whoever last changed this cron |
Job name | Deliberately different from |
Note
GitHub schedules can run minutes to an hour late when it's busy, and they get disabled after 60 days without repo activity. For a weekly check that's acceptable, since Trivy's exp: and the exceptions check still run on every PR and deploy.
Make exceptions a required check
In docs/rulesets/protect-development.json and protect-main.json I add exceptions to the required checks, then apply them with gh api -X PUT as in Part 6. Check:
for f in docs/rulesets/protect-development.json docs/rulesets/protect-main.json; do jq -c '[.rules[] | select(.type=="required_status_checks").parameters.required_status_checks[].context]' "$f"done["test","gitleaks","semgrep","govulncheck","dependency-review","trivy-config","trivy-image","exceptions"]["test","gitleaks","semgrep","govulncheck","dependency-review","trivy-config","trivy-image","exceptions","source-branch"]Warning
Apply the ruleset after the merge, not before
If exceptions becomes required before the job exists on development, any PR branched from the old development never reports that check. It waits forever with "Expected — Waiting for status to be reported". So the order is: merge the PR that adds the job, then PUT the rulesets.
I opened the PR with all of this (9 files) against development. It showed the 7 existing gates plus exceptions, all green, and I merged it. Only then did I apply the rulesets.
Demo A: an accepted risk that then expires
Now the proof. Scenario: the container has to run as root, say a legacy app that needs port 80. Two scanners catch this: Trivy config (DS-0002) and Semgrep (dockerfile.security.last-user-is-root.last-user-is-root). One finding means an exception in both scanners. It's tedious on purpose, so exceptions stay rare.
This PR is a demo and gets closed without merging.
git switch development && git pull --ff-onlygit switch -c demo/exception-accepted-riskEXP=$(date -v+30d +%F); echo "$EXP" # macOS. On Linux: date -d '+30 days' +%FIn the Dockerfile I replace the USER 65532:65532 line with these three lines (put the date from above in place of <EXP>):
# exception: owner=@dimss113 exp=<EXP> reason="demo: root needed to bind port 80, tracked in issue"# nosemgrep: dockerfile.security.last-user-is-root.last-user-is-rootUSER rootOrder matters: the exception: line goes directly above nosemgrep, and nosemgrep directly above USER root.
Warning
`suppression needs 'exception: ...'` in the Dockerfile
This one got me. Almost certainly <EXP> is still in there. The script only accepts a YYYY-MM-DD date, so exp=<EXP> isn't seen as an exception line. Run sed -i '' "s/exp=<EXP>/exp=$EXP/" Dockerfile. If it's a fresh terminal and $EXP is empty, set the variable again first.
Then the Trivy entry, with the same date:
printf '# exception: owner=@dimss113 exp=%s reason="demo: root needed to bind port 80, tracked in issue"\nDS-0002 exp:%s\n' "$EXP" "$EXP" >> .trivyignoregit add -A && bash scripts/check-exceptions.sh # expect: exceptions OKgit commit -m "demo: accept root user with an expiring exception"git push -u origin demo/exception-accepted-riskgh pr create --base development --title "demo: accepted risk with expiry" \ --body "Demo only, not to be merged."gh pr checks --watchCheck: all eight required checks are green, including trivy-config, semgrep and exceptions. The PR can be merged.
One thing confused me at first: Code scanning results / Semgrep OSS stays red. That isn't our semgrep job. It's a separate check GitHub creates from the uploaded SARIF. Semgrep still writes the nosemgrep-ed finding into the SARIF, marked suppressions: inSource, but GitHub code scanning ignores that mark and opens a new alert. That check isn't in the ruleset, so it doesn't block:
gh pr view --json mergeStateStatus,mergeable{"mergeStateStatus":"UNSTABLE","mergeable":"MERGEABLE"}UNSTABLE means an optional check is red but the merge is still allowed. That's actually good for audits: the exception stays visible in the Security tab. For a real exception, close the alert with Dismiss alert → Won't fix, using the same reason and date, so the decision is also recorded in GitHub along with who made it.
Expired means red again
Now I move the date back to yesterday, in both places at once:
OLD=$(date -v-1d +%F)sed -i '' "s/$EXP/$OLD/g" Dockerfile .trivyignoregit commit -am "demo: let the exception expire"git pushgh pr checks --watchCheck:
Check | Status | Why |
|---|---|---|
| red |
|
| red | Trivy itself stops ignoring |
| green | Semgrep has no idea about dates. Without the |
That green semgrep row is the clearest proof of why the exceptions check is needed. Then I close the PR:
gh pr close --delete-branch --comment "Demo only: exception accepted, then expired and blocked."Demo B: a Gitleaks false positive by fingerprint
Second scenario: a fixed test value in the code that looks like an API key. Gitleaks and Semgrep (p/secrets) flag it. It's a false positive, so an exception is allowed.
git switch development && git pull --ff-onlygit switch -c demo/exception-false-positivemkdir -p internal/demoV=$(openssl rand -hex 20)printf 'package demo\n\n// Demo only: a fixed test value that looks like an API key.\nconst apiKey = "%s"\n\nfunc Key() string { return apiKey }\n' "$V" > internal/demo/vector.gounset Vgit add -A && git commit -m "demo: add a fixed test value that looks like an api key"openssl rand -hex 20 gives 40 random hex characters, clearly nobody's real credential.
Get the fingerprint
gitleaks git . --no-banner --redact -v --log-opts="origin/development..HEAD"--log-opts="origin/development..HEAD" limits the scan to this branch's commits, the same way CI only scans the PR's commits. -v prints the details, including the fingerprint:
RuleID: generic-api-key...Fingerprint: <commit-sha>:internal/demo/vector.go:generic-api-key:4The format is <commit>:<file>:<rule>:<line>. So this exception covers one finding in one commit. Paste the same value into another commit and Gitleaks still catches it. That's exactly what I want.
Store it in a variable instead of copying by hand:
FP=$(gitleaks git . --no-banner --redact --log-opts="origin/development..HEAD" \ --report-format json --report-path /tmp/gl.json >/dev/null 2>&1; jq -r '.[0].Fingerprint' /tmp/gl.json)rm /tmp/gl.jsonAdd the Gitleaks and Semgrep exceptions
EXP=$(date -v+30d +%F)printf '# exception: owner=@dimss113 exp=%s reason="demo: fixed test value, not a real credential"\n%s\n' "$EXP" "$FP" >> .gitleaksignoreFor Semgrep, add two lines above const apiKey:
// exception: owner=@dimss113 exp=<EXP> reason="demo: fixed test value, not a real credential"// nosemgrep: generic.secrets.security.detected-generic-api-key.detected-generic-api-keyconst apiKey = "..."Warning
Don't put `nosemgrep` at the end of the `const` line
If you edit the const line, the new commit re-adds the line holding the value. Gitleaks sees it as a new finding with a new commit SHA, and the fingerprint no longer matches. Put it on its own line above. Semgrep still honours a nosemgrep on the line above; I tested it. The same goes for amend or rebase: the SHA changes, so grab the fingerprint again.
gofmt -l internal/demo # must print nothinggit add -A && bash scripts/check-exceptions.shgit commit -m "demo: except the false positive by fingerprint"git push -u origin demo/exception-false-positivegh pr create --base development --title "demo: false positive exception" --body "Demo only, not to be merged."gh pr checks --watchCheck: every required check is green, including gitleaks, semgrep and exceptions. Code scanning / Semgrep OSS is red for the same reason as in Demo A. Code scanning / gitleaks stays green, because findings that match a fingerprint aren't written to the SARIF.
Tip
Want a "before and after" screenshot? Push only the first commit, watch gitleaks and semgrep go red, then add the exceptions.
Close the demo with gh pr close --delete-branch.
When things go wrong
Symptom | Cause | Fix |
|---|---|---|
Trivy fails: |
|
|
| The exception line isn't directly above, the reason is under 10 characters, or | Put it directly above, in the exact format |
| The Trivy entry's date differs from the exception line | Make the dates match |
|
| 90 days max, renew later in a new PR |
| The commit was amended/rebased, or the line with the value changed | Get the fingerprint again from the latest commit |
| Typo in the rule id | Copy the exact rule id from the Semgrep log |
Limits I accept
The process isn't perfect, and it's better to say so plainly:
Anyone can be written as the owner. The script checks the format, not whether that person agreed. In a team, add
CODEOWNERSfor.trivyignore,.gitleaksignore, the script and the workflows, plus "require code owner review" in the ruleset.A PR author can change the script or workflow in the same PR, because
pull_requestuses the workflow from the PR branch. The mitigation is careful review of.github/andscripts/, or required workflows at the organization level.A Gitleaks fingerprint keeps hiding one old finding after it expires, since Gitleaks has no dates. The
exceptionscheck stays red until the entry is removed, and a fingerprint only ever matches one commit.No central dashboard yet. Exceptions live in three places, but
git grep -n "exception:"lists them all.
Recap
docs/exception-process.md: the written policy. Fix first, one finding per entry, owner + reason + an expiry of at most 90 days, real secrets never excepted..trivyignoreand.gitleaksignoreas the only official exception files.scripts/check-exceptions.sh, which rejects exceptions with missing metadata, expired or too-long ones, a barenosemgrep,gitleaks:allow, and broad suppression files.The
exceptionsjob as a required check ondevelopmentandmain, plus the weeklyexception-expiryworkflow.Proof: a valid exception turns the PR green, and once it expires the gate goes red again. Including proof that Semgrep on its own would never go red again without this check.
In Part 10, the last one, we deal with the only credential still stored in GitHub: DOKPLOY_API_KEY. I delete that secret, and CI gets its Dokploy token straight from Vault through GitHub OIDC.