Database
Create and manage Postgres databases with ccp db.
Database
Cluster provides managed Postgres databases running in isolated microVMs. Each database gets its own VM with a SQL-over-HTTP proxy for secure access.
Create a Database
ccp db create --name my-app-db
# ✓ Created database my-app-db
#
# ID db-c1cf9a9be667
# Host db-c1cf9a9be667.clusterbase.dev
# Token a9db7b185e40...
#
# ✓ Token saved to .cluster/config.jsonIf you run this from a project directory with a .cluster/config.json, the database credentials (DATABASE_URL and DATABASE_TOKEN) are automatically injected as environment variables into your linked function.
List Databases
ccp db list
# • my-app-db ready db-c1cf9a9be667
# db-c1cf9a9be667.clusterbase.devShow Database Details
ccp db info db-c1cf9a9be667
# Database my-app-db
#
# ID db-c1cf9a9be667
# Status ready
# Host db-c1cf9a9be667.clusterbase.dev
# Database default
# User clusterbaseDelete a Database
ccp db destroy db-c1cf9a9be667
# Delete database db-c1cf9a9be667? This will destroy all data. (y/N)Use -y to skip the confirmation prompt.
Interactive SQL Shell
Open an interactive SQL session with ccp db connect:
ccp db connectThis opens a terminal UI with a SQL prompt. Type queries ending with ; to execute them:
╭──────────────────────────────────────────────────╮
│ >_ ccp db connect (db-c1cf9a9be667) │
│ │
│ database: default │
│ host: db-c1cf9a9be667.clusterbase.dev │
╰──────────────────────────────────────────────────╯
› SELECT * FROM users;
id │ name │ email
───┼───────┼──────────────
1 │ Alice │ alice@co.com
(1 row)
43ms
› Enter SQL (end with ;)Meta-commands
| Command | Description |
|---|---|
\dt | List all tables |
\d <table> | Describe a table (columns, types) |
\q | Quit |
Navigation
- Up/Down arrows — cycle through query history
- Ctrl+D or Ctrl+C — exit (clears input if non-empty)
One-Shot SQL
Execute a single SQL statement without opening the interactive shell:
ccp db exec "SELECT count(*) FROM users;"
#
# count
# ─────
# 42
#
# (1 row)
#
# 38msWorks for any SQL — SELECT, INSERT, UPDATE, DELETE:
ccp db exec "INSERT INTO users (name, email) VALUES ('Bob', 'bob@co.com');"
#
# 1 row affected · 45msStatements with RETURNING clauses display the returned rows:
ccp db exec "DELETE FROM users WHERE id = 1 RETURNING *;"Migrations
Run SQL migration files against your database with ccp db migrate. Migrations are tracked in a _ccp_migrations table so they only run once.
Setup
Create a migrations/ directory in your project with numbered .sql files:
migrations/
001_create_users.sql
002_add_posts.sql
003_add_indexes.sqlEach file can contain multiple SQL statements. PL/pgSQL functions with $$ dollar-quoting are fully supported.
Run Migrations
ccp db migrate
# › Running 3 pending migrations...
#
# ✓ 001_create_users.sql 48ms
# ✓ 002_add_posts.sql 37ms
# ✓ 003_add_indexes.sql 42ms
#
# ✓ 3 migrations appliedRunning again is safe — only pending migrations are applied:
ccp db migrate
# ✓ All 3 migrations already appliedCheck Status
See which migrations have been applied without running anything:
ccp db migrate --status
# › Migration status:
#
# ✓ 001_create_users.sql
# ✓ 002_add_posts.sql
# ○ 003_add_indexes.sql
#
# 1 pending migrationOptions
| Flag | Description |
|---|---|
--dir <PATH> | Migrations directory (default: ./migrations) |
--db-id <ID> | Database ID (reads from .cluster/config.json if omitted) |
--token <TOKEN> | Database token (reads from .cluster/config.json if omitted) |
--status | Show migration status without applying |
If you provide --db-id, you must also provide --token (and vice versa).
How It Works
Each migration runs in a database transaction. The migration SQL and the tracking insert are atomic — if the migration fails, nothing is committed. Migrations are applied in filename order, so use a numeric prefix (001_, 002_, etc.) to control the sequence.
Backups
Databases are backed up automatically once per day and backups are retained for 7 days. You can also create manual backups on demand.
Create a Backup
ccp db backup create
# ✓ Backup created
# ID bkp-8f3e2a1c
# Created 2026-04-12 14:22 UTCUse --db-id <ID> to target a database other than the one in your .cluster/config.json.
List Backups
ccp db backup list
# • bkp-8f3e2a1c manual 2026-04-12 14:22 UTC
# • bkp-7d1f9e4b scheduled 2026-04-11 00:00 UTC
# • bkp-6c9a2d7e scheduled 2026-04-10 00:00 UTCls is an alias for list.
Restore from a Backup
ccp db backup restore bkp-8f3e2a1c
# Restore database db-c1cf9a9be667 from bkp-8f3e2a1c? This will overwrite current data. (y/N)Use -y / --yes to skip the confirmation prompt.
Delete a Backup
ccp db backup delete bkp-8f3e2a1crm is an alias for delete.
Querying from Handlers
Access your database from serverless functions using the SQL-over-HTTP proxy:
const res = await fetch(`https://${process.env.DATABASE_ID}.clusterbase.dev/query`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.DATABASE_TOKEN}`,
},
body: JSON.stringify({
sql: "SELECT * FROM users WHERE id = $1",
params: [42],
}),
});
const { rows } = await res.json();Endpoints
| Endpoint | Method | Description |
|---|---|---|
/query | POST | Execute a SELECT query, returns { rows, fields } |
/execute | POST | Execute INSERT/UPDATE/DELETE, returns { rows_affected } |
/transaction | POST | Execute multiple statements atomically |
Request Format
{
"sql": "SELECT * FROM users WHERE id = $1",
"params": [42]
}Parameters use $1, $2, etc. for positional binding.
Transaction Format
{
"statements": [
{ "sql": "INSERT INTO users (name) VALUES ($1)", "params": ["Alice"] },
{ "sql": "INSERT INTO logs (action) VALUES ($1)", "params": ["user_created"] }
]
}Client-Mode Authentication
By default, the database accepts only the master token (DATABASE_TOKEN) and runs every query as the privileged clusterbase role. That's fine for server-side handlers, but it means you can't safely expose the db-proxy host directly to browsers or scope queries per user without manual WHERE user_id = $N filters.
Client-mode opens two additional auth paths on the same database:
- Anon — requests with no
Authorizationheader are allowed and run as the read-onlyanonPostgres role. - JWT — requests with a
Bearer <JWT>header are validated against your OIDC issuer; the JWT'ssubis exposed inside Postgres viaauth.uid(), and the query runs as theauthenticatedrole.
The master token continues to work alongside both. Row-Level Security policies then scope rows per user via auth.uid().
Client-mode is off at create time and can be toggled on or off at any point without redeploying or recreating the database.
Enable Client-Mode
ccp db client-access enable
# ✓ client-access enabled on db-c1cf9a9be667This restarts the db-proxy in place (~3 seconds). In-flight queries fail and should be retried; the master path resumes immediately after restart. Run with no arguments to target the DB linked in .cluster/config.json, or pass ccp db client-access enable <db-id> to target another DB.
Disable Client-Mode
ccp db client-access disable
# ✓ client-access disabled on db-c1cf9a9be667Closes the anon and JWT paths. The master token continues to work.
Both commands are idempotent — calling enable on an already-enabled DB is a no-op success and does not restart the proxy.
Anon Queries
Once enabled, requests with no Authorization header run as the anon role:
const res = await fetch(`https://${process.env.DATABASE_URL}/query`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sql: "SELECT * FROM public_posts", params: [] }),
});By default anon has no permissions — grant only what you want exposed publicly:
GRANT SELECT ON public_posts TO anon;JWT Queries
Forward a user's Cluster OIDC access token as the bearer (see Sign in with Cluster for how to obtain one):
const res = await fetch(`https://${process.env.DATABASE_URL}/query`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${userJWT}`,
},
body: JSON.stringify({ sql: "SELECT * FROM todos", params: [] }),
});The query runs as the authenticated role with auth.uid() populated from the JWT's sub claim. The JWT is validated against the OIDC issuer's JWKS — invalid, expired, or wrong-issuer tokens return 401.
Row-Level Security with auth.uid()
The typical pattern: enable RLS on user-owned tables and scope rows by the authenticated user's sub.
CREATE TABLE todos (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL
);
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
CREATE POLICY todos_owner ON todos
FOR ALL TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
GRANT SELECT, INSERT, UPDATE, DELETE ON todos TO authenticated;With this policy, SELECT * FROM todos returns only the calling user's rows even with no WHERE clause, and inserts or updates referencing other users' rows are rejected.
Roles
| Role | Used when | Defaults |
|---|---|---|
clusterbase | Master token (DATABASE_TOKEN) | Owner of all objects; bypasses RLS |
authenticated | Valid JWT | Subject to RLS; grant tables explicitly |
anon | No Authorization header | Subject to RLS; grant tables explicitly |
Requirements
- The DB must be running. Paused DBs return 409; wake the DB with any query first, then retry the toggle.
- The toggled state persists across pause/resume and across db-proxy restarts — you don't need to re-enable after a wake.
Aliases
| Command | Alias |
|---|---|
ccp db list | ccp db ls |
ccp db destroy | ccp db rm |