Skip to content
BuildStackHQHosting
All docs

Docs

Databases

Managed Postgres and Valkey, how the connection URL reaches your app, and how backups work.

keel db create main gives an organization an EU-resident Postgres database in seconds. The connection URL is stored as the organization secret DATABASE_URL, so any service that lists that name under secrets: in its keel.yaml gets it as an environment variable on its next deploy or restart. Valkey (Redis compatible) works the same way under REDIS_URL. Stores are backed up nightly and billed per hour: 19 EUR per Postgres database-month and 9 EUR per Valkey instance-month on Business and Enterprise; Starter has no data stores.

Model

A DataStore belongs to one organization and has a kind (postgres or valkey), a name (a slug of at most 32 characters, unique per organization), a status (provisioning, ready, deleting, failed), the sampled sizeBytes with its sizeSampledAt, the connectionSecretName, and createdAt, updatedAt, deletedAt. The table is data_stores (migration 0025), tenant scoped with the same row level security as every other tenant table; keel_platform may read it for the settlement and purge directories.

Plan fields: features.dataStores (Business and Enterprise), limits.maxDataStores (Business 10, Enterprise unlimited, Starter 0), limits.dataStoreGbEach (Business 10 GB, Enterprise 50 GB), pricing.postgresPriceCentsPerMonth (1900) and pricing.valkeyPriceCentsPerMonth (900). The catalog schema refuses a plan that offers data stores without pricing both kinds or without a size allowance.

Secret names come from the store name: main maps to the bare DATABASE_URL or REDIS_URL; every other name is upper cased with dashes turned into underscores and suffixed, so a Postgres store analytics-2 is ANALYTICS_2_DATABASE_URL and a Valkey store job-queue is JOB_QUEUE_REDIS_URL. Creating a store refuses a name whose secret already exists in the organization (409 secret_name_taken), so an externally managed DATABASE_URL is never overwritten.

Connection URLs are postgres://<user>:<password>@db.internal:5432/<database>?sslmode=disable and redis://default:<password>@<name>-<org16>.valkey.internal:6379/0 (org16 is the first sixteen hex characters of the organization id). They only resolve from the agent network; there is no public port, which is why keel db psql does not exist. Run migrations from a service container (a one-off keel run of an agent that lists the secret works) or from an SSM session on the host with docker compose exec tenant-postgres psql.

Use cases

All in packages/application/src/data-stores:

  • CreateDataStore: validates the name, refuses when the plan has no data stores or the limit is reached (deleted stores stop counting immediately, purged names become free again), counts the existing stores while holding the organization's quota row lock (lockOrCreateQuotaState, so two concurrent creates cannot both squeeze under the limit), inserts the row as provisioning under a unique index on (organization_id, name), then calls the provisioner, writes the URL through PutSecret and marks the store ready. A provisioner or secret failure marks it failed and returns 502 data_store_provisioning_failed; delete a failed store to free its name. A store deleted while it was still provisioning stays deleting and the purge cleans the resources up.
  • DeleteDataStore: a ready store becomes deleting with deletedAt = now and its secret is removed at once, so services lose the URL on their next restart while the data stays for seven days (dataStoreSoftDeleteWindowMs). A failed store is deprovisioned and removed right away. Deleting while provisioning is refused with 409 data_store_busy.
  • PurgeDataStores: the data-store-purge worker role (hourly, also an in-process loop in the API on the single host) deprovisions and deletes every store whose window has passed; failures are logged and retried next tick.
  • RotateDataStoreCredentials: locks the store row, asks the provisioner for a new password and rewrites the secret inside that transaction, so two rotations run one after the other and the secret always matches the password the store accepts. Running services keep the URL they started with until they restart (Postgres keeps their open connections alive, Valkey drops them when the container is recreated); the response never carries the password.
  • GetDataStoreConnection: reads the URL from the secret store. The endpoint needs data_stores.connect, which only owners and admins hold; every call is written to the audit log by the mutating request interceptor with the store id as target and never the URL.
  • SettleDataStoreUsage: the data-store-settlement role, every five minutes. Per organization it samples every store's size through the provisioner (pg_database_size for Postgres, used_memory for Valkey), then under the quota row lock inserts one data_store kind usage record per completed clock hour since the store's settledWindowStart (unsettledDataStoreWindows, the same window math as services). A deleted store is settled through its deletion time, including the partial hour, and then leaves the billable set. Records carry data_store_id and data_store_kind and no run; a unique index on (data_store_id, window_start) keeps settlement idempotent.

Ports and adapters

DataStoreProvisioner is the seam the platform adapters implement:

provision(kind, organizationId, name) -> { host, port, database, username, password }
deprovision(kind, organizationId, name)
rotate(kind, organizationId, name) -> credentials
usage(kind, organizationId, name) -> { sizeBytes }

DataStoreRepository and DataStoreDirectory (billable and purge-due listings across tenants) are the persistence ports; DataStoreConnectionReader reads a secret value for the reveal endpoint.

