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

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:

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

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

bash
export VAULT_ADDR=https://vault.example.comvault loginvault secrets list | grep static-secretvault auth list | grep approle
  • VAULT_ADDR tells the CLI which server to talk to.

  • vault login asks for the admin token (or whatever login method you normally use).

  • vault secrets list shows every secrets mount. Think of a mount as a cabinet. My Vault already has a static-secret/ cabinet of type KV version 2.

  • vault auth list confirms the approle/ 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):

bash
vault secrets enable -path=static-secret kv-v2vault auth enable approle

Check: 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.

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

A few things here are deliberate:

  • openssl rand -hex 32 produces 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 text openssl rand -hex 32, not the output.

  • Dev and production get different values, because openssl runs again on every loop. A JWT issued in dev will never be valid in production.

  • The field is called secret_key in both. The Vault Agent template in Part 4 reads that field.

Check: look at the metadata only, not the values.

bash
vault kv metadata get -mount=static-secret secure-cicd-go/development/secrets/jwtvault kv list -mount=static-secret secure-cicd-go/production/secrets   # → aes, jwt

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

vault/policies/secure-cicd-go-development.hcl hcl
path "static-secret/data/secure-cicd-go/development/*" {  capabilities = ["read"]}
vault/policies/secure-cicd-go-production.hcl hcl
path "static-secret/data/secure-cicd-go/production/*" {  capabilities = ["read"]}

Write them to Vault:

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

Least privilege, line by line

The policy is three lines, and each part is there for a reason:

Part

Why

secure-cicd-go/development/*

Only its own environment's folder. No access to production, let alone other apps

capabilities = ["read"]

Read only. No create, update, delete, or list. The app has no reason to change its own secrets

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

static-secret/data/<path>

The secret's content (the secret_key value)

static-secret/metadata/<path>

Metadata: versions, timestamps, and also what list uses

static-secret/delete/<path>, undelete/, destroy/

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:

bash
vault policy read secure-cicd-go-developmentvault policy read secure-cicd-go-production

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

bash
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=0done

Option

Meaning

token_policies

The token from a login carries only its own environment's policy

token_ttl=1h, token_max_ttl=4h

Short-lived Vault tokens. Vault Agent renews or logs in again on its own, so the app isn't affected

secret_id_ttl=0

The secret_id never expires

secret_id_num_uses=0

The secret_id can be used to log in any number of times

Those last two lines are on purpose. They're an accepted risk, and I cover it below so it doesn't look hidden.

Check:

bash
vault read auth/approle/role/secure-cicd-go-development

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

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

Repeat 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_id is the username. Vault creates it once, when the role is created, and it doesn't change. So we just read what's already there, with vault read.

  • secret_id is 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 different secret_id, and the old one stays valid until it's revoked. Creating something means writing, so it's vault write.

Part

Meaning

vault read

Fetch data that already exists, like GET

vault write

Send a request that creates or changes something, like POST

-f

force: send the write with no data at all. Without -f, Vault refuses an empty write

-field=role_id / -field=secret_id

Print only that value. Without -field you also get a table with secret_id_accessor, secret_id_ttl, and so on

auth/approle/role/<name>/role-id

Where that role's "username" lives

auth/approle/role/<name>/secret-id

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

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

Line by line:

  • vault write auth/approle/login is the AppRole login. It returns a dev token, stored in DEV_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

read

Wait for keyboard input, store it in a variable

ROLE_ID / SECRET_ID

The target variable, used later as "$ROLE_ID"

-r

raw: backslashes aren't interpreted, the value is stored exactly as pasted

-s

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:

  1. Typed commands are saved in shell history (~/.zsh_history). Anyone who opens the history sees the secret.

  2. Command arguments can show up in the process list (ps) while the command runs.

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

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

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

Keeps the setup simple

The policy is read on its own environment's folder only. Short Vault token TTL (1 hour, max 4). Revocable at any time with secret-id-accessor/destroy. Optional: secret_id_bound_cidrs to the VPS IP

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_id does 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 the secret_id and 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 the approle/ auth method.

  • Four random secrets: jwt and aes for development and production, with different values, in a secret_key field.

  • 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 denied on 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.

Related articles