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": "…" } }
  • kind selects the engine (Postgres or MySQL). provision_database creates the database + user on the platform’s host engine, encrypts the password, and — because attachServiceId is set — auto-creates a DATABASE_URL runtime env var on that service. Omit attachServiceId to provision unattached and wire it up later.
  • reveal_database_credentials returns 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_service write or remove DATABASE_URL on 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_database show 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 injected DATABASE_URL does not include it, so append it where your app builds the datasource URL. Migrations need a direct session connection: set directUrl in schema.prisma to a second env var (e.g. DIRECT_DATABASE_URL) holding the same URL on port 5432, and prisma migrate will use that instead of the pooler.
  • Session state (RLS, SET, advisory locks). A plain SET 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 = … or select set_config('app.tenant_id', $1, true) inside BEGIN … 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:

  1. add_database_allowlist with a cidr (typically a /32) and optional ttlHours. 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.
  2. reveal_database_public_credentials returns credentials with the public hostname. The URL only connects from an allowlisted IP.
  3. list_database_allowlist / remove_database_allowlist manage 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_service inject 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_credentials returns 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_buckets shows 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).

  1. Poll list_redis until the instance reports status='ready'.
  2. reveal_redis_credentials returns host/port/password and the full REDIS_URL (redis://:<password>@…:<port>/0) — only once ready.
  3. attach_redis_to_service / detach_redis_from_service inject or remove REDIS_URL (idempotent; instance must be ready).

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.