Provisioning data resources
Databases, object storage, and Redis all follow the same three-beat pattern: provision → attach → reveal. Provisioning creates the resource inside your project; attaching wires its connection details into a service as env vars; revealing hands you the raw credentials when a tool or a human needs them directly.
The env-var key names are a stable contract — a value can change if a resource is migrated to a different backend, but the key never does. Injected vars take effect on the service’s next deploy or restart.
Databases (Postgres / MySQL)
{ "name": "provision_database",
"arguments": { "projectId": "…", "kind": "postgres", "attachServiceId": "…" } }
kindselects the engine (Postgres or MySQL).provision_databasecreates the database + user on the platform’s host engine, encrypts the password, and — becauseattachServiceIdis set — auto-creates aDATABASE_URLruntime env var on that service. OmitattachServiceIdto provision unattached and wire it up later.reveal_database_credentialsreturns the full bundle (host, port, db name, user, password, connection URL) using the internal docker-bridge host that services inside the platform use. Every reveal is audit-logged.attach_database_to_service/detach_database_from_servicewrite or removeDATABASE_URLon any service in the database’s project (the portal’s Attach / Detach buttons). Attach is idempotent; detaching keeps the database and its data.list_databases/get_databaseshow which services each database is attached to.
Connection pooling (PgBouncer)
Where the platform runs a PgBouncer pooler for tenant Postgres, the injected DATABASE_URL targets the pooler port (6432) instead of Postgres directly (5432). PgBouncer runs in transaction pooling mode: a server connection is yours only for the duration of one transaction. MySQL is never pooled.
DATABASE_URL is written at attach time, so a service attached before the pooler was enabled still points at 5432. To move it onto the pooler, re-attach:
{ "name": "attach_database_to_service",
"arguments": { "databaseId": "…", "serviceId": "…" } }
The response reports alreadyAttached: true, pooled: true and injectedPort: 6432. Then restart_service (or redeploy) so the container picks up the new URL. Nothing else changes: same database, same credentials.
Two things to check in your app before switching:
- Prisma. The runtime URL needs
?pgbouncer=true(it disables prepared statements, which transaction pooling can’t carry across connections). The injectedDATABASE_URLdoes not include it, so append it where your app builds the datasource URL. Migrations need a direct session connection: setdirectUrlinschema.prismato a second env var (e.g.DIRECT_DATABASE_URL) holding the same URL on port 5432, andprisma migratewill use that instead of the pooler. - Session state (RLS,
SET, advisory locks). A plainSET app.tenant_id = …sticks to a server connection that the next transaction may not get — or that another client’s transaction will. Scope per-request settings to the transaction:SET LOCAL app.tenant_id = …orselect set_config('app.tenant_id', $1, true)insideBEGIN … COMMIT. The same applies to session-level advisory locks,LISTEN, temp tables and named prepared statements.
To go back to a direct connection, set DATABASE_URL yourself with set_env_var (port 5432) — a re-attach always writes the pooled form while the pooler is enabled.
Reaching a database from outside the platform
For local development or an external tool, use the public host and an IP allowlist:
add_database_allowlistwith acidr(typically a/32) and optionalttlHours. This adds a rate-limited firewall rule plus an engine-level grant scoped to this DB’s role from this source — cross-tenant probes are refused at the protocol level.reveal_database_public_credentialsreturns credentials with the public hostname. The URL only connects from an allowlisted IP.list_database_allowlist/remove_database_allowlistmanage the entries.
Object storage (S3-compatible buckets)
{ "name": "create_bucket",
"arguments": { "projectId": "…", "label": "uploads", "attachServiceId": "…" } }
create_bucket mints scoped credentials and, when attachServiceId is set, injects six runtime env vars: S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE.
attach_bucket_to_service/detach_bucket_from_serviceinject or remove those six vars on any service (idempotent). Detaching keeps the bucket and its objects; only the service’s access goes away.reveal_bucket_credentialsreturns the full credential bundle for an unattached service, a local dev env, or a tool that doesn’t read env vars. Audit-logged; the secret grants full read/write.list_bucketsshows buckets in a project and which services each is attached to.
Browser uploads (CORS)
If a browser app makes direct pre-signed GET/PUT requests to a bucket, set a CORS policy:
{ "name": "set_bucket_cors",
"arguments": { "bucketId": "…", "rules": [
{ "allowedOrigins": ["https://app.example.com"],
"allowedMethods": ["GET", "PUT", "HEAD"] } ] } }
set_bucket_cors replaces the whole policy (it is not a merge). get_bucket_cors reads the current rules. CORS is enforced by the browser only — it is not access control. A private bucket stays private; every request still needs a signed URL. Avoid "*" origins in production.
Serving public files through a CDN
Public images and downloads (for example a news site’s article photos) can be served from edge caches close to readers instead of from the bucket’s origin. enable_bucket_cdn gives the bucket a CDN hostname Tandem controls; purge_bucket_cdn evicts stale copies after an overwrite. See Bucket CDN for the hostname options, the public-read rule, and caching behaviour.
Redis / Valkey
Provisioning is asynchronous:
{ "name": "provision_redis",
"arguments": { "projectId": "…", "tier": "default", "attachServiceId": "…" } }
provision_redis returns immediately with status='provisioning'. tier sets memory: small = 128MB, default = 256MB, large = 1024MB. It is subject to the deploy gate and a host RAM budget (returns quota_exceeded when full).
- Poll
list_redisuntil the instance reportsstatus='ready'. reveal_redis_credentialsreturns host/port/password and the fullREDIS_URL(redis://:<password>@…:<port>/0) — only onceready.attach_redis_to_service/detach_redis_from_serviceinject or removeREDIS_URL(idempotent; instance must beready).
Instances are durable by default (AOF persistence) and reachable from the project’s services over the internal network.
Deleting
Deletes are destructive and mostly irreversible — see Deleting resources for delete_database, delete_bucket, and delete_redis and exactly what each removes vs preserves.