Three ways in: the dashboard, the zbase CLI,
or the HTTP API directly. All three are the same thing underneath.
Install the CLI once:
cd zbase/cli && sudo npm link
Prefer not to touch the global bin? Run it in place instead —
alias zbase='node /path/to/zbase/cli/index.js'
in your ~/.zshrc does the same job.
Then, from nothing to a working database URL:
zbase login zbase create acme-corp --env # .env.local now contains: # DATABASE_URL=postgres://…@zbase.zavecoder.com:6432/…?sslmode=require
Writing is idempotent — an existing DATABASE_URL is replaced
in place and every other line in the file is preserved. For a project that already
exists, use zbase env <name> --write —
create would fail on the duplicate name.
Prefer clicking? The dashboard does the same thing and needs nothing installed.
| Command | What it does |
|---|---|
zbase login | Browser sign-in; the token is handed back over a loopback port |
zbase login --token <jwt> | Sign in non-interactively |
zbase whoami | Show the signed-in account and org |
zbase list | Projects in your org |
zbase create <name> | Provision a project |
zbase create <name> --env [file] | …and write DATABASE_URL (default .env.local) |
zbase env <name> [--write [file]] | Print or write the connection string |
zbase delete <name> [--yes] | Drop the database and role |
Credentials live in ~/.zbase/config.json (mode 0600).
--host targets a different zBase instance.
zbase login starts a throwaway listener on a random loopback
port and opens the normal browser sign-in; the dashboard then asks you to confirm before
sending the token back, so there is nothing to copy and paste. The hand-off is bound to
that one attempt by a random nonce and an origin check, and requires an explicit click —
so neither a page you happen to have open nor a crafted link can push your token to a
local port you didn't intend.
Connection strings point at PgBouncer on port 6432, which is publicly reachable and requires TLS — plaintext connections are refused outright. It presents the same publicly-trusted certificate as this site, so full verification works with no custom CA bundle:
psql "$DATABASE_URL" # sslmode=require, as issued psql "…?sslmode=verify-full&sslrootcert=system" # full chain verification
sslmode | Result |
|---|---|
disable | Refused — FATAL: SSL required |
require | Works (what the CLI issues) |
verify-full | Works with sslrootcert=system |
Pooling is transaction mode, so session-level features
(LISTEN/NOTIFY, session SET,
advisory locks held across statements) won't behave as they would on a direct connection.
Opening a connection is expensive, and it gets worse the further away you are. Measured against this deployment from a client ~30ms away:
| Operation | Time |
|---|---|
| New connection + one query | ~300ms |
| Query on an already-open connection | ~29ms |
| New connection, measured on the server itself | ~18ms |
The server does its part in 18ms — the other ~280ms is network round trips. The TLS handshake and SCRAM authentication are chatty, costing roughly ten round trips before your first query runs, and every one of those is charged at your latency to Singapore. Reusing a connection skips all of it, which is why the same query drops to a single round trip.
So if zBase feels slower than a hosted provider, this is almost always why, and the fix is in your app rather than on the server.
Create the pool once at module scope and reuse it. Never inside a request handler — that opens and discards a connection per request, paying the full handshake every time.
// db.js — evaluated once per process import pg from 'pg'; export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5, // small on purpose — see below idleTimeoutMillis: 30_000, }); // routes.js import { pool } from './db.js'; app.get('/items', async (req, res) => { const { rows } = await pool.query('select * from items where id = $1', [req.query.id]); res.json(rows); });
Keep max small. PgBouncer is already
multiplexing — it holds the real server connections and hands them out per transaction.
A large client pool doesn't buy throughput, it just parks idle TLS sockets. Each project
gets at most 12 server connections, so oversized client pools across
several instances will queue and eventually hit
query_wait_timeout (20s).
| Where your code runs | What to do |
|---|---|
| Long-running server (Express, Nest, Fastify) | Module-scope pool, max: 5 |
| Next.js route handlers | Module-scope pool, cached on globalThis to survive dev hot-reload |
| Scripts, migrations, cron | Nothing to change — one connection for the whole run |
| Lambda / Vercel functions | Module scope still helps on warm invocations; max: 1 |
| Cloudflare Workers / edge | Use SQL over HTTP — no pool needed |
In Next.js dev, hot-reload re-evaluates modules and leaks a new pool each time. Cache it:
globalThis._pool ??= new pg.Pool(…).
pg runs fine on Workers — v8.16.3 or later, with the
nodejs_compat flag and a compatibility date of
2024-09-23 or later. The problem isn't compatibility,
it's geography plus connection reuse.
This deployment is in Singapore. Workers run at the edge nearest the user, so a visitor in London hits a London isolate that must then reach Singapore — roughly 180ms each way, and the TLS handshake plus SCRAM authentication takes several round trips before a single query runs. Worker isolates are also short-lived and numerous, so the module-scope trick above recovers far less than it does on a long-running server: many requests pay the full handshake.
Use Hyperdrive.
It keeps warm connections pooled regionally, so the handshake is already paid before your
query arrives, and it can cache read queries at the edge. Point it at
zbase.zavecoder.com:6432 like any other client — the pooler is
publicly reachable and presents a publicly-trusted certificate.
Your organization is derived from your email domain — everyone at the same company
lands in the same org, and the first person in owns it. Projects belong to an
org, and every lookup is org-scoped: a project in another org is
indistinguishable from one that doesn't exist, so cross-org access returns
404 rather than 403.
Project names only need to be unique within your org. The underlying Postgres role and
database names carry a random suffix, since those are cluster-wide objects — two orgs
can both have a project called api.
| Action | Who |
|---|---|
| Create a project, list, read a connection string | Any member |
| Delete a project | owner or admin only |
Deleting drops a database irreversibly, which is why it isn't something every member can do by accident. Orgs also carry a project cap, and provisioning is rate limited — each project is a real database on disk.
Creates, deletes, and credential reads are written to an append-only audit log. Reading a connection string decrypts a live password, so it belongs in the trail for the same reason the other two do.
Google sign-in issues a 7-day zBase JWT whose subject is your user ID. Every API call
other than /health and the OAuth routes requires it as a
Bearer token.
Sessions are revocable despite the token being stateless: each one carries the
token version it was minted with, and every request checks that against the
database. An administrator bumping that version signs the user out everywhere
immediately — subsequent requests get 401 TOKEN_REVOKED.
Returns the Google consent URL to redirect to.
Exchanges the OAuth code for a zBase JWT. Rejects emails outside the allowed domains with 403.
{ "token": "<jwt>",
"user": { "id": "<uuid>", "name": "…", "email": "…" },
"org": { "id": "<uuid>", "name": "…", "slug": "…" } }
The caller's profile and org memberships.
curl https://zbase.zavecoder.com/api/auth/me \ -H "Authorization: Bearer <jwt>"
Three conditions, all required:
| Condition | Why |
|---|---|
| Email domain is allowlisted | Gates access to the service |
| Google reports the address as verified | Your organization is derived from your email domain, so an unverified address would be enough to join someone else's org and inherit its databases |
Workspace hd claim agrees, when present |
For Workspace accounts the hosted-domain claim is authoritative and must match the address |
Creates a Postgres role and database owned by your org.
curl -X POST https://zbase.zavecoder.com/api/projects \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"name": "acme-corp"}'
{
"ok": true,
"projectId": "…",
"projectName": "acme-corp",
"region": "local",
"connectionUri": "postgres://zdb_acme_corp_58b0b3:***@zbase.zavecoder.com:6432/zdb_acme_corp_58b0b3",
"project": { "id": "…", "name": "acme-corp" },
"connection_uris": [{ "connection_uri": "…" }],
"databases": [{ "name": "zdb_acme_corp_58b0b3" }],
"roles": [{ "name": "zdb_acme_corp_58b0b3" }]
}
The project / connection_uris /
databases / roles fields exist so
Neon-API client code works against zBase unmodified. Append
?sslmode=require to the URI before using it.
Lists your org's projects. No credentials included.
One project, including its connection string. The password is decrypted on the fly — it is stored encrypted (AES-256-GCM) and never held in plaintext at rest.
Drops the database and role, then soft-deletes the control-plane row. Irreversible.
| Status | Meaning |
|---|---|
| 400 | Missing or invalid input, or provisioning failed (e.g. duplicate name in your org) |
| 401 | Missing, invalid, or expired Bearer token — or the session was revoked (TOKEN_REVOKED) |
| 403 | Email outside the allowed domains, no organization, or role too low for the action |
| 404 | No project by that id or name in your org |
| 429 | Rate limit hit, or the org's project quota is full |
| 500 | Unexpected server error |
Edge runtimes cannot hold a connection pool, so every request pays a full TCP + TLS + SCRAM
handshake — ~300ms against this deployment. POST /sql
holds the pool for you: one HTTP request per query, ~16ms measured end to end.
It is wire-compatible with @neondatabase/serverless, so migrating
from Neon is one line:
// the only change — point the driver at zBase
import { neon, neonConfig } from '@neondatabase/serverless';
neonConfig.fetchEndpoint = () => 'https://zbase.zavecoder.com/sql';
const sql = neon(process.env.DATABASE_URL);
const rows = await sql`select * from items where id = ${id}`;
Or call it directly, with no driver at all:
curl -X POST https://zbase.zavecoder.com/sql \
-H "Neon-Connection-String: $DATABASE_URL" \
-H 'content-type: application/json' \
-d '{"query":"select * from items where id = $1","params":[42]}'
| Header | Effect |
|---|---|
Neon-Connection-String | Required. Your project's connection string |
Neon-Array-Mode | true returns rows as arrays, not objects |
Neon-Raw-Text-Output | true returns every value as a string |
One query per request, and each runs in its own transaction. Multi-statement transactions are not supported — if you need them, keep using the pooler on 6432 from a runtime that can hold a connection. This matters for read-modify-write paths such as inventory or balances, where a lost update is a correctness bug rather than a slow query.
Credentials are supplied per request and never stored: the service holds no secret of its own and can reach nothing you could not already reach. Connections are permitted only to this deployment's pooler. Statements are capped at 30 seconds.
This removes the handshake — it does not move the data. The instance is in Singapore, so a caller in Europe still pays that round trip per query. Hyperdrive can additionally cache reads at the edge and remains the better end state for a globally distributed audience.
Two independent layers. A nightly logical dump of every database at 03:15 UTC, and continuous WAL archiving with a weekly physical base backup, which together allow recovery to any point in time rather than only to the last nightly run.
| Property | Value |
|---|---|
| Recovery point objective | ~5 minutes |
| Nightly logical dump | 03:15 UTC, 3 days local, 90 days off-site |
| Physical base backup | Weekly, 2 retained |
| Off-site | Cloudflare R2, client-side encrypted |
Restores are tested by actually performing them, not assumed: recovery to a target time is verified against markers written either side of that instant.
zdb_control database, separate from the tenant databases it provisions.CREATE/DROP DATABASE) runs on a dedicated maintenance connection, since it can't run inside a transaction or against the database being touched. Drops use WITH (FORCE) because PgBouncer holds pooled connections open.auth_query function, so provisioning never edits PgBouncer config or requires a restart. That lookup runs against the control database — tenant databases revoke CONNECT from everyone but their owner, so it would otherwise be refused.^[a-z][a-z0-9_]{0,62}$ before use in DDL, since identifiers can't be parameterized the way values can.