//PART 2 OF 10
Part 2: Vault Secrets, Policies, and an AppRole per Environment
· 13 min read
Indonesia//TUTORIAL · 2 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
In Part 1 we got the big picture and the tools on the laptop. Now we start with the foundation that matters most: where secrets live, and who is allowed to read them.
Everything else in the series leans on this. The Go app in Part 3 reads its secrets from a file. Vault Agent in Part 4 writes that file. And Vault Agent can only read what the policies we write today allow.
By the end of this part you'll have four secrets (two per environment), two policies, two AppRoles, and most importantly proof that the dev credential is refused when it tries to read production secrets or another app's secrets.
Warning
Don't store secret values anywhere
While you follow this part, don't write secret values, tokens, or a secret_id into notes, files, chat, or the repo. They pass through the terminal and go straight to where they're needed.
What Vault will look like
Before typing anything, here's the end state:
static-secret/ ← KV v2 mount└── secure-cicd-go/ ├── development/secrets/ │ ├── jwt (secret_key) │ └── aes (secret_key) └── production/secrets/ ├── jwt (secret_key) └── aes (secret_key)policy secure-cicd-go-development → read secure-cicd-go/development/*policy secure-cicd-go-production → read secure-cicd-go/production/*approle secure-cicd-go-development → token carries the development policyapprole secure-cicd-go-production → token carries the production policyThe pattern is simple: one environment, one folder, one policy, one AppRole. Nothing is shared. If the dev credential leaks, the only thing it can read is dev secrets. Production stays safe.
Log in and check the mounts
I'm using a Vault server I already run, where I'm an admin. This step does need an admin token, because we're creating policies and roles.
export VAULT_ADDR=https://vault.example.comvault loginvault secrets list | grep static-secretvault auth list | grep approleVAULT_ADDRtells the CLI which server to talk to.vault loginasks for the admin token (or whatever login method you normally use).vault secrets listshows every secrets mount. Think of a mount as a cabinet. My Vault already has astatic-secret/cabinet of type KV version 2.vault auth listconfirms theapprole/login method is enabled.
If your Vault doesn't have them yet, enable them first. These are the same commands the repo's local Vault script uses (scripts/local-vault.sh, which we'll use in Part 4):
vault secrets enable -path=static-secret kv-v2vault auth enable approleCheck: both greps above print one line: static-secret/ of type kv, and approle/ of type approle.
Creating four secrets
The app will need two secrets: a key to sign JWTs and an AES-256 key. Each environment gets its own values.
for ENV in development production; do vault kv put -mount=static-secret secure-cicd-go/$ENV/secrets/jwt secret_key="$(openssl rand -hex 32)" vault kv put -mount=static-secret secure-cicd-go/$ENV/secrets/aes secret_key="$(openssl rand -hex 32)"doneA few things here are deliberate:
openssl rand -hex 32produces 32 random bytes as 64 hex characters. For AES that's exactly an AES-256 key: the app decodes the hex back into 32 bytes.The value is generated inside
$(...), so I never type it or see it. Shell history only records the textopenssl rand -hex 32, not the output.Dev and production get different values, because
opensslruns again on every loop. A JWT issued in dev will never be valid in production.The field is called
secret_keyin both. The Vault Agent template in Part 4 reads that field.
Check: look at the metadata only, not the values.
vault kv metadata get -mount=static-secret secure-cicd-go/development/secrets/jwtvault kv list -mount=static-secret secure-cicd-go/production/secrets # → aes, jwtmetadata get shows versions and creation times without the content. It's a small habit that pays off: if you're screen-sharing, no value leaks onto the screen.
A policy per environment
A Vault policy is a list of "doors" a token may open. If a path isn't in the policy, access is refused. The default is deny.
The two policy files live in the repo, under vault/policies/. The reason: a change in access rights matters as much as a change in code, so it should go through a PR and get reviewed. In a real company that would usually be a separate platform repo. For this project I keep it in the same repo so it's easy to see.
path "static-secret/data/secure-cicd-go/development/*" { capabilities = ["read"]}path "static-secret/data/secure-cicd-go/production/*" { capabilities = ["read"]}Write them to Vault:
vault policy write secure-cicd-go-development vault/policies/secure-cicd-go-development.hclvault policy write secure-cicd-go-production vault/policies/secure-cicd-go-production.hclLeast privilege, line by line
The policy is three lines, and each part is there for a reason:
Part | Why |
|---|---|
| Only its own environment's folder. No access to production, let alone other apps |
| Read only. No |
One policy per environment | If production needs an extra secret one day, the dev policy doesn't change |
That's least privilege: grant the smallest set of rights that still gets the job done. Vault Agent only needs to read two secrets, so that's all it gets.
Why there's a data/ in the path
This one tripped me up the first time I used KV v2. Look closely: in the vault kv put commands, the path was secure-cicd-go/development/secrets/jwt. In the policy it's static-secret/data/secure-cicd-go/development/*. Where did data/ come from?
KV version 2 keeps versioned secrets. Because of that, one secret actually has several API paths, depending on what you want to do:
API path | What's there |
|---|---|
| The secret's content (the |
| Metadata: versions, timestamps, and also what |
| Version management |
The vault kv ... commands with -mount= insert data/ or metadata/ for you, so you never see it. Policies, though, work on the real API path, so data/ must be written out. Forget it and the policy looks right while every read comes back 403.
One side effect worth knowing: because the policy only grants data/, the dev token can't even vault kv list its own dev folder (that needs metadata/). That's fine. Vault Agent doesn't need to browse a folder. It knows exactly which two paths it reads.
Check:
vault policy read secure-cicd-go-developmentvault policy read secure-cicd-go-productionBoth should print exactly what's in the repo files.
An AppRole per environment
Now Vault Agent needs a way to log in. For machines (not people), Vault has the AppRole method. Think of a normal login: there's a role_id (like a username) and a secret_id (like a password). Logging in gives you a Vault token that carries specific policies.
for ENV in development production; do vault write auth/approle/role/secure-cicd-go-$ENV \ token_policies="secure-cicd-go-$ENV" \ token_ttl=1h token_max_ttl=4h \ secret_id_ttl=0 secret_id_num_uses=0doneOption | Meaning |
|---|---|
| The token from a login carries only its own environment's policy |
| Short-lived Vault tokens. Vault Agent renews or logs in again on its own, so the app isn't affected |
| The |
| The |
Those last two lines are on purpose. They're an accepted risk, and I cover it below so it doesn't look hidden.
Check:
vault read auth/approle/role/secure-cicd-go-developmentMake sure token_policies is just [secure-cicd-go-development] and token_ttl is 1h.
Getting the role_id and secret_id
These two values get pasted into the Dokploy environment in Part 7. For now we need them for the isolation test.
vault read -field=role_id auth/approle/role/secure-cicd-go-development/role-idvault write -f -field=secret_id auth/approle/role/secure-cicd-go-development/secret-idRepeat with production if you need it. And again: don't save them to a file or a note.
Why read for role_id, but write for secret_id?
Back to the username and password analogy:
role_idis the username. Vault creates it once, when the role is created, and it doesn't change. So we just read what's already there, withvault read.secret_idis the password. It doesn't exist until we ask for one. Every time we ask, Vault creates a new one. A second call gives a differentsecret_id, and the old one stays valid until it's revoked. Creating something means writing, so it'svault write.
Part | Meaning |
|---|---|
| Fetch data that already exists, like |
| Send a request that creates or changes something, like |
| force: send the |
| Print only that value. Without |
| Where that role's "username" lives |
| That role's "password factory" |
Tip
Clean up unused secret_ids
Run vault write ... secret-id three times and you have three active secret_ids. The ones you don't use should be revoked. List them with vault list auth/approle/role/<name>/secret-id. What you get back are accessors, a kind of serial number, not the secret_ids themselves, so they're safe to show. Revoke with vault write auth/approle/role/<name>/secret-id-accessor/destroy secret_id_accessor=<accessor>. In Part 7, before pasting credentials into Dokploy, I revoke everything left over from this part's tests and create a single fresh one.
The isolation test: proof of least privilege
This step is not optional. A policy that looks right on paper isn't necessarily right in Vault. So we log in as dev and try three things: read a dev secret (should work), read a production secret (should be refused), and read another app's path on the same Vault (should be refused).
read -r ROLE_ID # paste the dev role_id, Enterread -rs SECRET_ID # paste the dev secret_id (hidden), EnterDEV_TOKEN=$(vault write -field=token auth/approle/login role_id="$ROLE_ID" secret_id="$SECRET_ID") VAULT_TOKEN=$DEV_TOKEN vault kv get -mount=static-secret -field=secret_key secure-cicd-go/development/secrets/jwt >/dev/null && echo "dev: ALLOWED (correct)"VAULT_TOKEN=$DEV_TOKEN vault kv get -mount=static-secret secure-cicd-go/production/secrets/jwt # must be: permission deniedVAULT_TOKEN=$DEV_TOKEN vault kv list -mount=static-secret another-app/ # must be: permission denied vault token revoke "$DEV_TOKEN"; unset ROLE_ID SECRET_ID DEV_TOKENLine by line:
vault write auth/approle/loginis the AppRole login. It returns a dev token, stored inDEV_TOKEN.VAULT_TOKEN=$DEV_TOKEN vault ...runs a single command with the dev token, without replacing your admin login.The dev read goes to
/dev/null, so the value never shows. We only care whether it succeeded.Replace
another-app/with a path that really exists on your Vault for some other app. The test means more when that path actually holds something.At the end, the dev token is revoked and the variables are cleared.
What read -r and read -rs are for
read is a shell builtin that asks for keyboard input and stores it in a variable. After you press Enter, the terminal waits for you to paste the value, then Enter again.
Part | Meaning |
|---|---|
| Wait for keyboard input, store it in a variable |
| The target variable, used later as |
| raw: backslashes aren't interpreted, the value is stored exactly as pasted |
| silent: what you paste doesn't show on screen, like typing a password |
Why not put the value straight in the command, like vault write auth/approle/login secret_id=abc123? Three reasons:
Typed commands are saved in shell history (
~/.zsh_history). Anyone who opens the history sees the secret.Command arguments can show up in the process list (
ps) while the command runs.They can be captured by screen-sharing, a screenshot, or a screen recording.
With read, history only gets the text read -rs SECRET_ID, not the value. The role_id only needs -r, without -s, because it's like a username rather than the real secret, so it's fine to see it and confirm the paste was right. The secret_id gets -s because it's the password.
The unset at the end removes the variables from the shell's memory, so they don't linger in a terminal that might stay open for hours.
Check: the dev line prints dev: ALLOWED (correct). The other two fail with a 403. Roughly like this (trimmed):
dev: ALLOWED (correct)Error reading static-secret/data/secure-cicd-go/production/secrets/jwt: Error making API request.Code: 403. Errors:* 1 error occurred: * permission deniedError listing static-secret/metadata/another-app: Error making API request.Code: 403. Errors:* 1 error occurred: * permission deniedLook at the paths in the errors: the CLI inserted data/ and metadata/ by itself. It's an easy way to see the real API path the policy is checked against.
I took a screenshot of this for the README, because it's the proof for one of the anti-goals from Part 1: the demo AppRole can't read another app's secrets.
Note
The same test is repeated from the container side in Part 4: the dev credential is used with the production compose file, Vault Agent gets permission denied, and the app never starts.
Accepted risk: a static secret_id
I created a secret_id that never expires and can be reused, and later I'll store it in the Dokploy environment. That's clearly not the strongest pattern. Stricter options exist, like response wrapping or a single-use secret_id delivered by an orchestrator. But they need extra moving parts that a simple Dokploy setup doesn't have.
So I accept the risk and write it down openly in the README:
Item | Why it's accepted | Mitigation |
|---|---|---|
Static AppRole | Keeps the setup simple | The policy is |
Two things to know about this risk:
Anyone with Dokploy admin or root on the VPS can read the
secret_id. That's what "static" means here.Revoking the
secret_iddoes not stop an app that's already running, because the secrets are already in its memory. What gets blocked is the next start or restart. To cut access right away, revoke thesecret_idand rotate the secrets, then redeploy.
What matters to me: the risk is known, bounded by a narrow policy, and written down. Not swept under the rug.
Note
For CI I didn't want to repeat this pattern. In Part 10 the GitHub Actions deploy job logs in to Vault with OIDC, so GitHub stores no Vault credential at all. Those policies live in vault/policies/ too (secure-cicd-go-ci-*.hcl), and each one can read exactly one path.
Recap
What now exists in Vault:
A KV v2 mount
static-secret/and theapprole/auth method.Four random secrets:
jwtandaesfor development and production, with different values, in asecret_keyfield.Two read-only policies, one per environment, also kept in
vault/policies/so they get reviewed through PRs.Two AppRoles with short-lived tokens, each carrying only its own environment's policy.
Isolation test proof: the dev credential can read dev, but gets
403 permission deniedon production and on another app's path.One written accepted risk, the static
secret_id, with its mitigations.
In Part 3 we build the "patient" for this pipeline: a small Go app that uses those two secrets for JWT and AES-256-GCM, reads them from a file, and stops with a clear error the moment something is wrong.