Single host adapters in packages/infrastructure/src/data-stores:

  • SingleHostPostgresProvisioner talks to the dedicated tenant-postgres cluster over TENANT_POSTGRES_ADMIN_URL. Provisioning refuses a role that already exists (PostgresRoleExistsError, a store is never taken over) and creates a LOGIN role named ks_<first 16 hex of the org id>_<name> with CONNECTION LIMIT 20, statement_timeout and idle_in_transaction_session_timeout of 60 seconds, NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS, a database of the same name owned by that role, REVOKE CONNECT ON DATABASE ... FROM PUBLIC and a grant back to the owner. The first call also revokes CONNECT on postgres and template1 from PUBLIC, so a tenant role can open nothing but its own database. Rotation is ALTER ROLE ... PASSWORD; deprovisioning terminates the backends and drops the database (WITH (FORCE)) and the role; usage is pg_database_size. Identifiers are validated before they are quoted, passwords are 24 random bytes in base64url and reach the cluster only as a SCRAM-SHA-256 verifier computed in the API, so the plaintext is in no statement, no server log and no PostgresError.query that a failed statement would carry into the API log.
  • DockerValkeyProvisioner runs one Valkey 8 container per store through the Docker socket (valkey/valkey:8.1-alpine, --appendonly yes, --maxmemory 256mb, --maxmemory-policy noeviction, --protected-mode yes, --requirepass; a cgroup memory limit of maxmemory plus 64 MB with no swap, a pids limit and no-new-privileges, no published ports), named keel-valkey-<org16>-<name> with its own volume of the same name (labels keel.role=data-store, keel.org, keel.data-store), restart: unless-stopped, on the agent network with the alias <name>-<org16>.valkey.internal and, when TENANT_VALKEY_ADDRESS_RANGE is set, a static address from that block (the lowest free one; an allocation race with Docker is retried). Before it replaces, removes or inspects a container the adapter checks the keel.role and keel.org labels and refuses one that belongs to another organization (ValkeyContainerOwnedElsewhereError). Rotation recreates the container with a new password (a second of downtime; the AOF on the volume keeps the data). One container per store was chosen over ACL users on a shared instance because ACL key patterns are not isolation: a shared instance leaks INFO, CONFIG and memory pressure across tenants and forces every client to prefix keys. The password is part of the container command line, so anyone with the Docker socket (root on the host) can read it, which is the same trust boundary as the platform database password.
  • CompositeDataStoreProvisioner routes by kind; a kind without an adapter answers 501 data_stores_not_configured.

Composition: the API builds the Postgres adapter when TENANT_POSTGRES_ADMIN_URL is set and the Valkey adapter when EXECUTOR=docker (apps/api/src/composition/data-store-ports.ts); the worker mirrors it in apps/worker/src/composition/data-store-ports.ts.

Network path on the single host

tenant-postgres is a second postgres:17.6-alpine container with its own volume, attached to platform (so the API and the worker can administer it) and to the internal keel-agents bridge at 10.201.0.40 with the alias db.internal. Service containers run in their supervisor's network namespace on keel-agents with CoreDNS as their only nameserver (deploy/single-host/agent-resolv.conf), and CoreDNS delegates the internal. zone to Docker's embedded resolver, so db.internal and the Valkey aliases answer with the container addresses while every other name gets the gateway; database traffic never touches the egress gateway and the gateway's SNI policy still governs everything that resolves to 10.201.0.10. Run containers get dynamic addresses from 10.201.0.0/25 (the network's ip_range) and Valkey containers static ones from 10.201.0.128/25 (TENANT_VALKEY_ADDRESS_RANGE), so on the instance agent-network-firewall.sh adds two rules to the KEEL-AGENTS chain: 10.201.0.40:5432 and 10.201.0.128/25:6379; everything else from the bridge stays dropped, including port 6379 on other run containers, the platform Postgres, the registry sidecar and the host. Tenants share the cluster and the bridge, so isolation between databases is the Postgres role and REVOKE CONNECT, and between Valkey instances the per-container password. packages/infrastructure/src/data-stores/data-store-provisioners.docker.test.ts proves the path: a service container on an internal network runs psql and valkey-cli against provisioned stores by alias, a second tenant's role and the postgres maintenance database are refused with permission denied for database, provisioning an existing role is refused, an unauthenticated Valkey client gets NOAUTH, the container lands in the reserved address block with the memory limit and without published ports, a container labelled for another organization is left untouched, rotation invalidates the old password and deprovisioning removes the database, the container and the volume.

Backups and restore

deploy/single-host/backup.sh dumps the platform cluster and the tenant cluster (pg_dumpall from tenant-postgres, backups/tenant-postgres-<stamp>.sql.gz) and archives every Valkey volume labelled keel.role=data-store (backups/valkey-<volume>-<stamp>.tar.gz) to the payload bucket under the platform key; the bucket lifecycle keeps 30 days.

