Skip to main content
Version: 0.9.12

Managed database secrets

platform v0.9.11verified 2026-05-14

When the database is a managed AWS service (RDS, Aurora, ElastiCache for Redis with RBAC), AWS owns the credential lifecycle. The username and password are stored in a Secrets Manager secret AWS creates and rotates — you should not duplicate them into the service's per-service secrets bundle. Two vars.yaml features cover this case cleanly:

  • secret.arn_from — pull a single field out of a JSON secret named by a bootstrap variable.
  • compose.template — assemble a final value (like DATABASE_URL) from those fields plus your own non-secret parts.

The result: the database's app-facing connection string is built fresh on every restart, never written to disk, and never duplicated across services or stores.

End-to-end pattern

1. Bootstrap the ARN

The bootstrap .env placed by cloud-init contains the ARN of the AWS-owned secret:

# /opt/deployment/.env on each Database / API / Web / Voice / Ops host
DB_MASTER_SECRET_ARN=arn:aws:secretsmanager:eu-central-1:123456789012:secret:rds!cluster-abcdef-...

This is the only thing that has to know the ARN. Place it in bootstrap via your provisioning workflow or write it manually on the host. It is bootstrap because the service hosts have to know which secret to fetch before they can resolve anything else.

2. Declare the credential variables in vars.yaml

Each service that talks to the database adds:

# /opt/services/api/vars.yaml (excerpt)
- name: DB_MASTER_SECRET_ARN
source: local
required: true
# Set in bootstrap to the AWS-managed master secret ARN.

- name: DATABASE_USERNAME
required: true
sensitive: true
containers: [telapi]
secret:
arn_from: DB_MASTER_SECRET_ARN
type: json
key: username

- name: DATABASE_PASSWORD
required: true
sensitive: true
containers: [telapi]
secret:
arn_from: DB_MASTER_SECRET_ARN
type: json
key: password

fetch-env.sh calls aws secretsmanager get-secret-value --secret-id $DB_MASTER_SECRET_ARN, parses the response as JSON, and reads the username / password keys. The values land in shell memory only.

Per-service SSM / SM placeholders are not needed for variables flagged with secret.arn_from; there is nothing to seed. Only the bootstrap variable (DB_MASTER_SECRET_ARN) needs to be wired in.

3. Add the non-secret parts (host, port, DB name) to SSM

- name: DATABASE_HOST
required: true
containers: [telapi]
# SSM: /voiceai/<env>/api/DATABASE_HOST = <cluster-endpoint>

- name: DATABASE_PORT
default: '5432'
containers: [telapi]

- name: DATABASE_NAME
required: true
containers: [telapi]
# SSM: /voiceai/<env>/api/DATABASE_NAME = <dbname>

Host and DB name come from SSM as usual — they're not secrets. Port has a sane default.

4. Compose the final DATABASE_URL

- name: DATABASE_URL
required: true
sensitive: true
containers: [telapi]
compose:
template: 'postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}'
encode:
- DATABASE_PASSWORD
- DATABASE_USERNAME

compose.template runs in the second pass of fetch-env.sh, after every other variable has resolved. compose.encode lists the variable names whose values must be URL-encoded before substitution — RDS-rotated passwords commonly contain @, :, &, /, etc.

The result is a single DATABASE_URL string the application code (Prisma, etc.) can consume directly. The username and password components are also exported individually for any code path that needs them separately.

Encrypted connections: keep sslmode / other ssl* keys out of DATABASE_URL — the Node stack resolves TLS from DATABASE_SSL_MODE plus DATABASE_SSL_CA_BUNDLE_B64 (decoded to DATABASE_SSL_CA_FILE, default /etc/ssl/database/ca.crt). That avoids pg-connection-string rewriting sslmode in ways that fight an explicit client ssl config. Through PgBouncer, the bundle must trust PgBouncer’s certificate; for direct RDS/Aurora, use the AWS RDS CA PEM in DATABASE_SSL_CA_BUNDLE_B64. See Internal encryption.

5. Wire docker-compose.yaml

# /opt/services/api/docker-compose.yaml (excerpt)
services:
telapi:
image: ${ECR_REGISTRY}/voiceai-telapi:${ECR_TAG}
environment:
- DATABASE_URL=${DATABASE_URL}
- DATABASE_HOST=${DATABASE_HOST}
- DATABASE_NAME=${DATABASE_NAME}
# ...

${DATABASE_URL} is now in the shell init.sh calls docker compose up from. Compose substitutes it into the container's environment block.

What runs at boot, in order

Same pattern, other AWS-managed services

The same shape works for any AWS-owned credential blob. Set the ARN in bootstrap, declare the variables with secret.arn_from, optionally compose a final URL.

Use caseBootstrap variablesecret.arn_from keysComposed result
RDS / Aurora master user (Postgres)DB_MASTER_SECRET_ARNusername, passwordDATABASE_URL
Aurora reader endpointDB_READER_SECRET_ARNusername, passwordDATABASE_READ_URL
ElastiCache RBAC userREDIS_USER_SECRET_ARNusername, passwordREDIS_URL
Application DB user (rotated by Lambda)APP_DB_USER_SECRET_ARNusername, passwordDATABASE_URL

For ElastiCache TLS / RBAC specifically, set REDIS_TLS_ENABLED=true and REDIS_USERNAME / REDIS_PASSWORD from the secret; REDIS_URL (when used) can be composed the same way.

What the operator does

  1. Create the managed-DB instance and let AWS generate the master secret.
  2. Note the secret ARN; place it in the bootstrap .env of every service that talks to the database (Database, API, Web, Voice, Ops).
  3. Grant the per-service IAM role secretsmanager:GetSecretValue on that ARN (in addition to the per-service <NAMESPACE>/<service>/* grant).
  4. Populate DATABASE_HOST, DATABASE_NAME, and any other non-secret parts in SSM under each service's namespace.
  5. Run update.sh --restart-only on each service. Done.

When AWS rotates the password, the value picked up on the next update.sh --restart-only (or any restart that re-runs fetch-env.sh) is the new one — no rotation work is required on Delphi side. If the application is connection-pooling, restart it after rotation so existing connections re-handshake with the new credentials.

Why not just store DATABASE_URL in our own SM bundle?

You can — and that is the simplest pattern when the database is self-managed (Postgres on the Database service instance). For AWS-managed databases the secret.arn_from + compose.template pattern wins because:

  • AWS rotates the password automatically; no Delphi-side rotation script needed.
  • The credential lives in exactly one place — no copy in ${NAMESPACE}/api/secrets, ${NAMESPACE}/web/secrets, ${NAMESPACE}/voice/secrets, etc., to keep in sync.
  • IAM policies stay narrow — only services that actually need the DB get GetSecretValue on that one ARN.

See also