//PART 10 OF 10
Part 10: CI Gets Its Dokploy Token from Vault through GitHub OIDC, and Wrapping Up the Series
· 23 min read
Indonesia//TUTORIAL · 10 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 the end of Part 9 the pipeline was complete. PRs get checked, a merge into development deploys to dev, a merge into main is promoted to production after an approval, and exceptions carry an expiry date. But one thing has bothered me since Part 8: the Dokploy API key lives in GitHub as an Environment Secret called DOKPLOY_API_KEY.
That's what I called Stage A back in Part 1. It came first on purpose, to get deploys working. In this final part I replace it with Stage B: the deploy job asks GitHub for a temporary identity (an OIDC token), trades it with Vault, and reads the Dokploy key for its own environment. Then DOKPLOY_API_KEY is deleted from GitHub. CI no longer stores any Dokploy credential.
At the end I close out the series: what got built from Part 1 to Part 10, which risks I accepted on purpose, and what could come next.
Why a static key in GitHub is a problem
GitHub environment secrets are actually reasonably safe. Only a job that enters the production environment can read them, and that environment is only available to main after an approval. So what's the problem?
It's the nature of the key: it's long-lived. The Dokploy key is valid for 90 days. If it leaks once, from a log, a cache, an artifact, or a stolen GitHub account, anyone can use it until it expires. And there's no record of who fetched it when.
Scenario | Stage A (GitHub secret) | Stage B (Vault via OIDC) |
|---|---|---|
Key leaks from a log, cache, or artifact | Usable for up to 90 days | Key is still masked. The Vault token dies in 5 minutes |
A new workflow on a feature branch tries to read the key | Safe if environment protection is right | Refused by Vault: |
A dev job tries the prod key | Different secret, but both are stored permanently | Refused: the dev role only carries the dev policy |
A GitHub admin or GitHub token is compromised | Can read and replace the secret | There's no secret to read. Access is controlled in Vault |
Audit of "who fetched the key when" | None | Recorded in Vault (login entity, audit log) |
Note
What doesn't change
The Dokploy key itself is still a regular key, because Dokploy doesn't offer per-service or short-lived tokens yet. What this part shrinks is where the key is stored and who can fetch it, not the strength of the key.
How it works: trading a GitHub OIDC token with Vault
Any GitHub Actions job with the id-token: write permission can ask GitHub for a JWT. That JWT is:
signed by GitHub with its private key. Vault checks the signature with GitHub's public key, so it can't be forged.
short-lived, about 5 minutes.
full of claims: facts about the job that asked for it (repo, branch, environment, workflow file). GitHub writes these claims itself, not the workflow. So a workflow can't claim to be something it isn't.
The analogy I use: it's a temporary ID card that GitHub prints for one job. Vault is the security guard who checks that ID and hands out a 5-minute visitor badge for one door.
The key point is that there's no shared secret between GitHub and Vault. Trust comes purely from the digital signature. The flow:
GitHub runner GitHub OIDC Vault (vault.example.com)───────────── ─────────── ─────────────────────────[0] job has id-token: write[1] ask for a JWT, audience=https://vault.example.com ──▶ build and sign the JWT ◀── JWT (lives ~5 minutes)[2] POST /v1/auth/jwt-github/login {role, jwt} ──────────────────▶ [3] verify signature (GitHub public key) check iss, aud, sub, repository_id, job_workflow_ref ◀────────────────────────────────────────────── [4] Vault token, TTL 5 minutes, 1 policy[5] GET /v1/static-secret/data/secure-cicd-go/ci/<env>/dokploy ──▶ [6] policy: may it read this path? ◀── api_key (masked as *** in logs)[7] deploy step uses the key → Dokploy[8] job ends, runner VM is thrown away, Vault token expires on its ownThere are only two network requirements, both plain outbound HTTPS on 443: the runner reaches Vault, and Vault reaches token.actions.githubusercontent.com to fetch GitHub's public keys. That means your Vault must be reachable from the internet. If it's only reachable over a VPN, GitHub-hosted runners can't log in.
curl -sS https://vault.example.com/v1/sys/health | jq '{initialized, sealed, version}'This endpoint is public and needs no token. You want "initialized": true, "sealed": false.
The claims we use
Claim | Example value | Used for |
|---|---|---|
|
| Making sure GitHub issued it |
|
| The token was minted for this Vault. A token meant for another service (say AWS) can't be replayed here |
|
| Who is logging in: the repo and the environment |
|
| The repo's numeric ID. Survives a rename, changes if the repo is deleted and recreated |
|
| The workflow file and the branch. Another workflow in the same repo is still refused |
Why is a sub containing environment:production so strong? In Part 8 the production environment was set up so only the main branch can use it, and it needs a human approval. GitHub only writes environment:production into sub when the job actually entered that environment. So a token with that sub can only come from main, after approval. Vault gets that guarantee without knowing anything about approvals.
Step 1: Vault policies and roles live in the repo
Just like the app policies in Part 2, the CI policies and roles live in the repo (vault/policies/, vault/roles/). Changing CI's access has to go through a PR, gets reviewed, and is recorded in Git.
The policy is as narrow as it gets. One exact path, no *, read only:
# CI deploy job (development): read the Dokploy API key, nothing else.path "static-secret/data/secure-cicd-go/ci/development/dokploy" { capabilities = ["read"]}The production file is identical with development swapped for production. Note the ci/ folder: it's deliberately outside secure-cicd-go/<env>/*. So the app policy (the one Vault Agent uses) can't read the CI key, and the CI policy can't read the app's JWT/AES secrets. Two separate worlds.
Now the role. This is the file from the repo, with the owner and repo IDs replaced by placeholders:
{ "role_type": "jwt", "user_claim": "sub", "bound_audiences": ["https://vault.example.com"], "bound_subject": "repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>:environment:production", "bound_claims_type": "string", "bound_claims": { "repository_id": "<REPO_ID>", "job_workflow_ref": "dimss113/secure-cicd-go/.github/workflows/deploy.yml@refs/heads/main" }, "token_policies": ["secure-cicd-go-ci-production"], "token_ttl": "5m", "token_max_ttl": "10m"}Field | What it means |
|---|---|
| Log in with a JWT sent directly, not the browser OIDC flow |
| The claim that becomes the login "name" in Vault. Shows up in logs and on the Vault entity |
| The token's |
|
|
| Exact matching, not |
| The repo ID ( |
| Only |
| The resulting token only carries this policy (plus |
| Expires in 5 minutes, can't be extended past 10. A deploy only needs a few seconds |
I don't type the sub prefix by hand. I read it straight from GitHub's API, because that's where the biggest trap of this phase was (story below):
SUB_PREFIX=$(gh api repos/dimss113/secure-cicd-go/actions/oidc/customization/sub --jq .sub_claim_prefix)echo "$SUB_PREFIX" # repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>From there, the two role files and two policy files come out of a for ENV in development production loop with heredocs. One small detail if you use zsh: write ${SUB_PREFIX}:environment:... with braces. Without them, zsh reads $SUB_PREFIX:e as the :e modifier and you get "nvironment:...". Broken, and invisible until the login fails.
Check:
for f in vault/roles/*.json; do jq -e . "$f" >/dev/null && echo "$f ok"; doneYou should see ok twice.
Step 2: enable JWT auth, policies, and roles in Vault
The jwt auth method at path jwt-github only needs to be enabled once. Check first, because if it already exists (say another project enabled it), vault auth enable fails with path is already in use.
export VAULT_ADDR=https://vault.example.comvault loginvault auth list | grep jwt-github || echo "not there yet"If it's not there:
vault auth enable -path=jwt-github jwtvault write auth/jwt-github/config \ oidc_discovery_url="https://token.actions.githubusercontent.com" \ bound_issuer="https://token.actions.githubusercontent.com"oidc_discovery_url is where Vault fetches GitHub's public keys, and it refreshes them by itself when GitHub rotates keys. bound_issuer makes the iss claim mandatory.
Then upload the policies and roles from the files in the repo:
for ENV in development production; do vault policy write "secure-cicd-go-ci-$ENV" "vault/policies/secure-cicd-go-ci-$ENV.hcl" vault write "auth/jwt-github/role/secure-cicd-go-ci-$ENV" "@vault/roles/secure-cicd-go-ci-$ENV.json"done@file.json means "send this file's contents as the data". So what's in Vault always matches what's in Git.
Check:
vault read -format=json auth/jwt-github/role/secure-cicd-go-ci-production \ | jq '.data | {bound_subject, bound_claims, bound_audiences, token_policies, token_ttl}'bound_subject should end in environment:production, job_workflow_ref should end in refs/heads/main, and you should see "token_ttl": 300 (seconds).
Step 3: rotate the Dokploy key and store it only in Vault
The old key used to live in GitHub, so I treat it as possibly exposed. I generated new keys in Dokploy (Profile → API/CLI → Generate, 90-day expiry, rate limiting on), one for dev and one for prod. Don't delete the old ones yet: deploys still use them until the workflow change is merged.
read -rs DOKPLOY_KEY # paste the NEW dev key (not shown), Enterprintf '%s' "$DOKPLOY_KEY" | vault kv put -mount=static-secret secure-cicd-go/ci/development/dokploy api_key=-unset DOKPLOY_KEYread -rs reads input without echoing it, so it never lands in shell history. api_key=- means the value comes from stdin, so the key never appears in the command's arguments and can't be seen in ps. Repeat for ci/production/dokploy.
I also tag an owner and a rotation date as metadata. Metadata is visible without opening the key's value:
ROTATE=$(date -v+90d +%F)for ENV in development production; do vault kv metadata put -mount=static-secret \ -custom-metadata=owner=@dimss113 -custom-metadata=rotate_before="$ROTATE" \ "secure-cicd-go/ci/$ENV/dokploy"doneStep 4: test isolation before touching the workflow
Before changing anything in GitHub, I prove the policy is right. Create a test token that carries only the dev CI policy:
T=$(vault token create -policy=secure-cicd-go-ci-development -ttl=2m -field=token)VAULT_TOKEN=$T vault kv get -mount=static-secret -field=api_key secure-cicd-go/ci/development/dokploy | wc -cVAULT_TOKEN=$T vault kv get -mount=static-secret secure-cicd-go/ci/production/dokploy # must be 403VAULT_TOKEN=$T vault kv get -mount=static-secret secure-cicd-go/development/secrets/jwt # must be 403vault token revoke "$T"; unset Twc -c counts the key's characters without printing it. Repeat with -policy=secure-cicd-go-development (the app policy): reading ci/development/dokploy must also be 403.
Check: the first line prints a number above 0, the rest say Code: 403 ... permission denied.
Step 5: change deploy.yml
The change is small, and only in the two deploy jobs (deploy-dev and promote). Here's deploy-dev from the repo, with the image retag step trimmed because it's unchanged from Part 8:
env: IMAGE: ghcr.io/dimss113/secure-cicd-go # The deploy jobs log in to Vault with their GitHub OIDC token; no Dokploy key is stored in GitHub. VAULT_ADDR: https://vault.example.com jobs: # ... build (same as Part 8) deploy-dev: needs: build runs-on: ubuntu-24.04 timeout-minutes: 15 environment: name: development url: ${{ vars.APP_URL }} permissions: contents: read packages: write id-token: write steps: # ... checkout + "Point the development tag at this image" (same as Part 8) # The role only accepts this workflow, on development, in the development environment. - name: Read the Dokploy key from Vault id: vault uses: hashicorp/vault-action@892a26828f195e65540a40b4768ae4571f51ebfc # v4.0.0 with: url: ${{ env.VAULT_ADDR }} method: jwt path: jwt-github role: secure-cicd-go-ci-development jwtGithubAudience: ${{ env.VAULT_ADDR }} exportEnv: false secrets: static-secret/data/secure-cicd-go/ci/development/dokploy api_key | DOKPLOY_API_KEY - name: Deploy and smoke test env: DOKPLOY_URL: ${{ vars.DOKPLOY_URL }} DOKPLOY_API_KEY: ${{ steps.vault.outputs.DOKPLOY_API_KEY }} DOKPLOY_COMPOSE_ID: ${{ vars.DOKPLOY_COMPOSE_ID }} APP_URL: ${{ vars.APP_URL }} EXPECTED_COMMIT: ${{ github.sha }} run: scripts/dokploy-deploy.shThe promote job is identical, with role: secure-cicd-go-ci-production and the ci/production/dokploy path.
Piece | Why |
|---|---|
| Permission to request an OIDC token. Only on the two deploy jobs. |
| Pinned to a commit SHA, like every other action in the repo. A tag can move, a SHA can't |
| The default is |
| Sets |
| The key is not turned into an env var for every later step. Only the deploy step gets it, through |
| KV v2 path with |
| The key comes from the Vault step's output, no longer from |
vault-action masks the key and the Vault token in logs automatically. The Vault token isn't exported either, and it dies in 5 minutes.
I put the Vault step after the image retag, right before the deploy, on purpose. The key only exists in the job just before it's used. If the retag fails, Vault is never contacted.
Check: before committing, run actionlint and scripts/check-exceptions.sh from Part 9. Then commit the five files (workflow, two policies, two roles), open a PR into development, wait for green checks, and merge. The PR itself doesn't deploy anything, because deploy.yml only runs on a push to development/main.
Step 6: prove it, then delete DOKPLOY_API_KEY
That merge triggers deploy on development. Watch the run, then look at the Vault step's log:
RUN_ID=$(gh run list --workflow deploy.yml --branch development --limit 1 --json databaseId --jq '.[0].databaseId')gh run watch "$RUN_ID" --exit-statusgh run view "$RUN_ID" --log | grep -A6 "Read the Dokploy key from Vault" | head -20You want a successful jwt login and no key value (at most ***). The /version smoke test shows the merge commit.
Proof from Vault's side: the first login creates an entity named after sub.
ACC=$(vault auth list -format=json | jq -r '."jwt-github/".accessor')vault write -format=json identity/lookup/entity \ alias_name="${SUB_PREFIX}:environment:development" alias_mount_accessor="$ACC" \ | jq '.data | {name, alias_created: .aliases[0].creation_time}'Dev no longer needs the GitHub secret. Delete it:
gh secret delete DOKPLOY_API_KEY --env developmentgh secret list --env developmentThen delete the old dev key in Dokploy, and rerun the same run with gh run rerun "$RUN_ID". If it's still green, you've proven the dev deploy depends only on Vault.
Production follows the normal promotion from Part 8: PR development → main, green checks, merge, approve the production deployment. The Vault step in promote uses the prod role. Once it's green, gh secret delete DOKPLOY_API_KEY --env production, and delete the old prod key in Dokploy.
Check:
for ENV in development production; do echo "== $ENV"; gh secret list --env "$ENV"; donegh secret listThere must be no DOKPLOY_API_KEY anywhere. DOKPLOY_URL and DOKPLOY_COMPOSE_ID stay as variables, and they really aren't secrets.
Something that tripped me up: the immutable sub format
The first dev deploy after the merge failed. The message:
vaultResponse: {"errors":["error validating token: invalid subject (sub) claim"]}And yet I'd tested everything on a local Vault dev server with fake tokens I signed myself: the dev token was accepted, the PR token refused, a wrong audience refused, another workflow refused. All as expected.
The problem turned out to be the sub format. The Vault, vault-action, and GitHub docs all show the old format: repo:dimss113/secure-cicd-go:environment:development. But since 2026 GitHub has an immutable subject format that includes the numeric owner and repo IDs: repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>:.... New repos (created after mid-July 2026) use it automatically, this one included.
My local tests used fake tokens in the old format. So all they proved was that my assumptions matched my role, not that my role matched a real GitHub token.
Why did GitHub change the format? Names can be reused, IDs can't. If an account or repo is deleted and someone else recreates it under the same name, the old format produces an identical sub. With the immutable format the ID is different, so sub differs and the token is refused. (Our role already pins bound_claims.repository_id, so this attack was already closed. The immutable format adds a second layer in sub itself.)
The fix: read the prefix from the API (oidc/customization/sub, as in Step 1), update bound_subject in both role files, vault write them again, then gh run rerun "$RUN_ID" --failed so only deploy-dev reruns. After that the corrected role files go in through a PR, so vault/roles/ keeps matching what's in Vault.
Tip
The lesson
Test identity integrations with tokens from the real issuer, or read the format from the official source (here the oidc/customization/sub API). Not from documentation examples. The demo in the next section prints the real sub. Had I run it before creating the roles, the difference would have been obvious.
Negative demo: a PR job can't get the key
This is the strongest proof in this part. The scenario: someone opens a PR that adds a workflow to steal the Dokploy key. That workflow is allowed to request an OIDC token, and it even uses the right audience. Vault must still refuse.
This PR is a demo only and gets closed without merging.
name: oidc-demo # Demo only, never merged: a pull request job asks Vault for the Dokploy keys and must be refused.on: pull_request permissions: {} jobs: vault-denies-pull-requests: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: id-token: write env: VAULT_ADDR: https://vault.example.com steps: - name: Ask GitHub for an OIDC token and try both deploy roles run: | jwt="$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$VAULT_ADDR" | jq -r .value)" echo "::add-mask::$jwt" # The payload is not a credential without its signature, so printing these claims is safe. payload="$(cut -d. -f2 <<<"$jwt" | tr '_-' '/+')" while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="$payload="; done base64 -d <<<"$payload" | jq '{sub, aud, ref, environment, job_workflow_ref}' for role in secure-cicd-go-ci-production secure-cicd-go-ci-development; do echo "== $role" resp="$(jq -n --arg role "$role" --arg jwt "$jwt" '{role: $role, jwt: $jwt}' | curl -sS -X POST "$VAULT_ADDR/v1/auth/jwt-github/login" --data @-)" if jq -e '.auth.client_token' <<<"$resp" >/dev/null; then echo "::error::Vault accepted a pull request token for $role" exit 1 fi jq -c '.errors' <<<"$resp" done echo "Both roles refused this pull request, as expected."The script does exactly what vault-action does, but by hand with curl, so every step shows up in the log. GitHub injects ACTIONS_ID_TOKEN_REQUEST_URL and _TOKEN because the job has id-token: write. The full token is masked. The middle part of the JWT (the payload) is decoded so we can see the claims Vault sees. If Vault ever hands out a token, the job fails, because that would mean there's a hole.
Check: in the oidc-demo job log:
"sub": "repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>:pull_request","environment": null,"job_workflow_ref": "dimss113/secure-cicd-go/.github/workflows/oidc-demo.yml@refs/pull/<n>/merge"== secure-cicd-go-ci-production["error validating token: invalid subject (sub) claim"]== secure-cicd-go-ci-development["error validating token: invalid subject (sub) claim"]Both roles refused this pull request, as expected.The job is green, because green here means "Vault said no". A PR job's sub ends in :pull_request, not :environment:<env>, so it matches neither role.
What if the attacker adds environment: production to the demo workflow? GitHub refuses the job before it runs, because production is restricted to the main branch. Even if that somehow got through, job_workflow_ref (oidc-demo.yml@refs/pull/...) still doesn't match deploy.yml@refs/heads/main. Two layers.
After the screenshot, close the PR with gh pr close --delete-branch.
The limitations that remain
I'll be honest about these, because they're also what usually gets asked:
Limitation | Why it's accepted | Mitigation |
|---|---|---|
The Dokploy key is still static for 90 days and covers the whole Dokploy account | Dokploy has no per-service or temporary tokens yet | Only in Vault, only fetchable by deploy jobs, rotated every 90 days ( |
While the job runs, the key is in the runner's memory | The key has to be sent to Dokploy | GitHub-hosted runners are discarded after the job. Masked, |
Anyone with write access can change | Whoever can change the pipeline is trusted by the pipeline | PRs must pass the gates and review. Vault roles also go through PRs and are applied by a Vault admin |
Vault becomes a point of failure for deploys | Deliberate, fail closed | Running apps aren't affected. Only new deploys wait |
| Renaming the file means deploys get refused | Update the role in |
One more: there's no auto refresh for the Dokploy key. Only the OIDC token and the Vault token are created fresh on every run. The Dokploy key is a static secret in KV; Vault just stores it, because there's no secrets engine for Dokploy. When it expires, Vault still hands over the key, Dokploy answers 401, and the deploy step goes red.
The difference now is that rotation is a single vault kv put with the new key, plus updating rotate_before. No changes in GitHub, no workflow edits, no PR. KV v2 keeps old versions, so if the new key turns out to be wrong, vault kv rollback -version=<old version> puts it back.
Wrapping up the series: what got built
This is the last part. Looking back from the start, this is what now stands:
Part | What it delivered |
|---|---|
1 | Goals, anti-goals, the list of security gates, tools, DNS |
2 | Secrets in Vault, separate policies and AppRoles per environment, proof the dev role can't read prod |
3 | A small Go app that reads its secrets from a file, not from env vars or the repo |
4 | A Vault Agent sidecar rendering |
5 | A public repo and the PR workflow: tests, Gitleaks, Semgrep, govulncheck, dependency review, Trivy config and image |
6 | Rulesets that block merges with no bypass, plus demo PRs proven to be blocked |
7 | Dokploy dev and prod, auto-deploy off, only CI triggers deploys |
8 | Build once, scan, SBOM, GHCR, deploy to dev, promote the same image to prod after approval, |
9 | An exception process with an owner, a reason, and an expiry date enforced by CI |
10 | CI with no stored Dokploy credential: OIDC → Vault → a 5-minute token for one path |
In one sentence: the deploy jobs store no Dokploy credential at all; they exchange a short-lived GitHub OIDC token for a 5-minute Vault token that can read one path, bound to this repository, environment, and workflow file.
There's one more step I didn't turn into its own post: polishing the README, diagrams, runbook, and proof screenshots so someone else can evaluate the project in five minutes.
Risks accepted on purpose
No system is perfect. What matters is that the risks are written down, with a reason and a mitigation. This is the final version:
Item | Why it's accepted | Mitigation |
|---|---|---|
Static AppRole | Simplicity | Policy only reads its own env folder, short token TTL, revocable any time ( |
Dokploy API key static for 90 days, covers the whole account | Dokploy has no per-service tokens | Since Part 10 it's only in Vault, fetched via OIDC, 5-minute Vault token, manual rotation tracked with |
The very first dev image (bootstrap) was built on a laptop | CI deploys didn't exist before Part 8 | Built from a clean |
Deploys use tags, not digests | Digests + signatures are their own project | The |
The vault-agent image is built on the Dokploy server | The template is baked into the image | Pinned base version, its Dockerfile is scanned by Trivy config |
docker-compose hardening isn't checked automatically | Trivy doesn't support compose | PR review |
App and vault-agent share a UID | So | The vault-agent auth volume is never mounted into the app |
Secret rotation doesn't reach the app automatically | The app reads | The agent re-renders; the app uses new values after a restart. Documented in the runbook |
Revoking | The secret is already in the app's memory | To cut access immediately: revoke the |
vault-agent healthcheck means "config was rendered once", not "Vault is reachable" | An old render stays on tmpfs | Watch the agent logs ( |
What could come next
I left a few things out on purpose to keep the scope in check. If you want to keep going, this is the order that makes sense to me:
Sign images with Cosign and deploy by digest. Deploys still use tags. With a signature and a digest, Dokploy only runs an image that this CI actually built and signed. That's container supply-chain security, and it deserves its own project.
DAST with OWASP ZAP. Every scanner in this series is static. A ZAP baseline scan against dev after each deploy would catch things that only show up when the app is running, like missing headers or exposed endpoints.
One place for findings: DefectDojo. Gitleaks, Semgrep, and Trivy results already land in GitHub's Security tab through SARIF, but govulncheck, dependency review, and the SBOM don't. DefectDojo (OWASP, open source) can collect all of them, deduplicate, and track SLAs. Its API key could come from Vault via OIDC, exactly the pattern from this part. Dependency-Track is also worth a look for continuously watching the SBOM, so a CVE published after a deploy still gets noticed.
Automatic Dokploy key rotation. Dokploy has procedures for creating and deleting API keys, so a scheduled job could rotate them in theory. The cost: that job needs write access to Vault and permission to create Dokploy keys, far more than a deploy job that reads one path. For now, documented manual rotation is enough.
Policy-as-code for Compose (Conftest/OPA, for example), so docker-compose hardening becomes a gate instead of just a review item.
Recap
In this part:
Policies
secure-cicd-go-ci-<env>that can onlyreadone path, stored invault/policies/.JWT roles
secure-cicd-go-ci-<env>invault/roles/, bound toaud,bound_subject(repo + environment),repository_id, andjob_workflow_ref(deploy.ymlon the right branch), issuing 5-minute Vault tokens.A
jwt-githubauth method in Vault that verifies GitHub's signature viaoidc_discovery_url.New Dokploy keys, stored only in Vault, with
rotate_beforemetadata.deploy.ymlwithid-token: writeon the two deploy jobs only, vault-action pinned by SHA,exportEnv: false.DOKPLOY_API_KEYdeleted from both GitHub Environments.Proof that a PR job is refused by both roles with
invalid subject (sub) claim.
And that closes the series. We started in Part 1 with "security depends on people remembering". Now every change has to pass gates that block the merge, the image running in production is the one already tested in dev, app secrets come from Vault, and CI stores no deploy credential at all. The full repo is at github.com/dimss113/secure-cicd-go. Thanks for following along this far. If you build your own version, I'm curious which gate ends up blocking your PRs most often.