Appearance
Database credentials
Where the password lives
Keel provisions RDS with manage_master_user_password = true. That means AWS — not Keel — generates the master password, stores it in Secrets Manager, and rotates it on a schedule.
The consequence worth internalising: Keel never holds the password. It is not in keel.yml, not in OpenTofu state, not in ~/.keel, and not in any log Keel writes. Nothing you can grep will produce it, because nothing on your machine has ever had it.
The running tasks do not hold it either. Each service's task definition names the secret by ARN and ECS injects the values at container start:
| Variable | Source |
|---|---|
DATABASE_USER | the secret's username key |
DATABASE_PASSWORD | the secret's password key |
DATABASE_CREDENTIALS | the whole JSON payload, for a client that wants it |
DATABASE_HOST | the endpoint, from the RDS resource |
DATABASE_PORT | the port, from the RDS resource |
DATABASE_NAME | the database name |
DATABASE_SCHEME | postgres or mysql2, per the engine |
So an application never needs the admin credentials, and should not use them: it already has what it needs, and it keeps working across a rotation.
There is no DATABASE_URL, and composing one needs care
The parts are injected separately. The cache gets CACHE_URL and REDIS_URL, so the asymmetry is real and deliberate: a cache URL can be composed by Keel because Keel knows every part of it at apply time, and a database URL cannot, because the password is a value Keel never sees. There is nowhere in the pipeline that could assemble one — ECS resolves the secret at container start, after every task-definition value is fixed, and a URL baked at apply time would be a copy of a password that rotates every seven days.
So the composition happens in your application, and the hazard is percent-encoding:
sh
# Wrong. A password containing ? # % or / silently produces a URL that parses
# into something else — usually an auth failure, occasionally a connection to a
# different database.
DATABASE_URL="$DATABASE_SCHEME://$DATABASE_USER:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT/$DATABASE_NAME"RDS forbids /, ", @ and space in a master password, so the two characters that break a URL most obviously cannot occur — which is exactly what makes this look like it works. ?, # and % are permitted and each breaks the parse.
Read the parts directly where the framework allows it. In Rails that is config/database.yml:
yaml
production:
adapter: postgresql
host: <%= ENV["DATABASE_HOST"] %>
port: <%= ENV["DATABASE_PORT"] %>
database: <%= ENV["DATABASE_NAME"] %>
username: <%= ENV["DATABASE_USER"] %>
password: <%= ENV["DATABASE_PASSWORD"] %>Where a URL is the only input the framework takes — dj-database-url, Prisma — build it in the entrypoint with the encoding done properly:
sh
#!/bin/sh
export DATABASE_URL="$(python3 - <<'PY'
import os, urllib.parse as u
q = lambda v: u.quote(v, safe="")
print("%s://%s:%s@%s:%s/%s" % (
os.environ["DATABASE_SCHEME"], q(os.environ["DATABASE_USER"]),
q(os.environ["DATABASE_PASSWORD"]), os.environ["DATABASE_HOST"],
os.environ["DATABASE_PORT"], os.environ["DATABASE_NAME"]))
PY
)"
exec "$@"More than one database role
Keel provisions one role: the RDS master, which AWS generates and rotates. It does not create application or migration roles, because creating a Postgres role means running SQL inside the VPC and there is no moment in an apply where that is a safe, idempotent thing for a provisioning tool to do on your behalf.
The supported pattern is to create them yourself and let Keel deliver the credential to the right task. This matters more than it sounds: a runtime role that can bypass row-level security makes every tenant-isolation policy in the schema advisory, so "the migrator is more privileged than the application" is the normal arrangement rather than an exotic one.
Create the roles once, as the master user, from inside the VPC:
$ keel exec web -c 'psql "$(...)" -c " CREATE ROLE app_runtime LOGIN PASSWORD :'"'"'pw'"'"' NOSUPERUSER NOBYPASSRLS; CREATE ROLE app_migrator LOGIN PASSWORD :'"'"'pw2'"'"'; GRANT CREATE ON SCHEMA public TO app_migrator;"'Or run the same statements from a migration, which is easier to keep in version control.
Store each credential as a Keel secret:
$ keel config set RUNTIME_DATABASE_URL 'postgres://app_runtime:...@...' $ keel config set MIGRATOR_DATABASE_URL 'postgres://app_migrator:...@...'Scope them. The runtime credential goes on the services; the migration credential goes on the release and nowhere else:
yamlservices: web: secrets: [RUNTIME_DATABASE_URL] worker: secrets: [RUNTIME_DATABASE_URL] release: command: bundle exec rails db:migrate service: web secrets: - MIGRATOR_DATABASE_URL
release.secrets puts the migrator's credential on a task definition of its own — {app}-{env}-release — which exists only for the length of one migration. Without it the credential has to be declared on a long-running service, where it sits in every internet-facing task for the life of the deployment in order to be read once a deploy.
Two things this does not do. Keel does not rotate these passwords: they are ordinary SSM parameters, and rotating one means writing a new value and redeploying. And the release still runs with the release service's task role and security group — the isolation is of the database credential, not of the AWS identity.
Each person as themselves
Everything above shares one credential, which is the honest limit of it: keel db psql, a tunnel and their own client, and the secret pasted into a GUI are all the master user, so %u in log_line_prefix attributes every statement to the same name. database.iam_auth closes that (Pro):
yaml
database:
iam_auth: true$ keel db grant alice # creates the database role "keel-alice"
$ keel db revoke alice # drops it; alice's IAM access is untouchedTwo halves, and neither is sufficient on its own. IAM authorises minting a token for a database user of a given name; the database must separately hold a role of that name that accepts token authentication (rds_iam, or AWSAuthenticationPlugin). So turning the flag on changes nothing until somebody is granted — which the refusal says, because a flag that appears to do nothing is one people set twice and then turn off.
The database user is the IAM user name with a keel- prefix and nothing else done to it. Three strings have to be identical — the session tag IAM substitutes, the resource pattern the grant matches, and the role name the DDL creates — and IAM substitutes the raw name, so a sanitiser in the middle of that chain looks like hygiene while breaking it. An awkward name is refused rather than rewritten.
A grant creates a login and no privileges. What that person may read stays the schema owner's decision, made in SQL where it is reviewable, exactly like the roles in the section above.
Three consequences worth planning around:
- The application keeps the master secret. A token expires in fifteen minutes and IAM authentication has a connection-rate ceiling. So the claim is that human access is attributable; application access is still one identity.
- Every failure falls back to the master credential — the flag off, not an IAM user, not granted yet, a token that could not be minted — and none of them is an error. The shell says which identity it will connect with before it connects.
- TLS is
require, notverify-full. The token is signed for the database's endpoint while your client dials127.0.0.1through the tunnel, so no certificate can match the name that was dialled. RDS refuses an unencrypted token connection outright, so the encryption is not optional — but "encrypted" and "verified" are different claims, and this is the first.
A session opened before the grant keeps running as the master user, and the correlation ID Keel stamps as PGAPPNAME is not joined to the database user. See docs/design/database-identity.md.
Seeing that it exists
The config views list it alongside the variables someone set, because a service's environment carries both:
$ keel config list
NAME TYPE VERSION
SECRET_KEY_BASE SecureString 3
Managed by AWS — injected into every service, not editable here:
@database credentials (active)
DATABASE_USER ← username the master username
DATABASE_PASSWORD ← password generated by AWS, rotated on a schedule
DATABASE_CREDENTIALS the whole JSON secret, for a client that wants it
AWS Secrets Manager, generated and rotated by RDS
rotated by AWS every 7 days — do not copy the value
secret arn:aws:secretsmanager:us-west-2:123456789012:secret:rds!db-1a2b3c-AbCdEf
values keel db credentialsThe dashboard's Config tab shows the same thing, in a block below the variables table and outside the cursor's range — the selection there drives unset, and none of this can be unset.
No value is read to produce that listing: it comes from DescribeSecret, which returns metadata only. So a viewer or a developer sees the secret exists, what it injects and how often it rotates, without holding any permission that could read it.
keel config set and keel config unset refuse these names rather than reporting a missing SSM parameter:
$ keel config unset DATABASE_PASSWORD
Error: DATABASE_PASSWORD is not a configuration variable and cannot be unset: it comes
from @database credentials, which AWS generates and rotates. Read the values with
'keel db credentials'.Reading them as a human
For the cases an application does not cover — connecting with psql, running a one-off query, creating a second database user — there are two commands. Both need admin access, because both call secretsmanager:GetSecretValue.
$ keel db credentials
acme (production) — postgres
host acme-production-db.abc123.us-west-2.rds.amazonaws.com
port 5432
database acme
username keeladmin
password •••••••••••••••• (--show to reveal)
secret arn:aws:secretsmanager:us-west-2:123456789012:secret:rds!db-1a2b3c-AbCdEfThe password is masked by default: checking an endpoint or the admin username is the common case, and it should not put a credential into a shared screen or a scrollback buffer. --show prints it.
For a connection string:
$ psql "$(keel db url)"keel db url is the only Keel command that emits a password, and it emits it only when asked. Do not store what it prints — the password rotates, and a saved URL stops working.
Reaching the database at all
The database sits in private subnets, and its security group only admits the service security groups on the engine's port. It is not reachable from a laptop, whatever credentials you hold.
There are two routes in, and both go through a task that is already running in the private subnets.
The first needs no admin access and no admin password:
$ keel exec web -c \
'psql "postgres://$DATABASE_USER:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT/$DATABASE_NAME"'That uses the service's own injected values and is the right answer for almost every "I need to look at the database" moment.
The second forwards a local port, so your own client reaches the database from your machine:
$ keel tunnel @database
Tunnel to @database (production)
localhost:5432 → acme-production-db.abc123.us-west-2.rds.amazonaws.com:5432
through task 9f8e7d
$ keel db psql # the same tunnel, plus psql and the master credentialkeel tunnel is the primitive and everything else is a wrapper — TablePlus, DataGrip and a migration tool all work against it without Keel knowing about them. It is SSM port forwarding through the same data channel keel exec uses, with a different document: nothing new is exposed, no listening port, no bastion, and it costs nothing when idle. It does need a running task to go through, so a scaled-to-zero environment has nothing to tunnel from, which is reported rather than hung on.
keel db psql (also keel db mysql, keel db shell) is admin, and it asks before connecting, naming the environment: the credential it hands the client is the master user, which can drop the schema. The password reaches the client through the environment (PGPASSWORD, MYSQL_PWD) and never the command line, where an argument list is world-readable in ps.
keel db credentials is for the cases where you genuinely need the master user in your own hands — creating roles, granting to a new user, an out-of-band migration.
Rotation
AWS rotates the master password on the schedule the secret carries. Two things follow:
- Copies expire. Anything you paste into a password manager, a
.env, or a CI variable will stop working, silently, at a time nobody chose. Read the secret each time instead. - Nothing needs redeploying when it rotates. The task definitions reference the secret by ARN, so ECS picks up the new value the next time a task starts.
If keel db credentials says the output is missing
A stack applied before Keel emitted the database_secret_arn output has no record of where the secret is. Re-applying adds the output:
$ keel up --env productionNothing is recreated — an output is not a resource.