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

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: sub and job_workflow_ref don't match

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:

text
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 own

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

bash
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

iss

https://token.actions.githubusercontent.com

Making sure GitHub issued it

aud

https://vault.example.com

The token was minted for this Vault. A token meant for another service (say AWS) can't be replayed here

sub

repo:dimss113@<OWNER_ID>/secure-cicd-go@<REPO_ID>:environment:development

Who is logging in: the repo and the environment

repository_id

<REPO_ID>

The repo's numeric ID. Survives a rename, changes if the repo is deleted and recreated

job_workflow_ref

dimss113/secure-cicd-go/.github/workflows/deploy.yml@refs/heads/development

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:

vault/policies/secure-cicd-go-ci-development.hcl hcl
# 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:

vault/roles/secure-cicd-go-ci-production.json json
{  "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

role_type: jwt

Log in with a JWT sent directly, not the browser OIDC flow

user_claim: sub

The claim that becomes the login "name" in Vault. Shows up in logs and on the Vault entity

bound_audiences

The token's aud must match. The workflow sends it via jwtGithubAudience

bound_subject

sub must match exactly: this repo, this environment

bound_claims_type: string

Exact matching, not * patterns

bound_claims.repository_id

The repo ID (gh api repos/<owner>/<repo> --jq .id). A public repo's ID isn't a secret

bound_claims.job_workflow_ref

Only deploy.yml, and only from the right branch (main for prod, development for dev)

token_policies

The resulting token only carries this policy (plus default)

token_ttl / token_max_ttl

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

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

bash
for f in vault/roles/*.json; do jq -e . "$f" >/dev/null && echo "$f ok"; done

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

bash
export VAULT_ADDR=https://vault.example.comvault loginvault auth list | grep jwt-github || echo "not there yet"

If it's not there:

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

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

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

bash
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_KEY

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

bash
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"done

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

bash
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 T

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

.github/workflows/deploy.yml yaml
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.sh

The promote job is identical, with role: secure-cicd-go-ci-production and the ci/production/dokploy path.

Piece

Why

permissions: id-token: write

Permission to request an OIDC token. Only on the two deploy jobs. build and resolve don't get it. Remember the workflow starts from permissions: {}

vault-action@<SHA> # v4.0.0

Pinned to a commit SHA, like every other action in the repo. A tag can move, a SHA can't

path: jwt-github

The default is jwt, so it has to match our auth method's path

jwtGithubAudience

Sets aud. Must match bound_audiences. Leave it empty and GitHub uses https://github.com/<owner>, and the login is refused

exportEnv: false

The key is not turned into an env var for every later step. Only the deploy step gets it, through env:

secrets: <path> api_key | DOKPLOY_API_KEY

KV v2 path with data/, then the field, then the output name

steps.vault.outputs.DOKPLOY_API_KEY

The key comes from the Vault step's output, no longer from secrets.

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:

bash
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 -20

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

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

bash
gh secret delete DOKPLOY_API_KEY --env developmentgh secret list --env development

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

bash
for ENV in development production; do echo "== $ENV"; gh secret list --env "$ENV"; donegh secret list

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

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

.github/workflows/oidc-demo.yml yaml
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:

text
"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 (rotate_before)

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, exportEnv: false

Anyone with write access can change deploy.yml through a PR

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

job_workflow_ref pins the file name deploy.yml

Renaming the file means deploys get refused

Update the role in vault/roles/ in the same PR as the rename

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 config.json to tmpfs, Compose files for dev and prod

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, /version smoke test

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 secret_id in the Dokploy env

Simplicity

Policy only reads its own env folder, short token TTL, revocable any time (secret-id/destroy), optional secret_id_bound_cidrs to the VPS IP

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 rotate_before

The very first dev image (bootstrap) was built on a laptop

CI deploys didn't exist before Part 8

Built from a clean development, passed Trivy, only tagged development, overwritten by CI right away. Production never used it

Deploys use tags, not digests

Digests + signatures are their own project

The /version smoke test confirms the running commit is the scanned commit

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 config.json can be 0400

The vault-agent auth volume is never mounted into the app

Secret rotation doesn't reach the app automatically

The app reads config.json once at start (simple, fail closed)

The agent re-renders; the app uses new values after a restart. Documented in the runbook

Revoking secret_id doesn't stop a running app

The secret is already in the app's memory

To cut access immediately: revoke the secret_id and rotate the secret, then redeploy

vault-agent healthcheck means "config was rendered once", not "Vault is reachable"

An old render stays on tmpfs

Watch the agent logs (permission denied, auth)

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 only read one path, stored in vault/policies/.

  • JWT roles secure-cicd-go-ci-<env> in vault/roles/, bound to aud, bound_subject (repo + environment), repository_id, and job_workflow_ref (deploy.yml on the right branch), issuing 5-minute Vault tokens.

  • A jwt-github auth method in Vault that verifies GitHub's signature via oidc_discovery_url.

  • New Dokploy keys, stored only in Vault, with rotate_before metadata.

  • deploy.yml with id-token: write on the two deploy jobs only, vault-action pinned by SHA, exportEnv: false.

  • DOKPLOY_API_KEY deleted 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.

Related articles