Restore one database without touching the others:

aws s3 cp s3://<bucket>/backups/tenant-postgres-<stamp>.sql.gz - | gunzip > /tmp/tenant.sql
grep -n '^\\connect ks_<org16>_<name>' /tmp/tenant.sql
docker compose exec -T tenant-postgres psql -U keel -d postgres -c 'drop database if exists "ks_<org16>_<name>" with (force)' -c 'create database "ks_<org16>_<name>" owner "ks_<org16>_<name>"'
sed -n '/^\\connect ks_<org16>_<name>$/,/^\\unrestrict /p' /tmp/tenant.sql | docker compose exec -T tenant-postgres psql -U keel -d "ks_<org16>_<name>" -v ON_ERROR_STOP=1

The slice ends at the \unrestrict line that closes the database's section. It must not run to the next \connect: pg_dumpall emits the next database's DROP DATABASE and CREATE DATABASE before its \connect, so a longer slice would try to drop the postgres maintenance database while restoring a tenant. Objects come back owned by the tenant role because the dump names it and the role still exists; ON_ERROR_STOP makes a missing role or a half-restored table visible instead of scrolling past.

The role and its password survive in the live cluster, so the stored DATABASE_URL keeps working. If the role itself is gone, keel db rotate <name> recreates it with a fresh password. A Valkey volume is restored by stopping the store's container, extracting the archive into the volume (docker run --rm -v <volume>:/data alpine tar -C /data -xzf -) and starting it again.

Billing

Usage records of kind data_store are settled hourly like services and reported on two Stripe meters, postgres_hours (STRIPE_PRICE_POSTGRES_HOUR, 2.638888888889 cents per hour) and valkey_hours (STRIPE_PRICE_VALKEY_HOUR, 1.250000000000 cents per hour), so a full month of one store lands on the flat price. The rollup, the nightly drift check and the usage subscription carry the two meters next to service_hours. Data store spend is a fixed rate add on that an owner or admin opts into explicitly, so it is shown on GET /v1/organizations/me/usage under dataStores with hours, active count and projected cents per kind but is not part of the spend cap projection that gates runs and services. See Billing.

API, CLI and dashboard

Endpoint Permission
POST /v1/data-stores { name, kind? } data_stores.write
GET /v1/data-stores, GET /v1/data-stores/{id} data_stores.read
DELETE /v1/data-stores/{id} data_stores.write
POST /v1/data-stores/{id}/rotate data_stores.write
POST /v1/data-stores/{id}/connection data_stores.connect (owner, admin; audited)

API keys can hold data_stores:read and data_stores:write; no key scope grants data_stores.connect. The CLI group is keel db create|list|url|rotate|delete (The CLI); the dashboard has a Data stores page with a create dialog, a detail view with size, status, rotate, typed delete and a reveal button (The dashboard).

Fargate

The port is clean for the shared RDS layout in infra/terraform/modules/rds: an RdsDataStoreProvisioner runs the same SQL against the shared instance through the master credentials in Secrets Manager (a security group rule from the run tasks to port 5432 and a private hosted zone alias db.internal replace the bridge and the firewall), and Valkey maps to one ElastiCache Serverless cache per store or an ACL user on a shared cluster. Neither adapter exists yet; on Fargate POST /v1/data-stores answers 501 data_stores_not_configured until they do.

Environment

Variable Meaning
TENANT_POSTGRES_ADMIN_URL Superuser URL of the tenant cluster; enables the Postgres adapter.
TENANT_POSTGRES_HOST, TENANT_POSTGRES_PORT Host and port written into tenant URLs (db.internal, 5432).
TENANT_POSTGRES_CONNECTION_LIMIT, TENANT_POSTGRES_STATEMENT_TIMEOUT_MS Per role connection limit (20) and statement plus idle-in-transaction timeout (60000).
TENANT_VALKEY_IMAGE, TENANT_VALKEY_MAX_MEMORY_MB Image and maxmemory of each Valkey container (valkey/valkey:8.1-alpine, 256).
TENANT_VALKEY_ADDRESS_RANGE Optional IPv4 CIDR block (/16 to /30) inside DOCKER_NETWORK from which Valkey containers get static addresses, so the firewall can open 6379 to that block only. Unset means Docker picks the address (local development). The single host sets 10.201.0.128/25 and keeps run containers in the network's ip_range 10.201.0.0/25.
DATA_STORE_SETTLEMENT_INTERVAL_SECONDS, DATA_STORE_PURGE_INTERVAL_SECONDS API in-process loop intervals (300, 3600).
STRIPE_PRICE_POSTGRES_HOUR, STRIPE_PRICE_VALKEY_HOUR Metered price ids, required when BILLING=stripe.
TENANT_POSTGRES_PASSWORD Single host .env only: the tenant-postgres superuser password the compose file turns into TENANT_POSTGRES_ADMIN_URL.