Every other secret in this repository is one somebody created and Vault stored. A dynamic secret is different: it does not exist until it is asked for, belongs to exactly one consumer, and expires on its own.
There is no shared database password to leak, rotate on a schedule, or find in a CI log two years later — because there is no shared password.
./scripts/bootstrap-dev-cluster.sh --with-database
export VAULT_ADDR=https://127.0.0.1:8200
export VAULT_CACERT=$PWD/docker/dev/tls/ca.crt
export VAULT_TOKEN=<root token printed above>
./scripts/bootstrap-database-secrets.sh --password bootstrap-only-rotated-immediately
vault read database/creds/appdata-readonlyEach read returns a different user:
Key Value
--- -----
lease_id database/creds/appdata-readonly/8kQ...
lease_duration 1h
username v-token-appdata-r-Xf3kP2...
password A1a-...
That account exists in Postgres now, did not a moment ago, and will be dropped when the lease ends.
| Thing | Why |
|---|---|
| A connection to Postgres | With allowed_roles naming exactly two roles |
appdata-readonly |
SELECT only |
appdata-readwrite |
SELECT, INSERT, UPDATE, DELETE |
| A policy per role | Read on that role's creds path and nothing else |
| Root rotation | See below |
Credentials default to a 1-hour TTL and a 24-hour maximum.
The creation statement sets an expiry on the database role itself, not only in Vault's lease.
Without it, a credential outlives its lease whenever Vault is unreachable at the moment revocation was due — which is precisely when you would rather it did not. With it, the database enforces the deadline even if Vault never gets to run the revocation statement.
MySQL cannot do this, and it is the one place where the two engines are not equivalent. See Choosing an engine.
Postgres refuses to drop a role that owns objects. A revocation that
tries to DROP ROLE directly fails, Vault gives up on the lease, and the
account stays alive in the database with nothing tracking it — which is
worse than never having managed it, because it now looks managed.
Omit it and no role can use the connection. Set it to * and every role
added later can, including ones added by someone who never looked at this
connection. It names the two roles explicitly.
./scripts/bootstrap-database-secrets.sh --engine mysqlTwo engines, one interface. The mount, the roles, the policies and the root rotation are identical; the plugin, the connection string and the SQL are all that change.
The point of having a second one is not MySQL specifically. It is that "Vault's database engine supports X" is easy to say and hides real differences, and this is the one that matters:
| Postgres | MySQL | |
|---|---|---|
| Credential expires because | the database enforces it, and Vault revokes it | Vault revokes it |
| Vault down at lease expiry | credential still dies on schedule | credential stays live |
| Username limit | 63 bytes | 32 characters (16 before 5.7) |
| Transport setting | sslmode= |
tls= |
readwrite can create tables |
yes | no — see below |
MySQL has no VALID UNTIL. CREATE USER takes no deadline, and
MySQL's PASSWORD EXPIRE is password ageing rather than an account
expiry — it cannot express "this login is dead at 14:05". So on the
MySQL path, revocation by Vault is the only thing that ends a
credential.
That is a genuine reduction in safety rather than a footnote. The Postgres path degrades safely when Vault is unavailable; the MySQL path depends on Vault being there at the right moment. If that matters for your workload, it is an argument for Postgres, or for a shorter TTL and monitoring that notices Vault being down — which the alerting rules already provide.
The tests assert this difference rather than smoothing it over: one
checks the MySQL statements do not contain VALID UNTIL (pasting it
in is a syntax error there), and its neighbour checks the Postgres ones
still do.
The Postgres readwrite role grants USAGE, CREATE ON SCHEMA public, so
a credential issued from it can create tables — which is what an ORM's
auto-migration or a schema migration step needs. The MySQL role grants
SELECT, INSERT, UPDATE, DELETE and no DDL, so the same migration fails
with CREATE command denied.
This one is not an oversight and it is not quietly fixable, because the two databases scope the privilege differently:
- Postgres ties DDL to ownership. A credential that creates a table owns it and can alter or drop it, and it can do nothing to tables it did not create.
- MySQL grants privileges per schema.
GRANT CREATE, ALTER, DROP ON appdata.*applies to every table in the database, including ones the credential never touched.
So granting MySQL the same capability would hand every issued readwrite
credential the ability to drop the whole schema. The narrower grant is
the default, and a workload that genuinely needs DDL should get a third
role scoped to that job rather than have readwrite widened underneath
every other consumer.
- Vault connects as a dedicated
vaultadminaccount, created bydocker/mysql/init/. Issuing credentials needsCREATE USERandGRANT OPTION, which an ordinary per-database account lacks — but connecting asrootwould be a trap, because the bootstrap rotates the password of whatever account it uses androot's password is what the container healthcheck needs. That would leave the container permanently unhealthy while MySQL itself was fine. - Users are created as
'{{name}}'@'%', not@'localhost'. Vault connects over the container network, and a user scoped to localhost cannot log in from where Vault actually is. - Grants are scoped to the one database (
ON appdata.*).ON *.*would give every issued credential the run of the server. - Usernames are capped at 32 characters. Vault's default username template truncates to fit, so this is handled — but exceeding it fails at issuance rather than at configuration, which makes it an unpleasant surprise if you write your own template.
The last step of the bootstrap rotates the password of the account Vault itself connects with, and Vault does not report the new value.
After it runs, nobody knows the credential to that database — not the operator who ran the script, not anything in version control. Only Vault does. That is the intended end state, and it is what makes the difference between "we use Vault" and "we have a password in Vault".
It has a real consequence. If Vault's storage is lost and cannot be
restored, that account has to be reset out-of-band by a database
superuser. That is an argument for taking
disaster recovery seriously, not for skipping the
rotation — but --no-rotate-root exists, and the script says plainly
what you are left with if you use it: a shared database password, which
is the thing this engine exists to remove.
sslmode defaults to disable. That is correct for the local Docker
profile, where Vault and Postgres share a container network and no
certificate authority exists, and wrong everywhere else.
A real deployment should pass --sslmode verify-full, which is why the
value appears in the connection string rather than being implied. This
default is a convenience for the dev profile, not a recommendation.
tests/database/run-tests.sh covers the configuration against a shim —
that the connection is scoped, the two roles grant genuinely different
things, the policies do not hand a consumer more than it needs, and
rotation happens last.
tests/integration/run-tests.sh covers whether any of it works, against
a real Postgres and a real Vault:
- two reads return different users
- the credential connects and queries
- the readonly credential cannot write
- revoking the lease stops the credential working
- the role is gone from
pg_rolesafterwards, not merely locked out - after rotation the bootstrap password no longer authenticates, while Vault can still issue
The last one is the whole argument in a single assertion, and it is only demonstrable against a real database.
The same suite runs the MySQL path against a real MySQL server on the
same mount — a second connection, not a second engine — and asserts the
parts that differ: that the generated username fits MySQL's 32-character
limit, that the readonly credential cannot write and cannot read
mysql.user, that revoking the lease drops the account, and that
vaultadmin's bootstrap password stops working after rotation.
PostgreSQL and MySQL. Vault's database engine also supports MSSQL, MongoDB and others through the same interface, and the shape here would carry over, but nothing else has been exercised.
The cloud profiles do not provision a database. terraform/aws and
terraform/azure build a Vault cluster, not an application estate —
pointing the engine at an RDS or Azure Database instance is left to the
reader, and the --host, --sslmode and --username flags exist for
exactly that.