Managing CI/CD Secrets Securely with HashiCorp Vault
This gets secrets out of CI/CD environment variables and .env files and into HashiCorp Vault, using AppRole authentication and short-lived dynamic credentials instead of long-lived static secrets.
Enabling KV v2 and writing your first versioned secret
Vault's KV v2 secrets engine is the starting point: a versioned key-value store where overwriting a secret doesn't destroy history and you can roll back.
vault secrets enable -path=secret kv-v2
vault kv put secret/ci/payments-api \
api_key="sk_live_..." \
webhook_secret="whsec_..."
vault kv get secret/ci/payments-api
vault kv get -version=1 secret/ci/payments-apiThat's already a step up from a CI env var: vault kv metadata get secret/ci/payments-api shows exactly when each version was created and by whom. But it's still a static secret sitting at a path: the real work is controlling who can read it, and that's where AppRole comes in.
AppRole auth: no human-managed static token in CI
A human logging into Vault uses their own identity, userpass, OIDC, whatever your org runs. A CI pipeline isn't human, and giving it a personal token (or worse, a shared root token) just relocates the credential to a CI secret field instead.
AppRole splits authentication into two pieces: a role_id (not secret, identifies the role, safe to bake into a pipeline config) and a secret_id (secret, short-lived, generated on demand). CI fetches a secret_id at the start of a run, trades both for a Vault token that expires when the job ends.
# policy: ci-payments-read.hcl
path "secret/data/ci/payments-api" {
capabilities = ["read"]
}
path "secret/metadata/ci/payments-api" {
capabilities = ["read", "list"]
}vault policy write ci-payments-read ci-payments-read.hcl
vault auth enable approle
vault write auth/approle/role/ci-payments-pipeline \
token_policies="ci-payments-read" \
token_ttl=15m \
token_max_ttl=30m \
secret_id_ttl=10m \
secret_id_num_uses=1
vault read auth/approle/role/ci-payments-pipeline/role-id
vault write -f auth/approle/role/ci-payments-pipeline/secret-idsecret_id_num_uses=1 means that secret_id is consumed the moment CI uses it. Even if it leaked into a build log, it's already dead. token_ttl=15m means the resulting Vault token is worthless past the length of a normal job.
Never use Vault's initial root token for day-to-day access, CI included. It bypasses every policy you write. Root tokens exist for bootstrapping and emergency break-glass: generated on demand, revoked immediately after, never stored, not even in Vault itself.
Fetching the secret in GitHub Actions without writing it to disk or logs
The secret_id should come from somewhere your CI platform treats as protected: a masked CI variable, or better, one generated per run by a trusted bootstrap step. Even a standing CI secret now has a blast radius of "read one Vault path for fifteen minutes," not "root access to production."
- name: Fetch secret from Vault
uses: hashicorp/vault-action@v3
with:
url: https://vault.internal.example.com:8200
method: approle
roleId: ${{ vars.VAULT_ROLE_ID }}
secretId: ${{ secrets.VAULT_SECRET_ID }}
secrets: |
secret/data/ci/payments-api api_key | PAYMENTS_API_KEY ;
secret/data/ci/payments-api webhook_secret | PAYMENTS_WEBHOOK_SECRET
- name: Deploy
run: ./scripts/deploy.sh
env:
PAYMENTS_API_KEY: ${{ env.PAYMENTS_API_KEY }}
PAYMENTS_WEBHOOK_SECRET: ${{ env.PAYMENTS_WEBHOOK_SECRET }}hashicorp/vault-action handles the AppRole login and masks the values in the Actions log automatically; the secret lands as a step output/env var, never as a file on the runner's disk. The same rule applies when calling Vault's HTTP API directly: pipe the response into an environment variable or a build tool's secret input, not a temp file you forget to clean up.
Resist the urge to echo $PAYMENTS_API_KEY even temporarily while debugging a pipeline. Most CI systems only mask known secret values: one fetched fresh from Vault in that same job often isn't registered as a masked string yet, and a failed step's error output can dump environment context verbatim. The safest debug is confirming the variable is non-empty: test -n "$PAYMENTS_API_KEY" && echo "set".
Dynamic secrets: stop handing out the password at all
AppRole solves authenticating to Vault. Dynamic secrets solve the bigger problem: the database password itself shouldn't be static at all. With the database secrets engine, Vault creates a short-lived database user for each CI run, backed by a real CREATE ROLE on your Postgres instance.
vault secrets enable database
vault write database/config/payments-db \
plugin_name=postgresql-database-plugin \
connection_url="postgresql://{{username}}:{{password}}@db.internal:5432/payments" \
allowed_roles="ci-readonly" \
username="vault-admin" \
password="..."
vault write database/roles/ci-readonly \
db_name=payments-db \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="4h"
vault read database/creds/ci-readonlyThat last command is what CI runs instead of reading a stored password; it gets back a username and password that exist only for this lease, scoped to exactly the grants in creation_statements.
Leases and revocation: making "expired" actually mean something
Every dynamic secret Vault issues comes with a lease, a tracked object with a TTL that Vault itself manages, so you can see and control it directly, rather than trusting the pipeline to let go of it.
vault list sys/leases/lookup/database/creds/ci-readonly
vault lease revoke -prefix database/creds/ci-readonly
vault lease renew database/creds/ci-readonly/abcd1234If a lease's TTL expires, Vault (with the database plugin) drops the corresponding Postgres role automatically: the credential is deleted outright, not merely revoked. And if you ever suspect a leak, vault lease revoke -prefix kills every credential issued under that path immediately.
Where this leaves you
A single dev-mode Vault instance is enough to try this before rolling it out further. What changes is the failure mode: every issuance gets logged, every lease can be revoked on demand, and nothing sits static long enough to go stale.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out