Dynamic database credentials for Jenkins with Vault

Stop pasting Postgres passwords into Jenkins credentials. Vault mints a fresh user per build and revokes it when the job ends.

Mar 24, 2026·Updated ·5 min readIntermediate·By SecOpsLog · documentation-verified

A Postgres password stored as a Jenkins credential is shared by every job that can reference its id, readable by anyone who can run a pipeline on the controller, and unchanged since the day the migration job was written. Vault's database secrets engine replaces it with a user that did not exist before the build and will not exist an hour after. The engine side is well documented; the two things that go wrong are in Jenkins: a plugin default that quietly rewrites the secret path, and a lease that lives longer than the block that used it.

The engine: one admin user Vault owns

vault-db.sh
vault secrets enable database
vault write database/config/appdb \
plugin_name=postgresql-database-plugin \
allowed_roles="jenkins-migrate" \
connection_url="postgresql://{{username}}:{{password}}@db.internal:5432/app?sslmode=verify-full" \
username="vault_admin" password="$BOOTSTRAP_PW" \
password_authentication="scram-sha-256"
# Vault picks a new admin password and keeps it; the bootstrap value in your shell history is now useless
vault write -f database/rotate-root/appdb

allowed_roles is the guardrail on the connection: even a mis-written policy cannot make this connection mint users for a role not in the list. rotate-root is the step tutorials skip, and it is the one that makes the admin credential Vault-only, because after it nobody, including the person who ran the setup, knows the password. Vault needs that admin user to be able to CREATE ROLE and DROP ROLE and to grant the privileges in the creation statement; it does not need superuser, and a Postgres role with CREATEROLE plus membership in the application's grant role is enough.

vault-role.sh
vault write database/roles/jenkins-migrate \
db_name=appdb \
default_ttl="15m" max_ttl="30m" \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT app_migrator TO \"{{name}}\";"

VALID UNTIL '{{expiration}}' makes Postgres itself refuse the login after the TTL, so a Vault outage that delays revocation does not extend the user's life. Granting a pre-defined role (app_migrator) instead of listing privileges keeps the SQL in the migration repository where it is reviewed, and keeps the Vault role short. The TTL should cover the slowest migration with margin, and the answer to a job that needs three hours is a separate role with a longer TTL and narrower grants, not a longer TTL for everyone.

The pipeline, and the default that breaks it

Jenkinsfile
pipeline {
agent any
stages {
stage('migrate') {
steps {
withVault(configuration: [vaultUrl: 'https://vault.acme.internal:8200',
vaultCredentialId: 'vault-approle-ci'],
vaultSecrets: [[
path: 'database/creds/jenkins-migrate',
engineVersion: 1, // not KV v2: without this the plugin asks for database/data/creds/...
secretValues: [[envVar: 'PGUSER', vaultKey: 'username'],
[envVar: 'PGPASSWORD', vaultKey: 'password']]
]]) {
sh './migrate.sh' // PGUSER/PGPASSWORD exist only inside this block
}
}
}
}
}

The plugin's default engine version is 2, which is right for KV v2 and wrong for every other engine: with the default, the plugin rewrites database/creds/jenkins-migrate into a KV-v2-shaped path and Vault returns a 404 that reads like a permissions problem. engineVersion: 1 on the secret entry is the fix, and it is the most common reason this integration "does not work". The vaultCredentialId should be an AppRole credential (the README's recommendation for automation) or, for a controller running in Kubernetes, the plugin's Kubernetes credential that reads the pod's service account token; a root or long-lived token in the credential store defeats the purpose.

bash — read the role once before wiring the job
vault read database/creds/jenkins-migrate
Key Value
lease_id database/creds/jenkins-migrate/Ck2m…
lease_duration 15m
username v-approle-jenkins--Kx2mQ…
password …
psql "host=db.internal dbname=app user=v-approle-jenkins--Kx2mQ… sslmode=verify-full" -c "select current_user"
v-approle-jenkins--Kx2mQ…

The lease outlives the block

withVault scrubs the variables when the block ends; in the current plugin source nothing revokes the lease (a disposer class exists but is never registered), so the database user stays valid until the TTL. For a fifteen-minute TTL that is an acceptable window. For a job that reads the credentials and then fails with a stack trace into the console log, it is fifteen minutes during which a copied password works, which is the argument for short TTLs over any post-build cleanup. Where revocation on completion is required, the plugin's VaultTokenCredentialBinding exposes VAULT_ADDR and VAULT_TOKEN to a shell, and the job can read the credentials itself and vault lease revoke the id in a post { always } step.

jenkins-migrate.hcl (the policy behind the AppRole)
path "database/creds/jenkins-migrate" { capabilities = ["read"] }
path "sys/leases/revoke" { capabilities = ["update"] } # only if the job revokes its own lease
# nothing under database/creds/*, kv/*, or pki/*: one role, one path
Console logs are the new credential store
A migration tool that echoes its connection string, a Groovy step that prints the environment, or a failed sh step with set -x on will write the leased password into a log that every reader of the job can open. The plugin masks the values it injected, but only in output it sees; a child process logging to a file does not get masked. Keep TTLs short enough that a logged password is stale by the time anyone reads it.

The same engine gives a Kubernetes pod a per-pod database user through Kubernetes auth, and the same AppRole discipline keeps other pipeline secrets out of the credential store. Once builds carry nothing long-lived, the quarterly password change stops being a project.

Go deeper in a courseVault from dev to productionDatabase engine, AppRole and Kubernetes auth for CI, leases and running Vault in production.View course

Related posts

Quick reference