Reference
Self-hosting
The whole of dabadata runs on your own machine or server with one command: the API, the viewer, this site, these docs and the live demo, on one port, over a Postgres database that is yours. All you need is Docker, or Bun and Postgres without it. The examples on this page use http://localhost:4600.
What you get
docker compose up starts two containers: postgres (Postgres 16, data in a named volume) and livedb (the server, built from the repository; the service, user, database and volume keep the product's earlier name). Everything is served from port 4600:
| Path | What |
|---|---|
/v1 | The HTTP API and the live WebSocket at /v1/live. |
/ | The viewer: tables, trace and the rules simulator. Open it with a view token, /?token=<view token> (how to get one). |
/www/ | The public site: home, why dabadata, pricing. |
/docs/ | These docs. |
/examples/demo/ | The live demo: two people on one database, with the trace underneath. Needs DABADATA_DEMO=1. |
/examples/todo/ | The shared to-do list example. |
/client/dist/ | The JavaScript client, dabadata.js (ES module) and dabadata.iife.js (a plain script tag). |
Quick start
You need Docker with Compose (Docker Desktop, or Docker Engine with the compose plugin) and git.
git clone <the dabadata repository> dabadata
cd dabadata
docker compose up --buildThe first build takes a minute or two. When the log says dabadata server on ..., open:
- http://localhost:4600/examples/demo/ - the live demo
- http://localhost:4600/www/ - the site, with these docs at
/docs/
Add -d to run it in the background; docker compose logs -f livedb follows the server's log and docker compose down stops both containers and keeps the data.
The server applies projects/default.ts at every start: the demo's tasks and announcements tables. To run your own tables, edit that file (see The default project, Tables and Rules) and run docker compose up --build again.
Configuration
Every setting has a default, so no .env file is needed. To change one, put it in a .env file next to docker-compose.yml (Compose reads it by itself) or set it in the shell before docker compose up:
| Key | Default | Meaning |
|---|---|---|
POSTGRES_PASSWORD | livedb | Password of the livedb database user. Compose passes the same value to both containers. Set it before the first start: Postgres keeps the password it was created with. Use letters and digits, since it also goes into a URL. |
DABADATA_PROJECT_KEY | change-me-please | The project's secret. Your own server uses it to mint user tokens and view tokens, and anything sent with it bypasses the rules. A new value takes effect at the next start. |
DABADATA_DEMO | 1 | 1 turns on POST /v1/demo/tokens, which the live demo needs. 0 removes that route. |
The compose file sets the server's own keys for you. They are the keys of .env.example, which is what a server run without Docker reads (The .env file):
| Key | In Docker | Meaning |
|---|---|---|
DATABASE_URL | postgres://livedb:<POSTGRES_PASSWORD>@postgres:5432/livedb | The database the server uses: the postgres container. |
TEST_DATABASE_URL | not set | Used by bun test only; the image does not run tests. |
PORT | 4600 | HTTP and WebSocket port inside the container. To use another port on the host, change the left side of ports, for example "8000:4600". |
DABADATA_HOST | 0.0.0.0 | The interface the server binds. Inside a container it must be every interface; without Docker the default is 127.0.0.1. |
DABADATA_PROJECT_NAME | default | The server's own project, the default project; its definition is projects/<name>.ts. |
DABADATA_PROJECT_KEY | from above | See above. |
DABADATA_DEMO | from above | See above. |
A server without DABADATA_PROJECT_KEY has no default project: it logs no default project, serves only hosted projects, answers a call that names no project (no key, no token) with 401, and answers POST /v1/demo/tokens with 404 even in demo mode. Compose always sets the key, so this only happens without Docker.
The livedb service sets these explicitly and does not load a .env file into the container, so a .env left over from running dabadata without Docker cannot point it at another database. Only the three keys in the first table are read from it. Postgres has no published port; only the livedb container reaches it.
# .env next to docker-compose.yml, on a server
POSTGRES_PASSWORD=<a long random value>
DABADATA_PROJECT_KEY=<another long random value>
DABADATA_DEMO=0More settings
Every DABADATA_ setting on this page is also read under its earlier name, LIVEDB_ plus the same suffix, so an existing .env keeps working; when both are set, DABADATA_ wins.
These keys are read by the server too; .env.example documents every one. Without Docker, put them in the repo .env. With Docker, the five marked compose are passed to the livedb service from the shell or a .env file next to docker-compose.yml (empty means off); for the others, add them to that service's environment in docker-compose.yml.
| Key | Default | Meaning |
|---|---|---|
DABADATA_ADMIN_KEY (compose) | empty | Turns on the admin API for hosting several projects. At least 32 characters; anything shorter leaves it off (with a warning in the log). Empty = off, and every /v1/admin/* path answers 404. |
DABADATA_SECRETS_KEY (compose) | empty | Encrypts project secrets: base64 of exactly 32 random bytes, for example the output of openssl rand -base64 32. Missing or malformed = a warning at start, the secrets routes answer 503 secrets_unavailable, and identity providers that use a secret verify nothing. |
DABADATA_BRAND (compose) | dabadata | The display name GET /v1/meta reports, and the name in mails, the hosted sign-in pages and the MCP server. |
DABADATA_TRUST_PROXY (compose) | 0 | 1 = take the client IP for the per-IP rate limits from the first X-Forwarded-For entry. Set it only behind your own reverse proxy, which overwrites that header; otherwise any client can claim any IP. 0 = the socket address. |
DABADATA_TRACE_DAYS (compose) | empty | How many days of trace the nightly job keeps for the default project. Empty = keep everything. Hosted projects follow their plan. |
DABADATA_LOG_DAYS | 1 | How many days of API request and realtime log the nightly job keeps for the default project. Hosted projects follow their plan's logDays. |
DABADATA_SQL_ROLES | on | 0 turns the console's SQL page off (its routes answer 404), for a database user that may not create roles. Otherwise the SQL console is on whenever DABADATA_SECRETS_KEY (else DABADATA_ADMIN_KEY) is set: each environment gets two Postgres roles, <schema>_ro and <schema>_rw, with passwords derived from that key. |
DABADATA_PUBLIC_URL | http://127.0.0.1:<PORT> | The server's public address, for example https://db.example.com. Every mailed link (console sign-in, invitations, your users' sign-in pages at /auth/<project>/), the MCP widgets' address and the OAuth metadata start with it. With https the session cookie is Secure. |
RESEND_API_KEY, MAIL_FROM | empty | Both set = mail goes out through Resend from MAIL_FROM (for example dabadata <[email protected]>). Otherwise the server writes every mail's subject and link to its log - fine on your own machine, never silent. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | empty | Both set = "Continue with Google" on the console's sign-in page. The OAuth client's redirect URI is <DABADATA_PUBLIC_URL>/v1/auth/google/callback. |
DABADATA_TEST_MAIL_DOMAIN, DABADATA_TEST_KEY | empty | For end-to-end tests: mail to @<domain> is never sent but kept in memory and read at GET /v1/test/mail with X-Test-Key. Both must be set and the key at least 32 characters; otherwise the route answers 404 and the domain is an ordinary one. |
DABADATA_MAX_WORKSPACES_PER_ACCOUNT | 3 | How many workspaces one console account may own, the one made at sign-up included. |
DABADATA_BETA_MAX_PLAN | starter | With billing off, the highest plan an owner may pick in the console (free, starter, pro, scale). The admin API sets any plan. |
DABADATA_CONSOLE_SIGNUP | open | Sign-up of new console accounts (not your apps' users). waitlist: an address without an account that confirms itself (link, Google, GitHub) goes on a waitlist instead; existing accounts, invited addresses and addresses approved with POST /v1/admin/waitlist/approve get in. GET /v1/admin/waitlist lists it. Any other value counts as waitlist. |
Behind a reverse proxy
To serve dabadata under your own domain with HTTPS, put a reverse proxy in front of port 4600. With the proxy on the same machine, publish the port on loopback only by changing ports to "127.0.0.1:4600:4600". An nginx server block:
server {
listen 443 ssl;
server_name db.example.com;
# ssl_certificate and ssl_certificate_key as for any site
# The live WebSocket: pass the upgrade on, keep quiet sockets open.
location /v1/live {
proxy_pass http://127.0.0.1:4600;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
location / {
proxy_pass http://127.0.0.1:4600;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}The API is at /v1 and the pages link to each other with absolute paths (/docs/, /www/, /examples/), so host dabadata at the root of a domain or a subdomain, such as db.example.com, not under a path prefix such as example.com/dabadata/. The client and the demo switch to wss:// by themselves when the page is served over HTTPS.
The block above sets X-Forwarded-For. If the server hosts projects, set DABADATA_TRUST_PROXY=1 so their per-IP rate limits see the real client address rather than the proxy's.
Your data
Everything dabadata keeps is in one plain Postgres database named livedb: its own tables (the project, the event log and mirrored records, tokens, the trace) in the public schema, and your declared tables in a schema named p_<project>, so p_default here. Each environment of a hosted project has a schema of its own, p_<12 characters> for live and the same with _test for test. You can read it with any Postgres tool. The data lives in the livedb-pgdata volume and survives restarts, rebuilds and docker compose down; only docker compose down -v deletes it.
A backup is one pg_dump; there is nothing else to back up:
# back up
docker compose exec -T postgres pg_dump -U livedb livedb > dabadata-backup.sql
# restore on a fresh volume, before dabadata first starts
docker compose up -d postgres
docker compose exec -T postgres psql -U livedb livedb < dabadata-backup.sql
docker compose up -dOperating several projects
One server can host many projects beside its default one, each with a prj_ id, live and test environments, and its own keys - the projects described on Projects and keys, with the limits of Plans and limits. They are created and removed through the admin API, an operator tool: the people who build on a project never need it. Turn it on with DABADATA_ADMIN_KEY (and set DABADATA_SECRETS_KEY so projects can store secrets). Every admin call sends the admin key as a Bearer token; a missing or wrong key is a 401.
Create a workspace
A workspace holds projects and carries the plan: free (the default), starter, pro, scale or enterprise.
curl -X POST http://localhost:4600/v1/admin/workspaces \
-H "Authorization: Bearer $DABADATA_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Example shop","plan":"starter"}'{
"workspace": {
"id": "ws_example12345", "name": "Example shop", "slug": "example-shop", "plan": "starter",
"planLimits": null, "limits": { "plan": "starter", "projects": 5, "connections": 200, ... },
"createdAt": "2026-10-15T09:00:00.000Z"
}
}GET /v1/admin/workspaces lists them. PATCH /v1/admin/workspaces/:id takes name, plan and planLimits: an object that overrides single limits of the plan, each a non-negative whole number or null for unlimited, for example {"planLimits":{"connections":5000}}; "planLimits": null clears the overrides. A change applies at once (within 30 seconds on other server processes).
Create a project
Creating a project creates both environments, their schemas and their keys, and, when you pass a definition (the JSON form), applies it to both as version 1:
curl -X POST http://localhost:4600/v1/admin/projects \
-H "Authorization: Bearer $DABADATA_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"workspaceId":"ws_example12345","title":"Shop"}'{
"project": { "id": "prj_example12345", "title": "Shop", "workspaceId": "ws_example12345",
"region": "il-central-1", "status": "active", "createdAt": "2026-10-15T09:00:00.000Z",
"envs": { "live": { ... }, "test": { ... } } },
"keys": {
"live": { "publishable": "pk_live_EXAMPLEexample0000000000", "secret": "sk_live_EXAMPLEexample00000000000000000000000000" },
"test": { "publishable": "pk_test_EXAMPLEexample0000000000", "secret": "sk_test_EXAMPLEexample00000000000000000000000000" }
}
}This is the only time the secret keys are shown. Only their hashes are stored, so hand the four keys and the project id to the project's owner straight away, over a channel you would trust with a password. A secret key that is lost cannot be shown again, but a member of the project's workspace (developer or above) gets a new one at any time by pressing Rotate secret key on the console's Keys page.
| Status | Meaning |
|---|---|
400 | A missing title, or a definition that does not validate (its messages start with definition: ). A definition with a secret issuer cannot be applied here, since the project has no secrets yet: create the project without it, store the secret, then PUT /v1/schema. |
403 | { "error": "plan_limit", "limit": "projects", "message" }: the workspace has all the projects its plan allows. Projects being deleted do not count. |
404 | No such workspace. |
GET /v1/admin/projects lists every hosted project, or one workspace's with ?workspaceId=.
Delete and restore
# schedule deletion: 410 project_deleted from now on, gone for good after 7 days
curl -X DELETE http://localhost:4600/v1/admin/projects/prj_example12345 \
-H "Authorization: Bearer $DABADATA_ADMIN_KEY"
# undo it, any time within those 7 days
curl -X POST http://localhost:4600/v1/admin/projects/prj_example12345/restore \
-H "Authorization: Bearer $DABADATA_ADMIN_KEY"Both answer { "project" }. Deleting sets both environments to deleting, closes their sockets and sets the deletion date 7 days ahead; the nightly job then drops both schemas and every row of both environments (their usage history stays). Restoring sets both back to active and does not check the plan's project allowance.
Run the nightly job
The server runs its nightly job by itself at 03:30 UTC, and at start when the last run is more than 25 hours old. To run it now - after changing a plan, or to test - call:
curl -X POST http://localhost:4600/v1/admin/jobs/nightly -H "Authorization: Bearer $DABADATA_ADMIN_KEY"{
"ok": true,
"report": {
"startedAt": "2026-10-15T09:50:00.000Z", "finishedAt": "2026-10-15T09:50:00.214Z",
"steps": {
"dbBytes": { "ok": true, "environments": 3, "bytes": 1867776, "ms": 41 },
"limitState": { "ok": true, "changed": 0, "over": [], "ms": 6 },
"retention": { "ok": true, "trace": 120, "viewTokens": 1, "userTokens": 14, "refreshTokens": 2, "usageDays": 0, "ms": 18 },
"idlePause": { "ok": true, "paused": [], "ms": 3 },
"deletion": { "ok": true, "projects": [], "ms": 1 },
"demoReset": { "ok": true, "skipped": "demo mode is off", "ms": 0 }
}
}
}A step that fails is ok: false with an error, and the other steps still run. With DABADATA_DEMO=1 the job also cleans up the demo: it deletes demo rooms idle for 2 hours (a sweep that also runs as visitors start new ones), then resets the default project - empties its tables, inserts the rows of projects/<name>.seed.json when that file exists, and deletes its trace and tokens. Several server processes on one database share the job, so it runs once.
Sites
Sites are served by a Cloudflare Worker from object storage, not by the dabadata server, so they need some Cloudflare set-up of your own. They are on when file storage is on (the four DABADATA_FILES_R2_* settings) and every one of these is set; otherwise every sites route answers 404 and the console hides the Sites page.
| Key | Default | Meaning |
|---|---|---|
DABADATA_SITES_DOMAIN | dabadata.site | The domain sites live under: <name>.<domain>. Use a registrable domain of its own, never a subdomain of the server's, so a site cannot set cookies for the console or the API. |
DABADATA_SITES_CNAME_TARGET | sites.dabadata.site | The hostname owners point their custom domains at. |
DABADATA_SITES_R2_BUCKET | empty | The R2 bucket for site files, apart from file storage's. The DABADATA_FILES_R2_* account and keys must cover it. |
DABADATA_SITES_CF_ACCOUNT_ID | empty | The Cloudflare account. |
DABADATA_SITES_CF_ZONE_ID | empty | The sites domain's zone, with Cloudflare for SaaS on and the CNAME target as its fallback origin. |
DABADATA_SITES_CF_API_TOKEN | empty | A token with SSL and Certificates edit and Custom Hostnames edit on that zone, and Workers KV Storage edit on the account. |
DABADATA_SITES_KV_NAMESPACE_ID | empty | The Workers KV namespace that maps hostnames to deploys. |
Deploy the Worker in edge/sites/ with Wrangler, bound to the R2 bucket and the KV namespace, on the routes of the sites zone (which also carry every custom hostname through the SaaS fallback). The server writes the KV entries and creates the custom hostnames through the Cloudflare API; the Worker only reads.
Updating
git pull && docker compose up --build -dThe new image replaces the old container; the data volume stays. Schema changes need no step of their own: at every start the server creates its own tables if they are missing and applies projects/<name>.ts, the same as a schema reload. Applying only adds, so an update never drops a column or a row.
Without Docker
dabadata needs nothing beyond Bun and Postgres, so you can run it straight from a checkout of the repository:
- Bun 1.3 - runs the server, the tests and the MCP server.
- Postgres - dabadata is developed against Postgres 16. Create two empty databases: one for the server and one for the tests.
- Optional: PHP, MariaDB and WP-CLI for the local WordPress test site.
From the repository root:
cp .env.example .env # fill DATABASE_URL, the project key etc.
site/setup.sh # once, optional: installs WordPress + activates the plugin
scripts/dev.sh # server on 4600, WordPress on 8080
scripts/stop.sh # stops bothscripts/dev.sh builds the viewer the first time, starts the server in the background (its log goes to temp-screenshots/server.log) and starts WordPress if it is installed. To run only the server in the foreground:
cd server && bun install && bun run startStarted that way, build the viewer and console (cd inspect && bun install && bun run build) and the client once first. The server listens on 127.0.0.1 at PORT; set DABADATA_HOST=0.0.0.0 only if it must be reachable from other machines. Check that it is up:
curl http://localhost:4600/v1/health
# {"ok":true}Run the checks
cd server && bun run typecheck && bun test # uses the test database
cd client && bun run typecheck && bun test && bun run build
cd mcp && bun run typecheck
cd inspect && bun run typecheck && bun run buildThe .env file
The server reads .env at the repository root, whatever the working directory. A real environment variable wins over the file. .env.example documents every key:
| Key | Example | Meaning |
|---|---|---|
DATABASE_URL | postgres://user:[email protected]:5433/dabadata | The database the server uses. |
TEST_DATABASE_URL | postgres://user:[email protected]:5433/dabadata_test | Used by bun test only; its name must contain dabadata_test. The tests leave it clean. |
PORT | 4600 | HTTP and WebSocket port. |
DABADATA_PROJECT_NAME | default | The server's own project, the default project. Its definition is projects/<name>.ts. |
DABADATA_PROJECT_KEY | change-me | The project's secret. Anything sent with it bypasses the rules. Empty = no default project (the server then serves hosted projects only). |
DABADATA_DEMO | 0 | 1 turns on the public demo's token endpoint, POST /v1/demo/tokens, which hands out demo tokens without auth. Keep it 0 except on a throwaway demo project. |
On start the server creates its own tables if they are missing and registers the project named DABADATA_PROJECT_NAME with DABADATA_PROJECT_KEY. The other keys of .env.example - DABADATA_ADMIN_KEY, DABADATA_SECRETS_KEY, DABADATA_BRAND, DABADATA_TRUST_PROXY, DABADATA_TRACE_DAYS - are optional and described under More settings.
The default project
A self-hosted server has one project of its own, the default project, defined in a file and guarded by a single project key (DABADATA_PROJECT_KEY), which takes the place of a hosted project's secret key. Calls to it need no publishable key.
Define a project
A project is a TypeScript file named after it. This is projects/default.ts, the demo's shared task list:
import { defineProject, t } from "../server/src/define";
// The demo project: a shared task list.
export default defineProject({
name: "default",
roles: ["admin", "editor", "viewer"],
tables: {
tasks: {
columns: {
title: t.text(),
done: t.bool({ default: false }),
owner: t.userId(),
due: t.date({ optional: true }),
notes: t.json({ optional: true }),
},
rules: {
read: "authenticated",
create: { role: ["admin", "editor"] },
update: { any: [{ role: "admin" }, { owner: "owner" }] },
delete: { role: "admin" },
},
},
announcements: {
columns: {
text: t.text(),
author: t.userId(),
},
rules: {
read: "anyone",
create: { role: "admin" },
update: { role: "admin" },
delete: { role: "admin" },
},
},
},
});Read it out loud and it is the policy: anyone signed in can read tasks, admins and editors can create them, an admin or the task's owner can update one, only admins delete. Announcements are public to read and admin-only to change. Tables and Rules cover every option.
Reload the schema
The server applies the file when it starts. After you edit it, ask the running server to apply it again:
curl -X POST http://localhost:4600/v1/schema/reload \
-H "Authorization: Bearer <project key>"{ "ok": true, "applied": ["add column tasks.due"], "drift": [] }applied lists what was created this time; drift lists anything in the database the file no longer describes. An invalid file is answered with a 400 and every problem it found, and the previous definition stays in force.
Mint a user token
End users never see the project key. Your backend signs the person in its own way, then asks dabadata for a token that carries their id, name and role:
curl -X POST http://localhost:4600/v1/users/token \
-H "Authorization: Bearer <project key>" \
-H "Content-Type: application/json" \
-d '{"id":"alice","name":"Alice","role":"admin"}'{
"token": "<user token>",
"expiresAt": "2026-10-15T10:30:00.000Z",
"refreshToken": "<refresh token>",
"refreshExpiresAt": "2026-11-14T09:30:00.000Z"
}The role must be one the project declares. token is the access token and lives 1 hour; refreshToken lives 30 days and gets the next pair (Refreshing). Pass all of it to the client: given the refresh token, the client refreshes by itself. For local testing, scripts/demo-tokens.sh mints three users at once - alice (admin), bob (editor) and carol (viewer) - and prints ready-to-open URLs for the to-do example.
The client, built from source
Build the JavaScript client once; the server then serves the bundles at /client/dist/, the same files the hosted service serves:
cd client && bun install && bun run build
# dist/dabadata.js ES module: createClient, DabadataError
# dist/dabadata.iife.js script tag: window.dabadata = { createClient, DabadataError }In a TypeScript project inside the checkout you can also import client/src/index.ts directly; the types come with it. Then, with Alice's token from above (no key: this is the default project):
<script type="module">
import { createClient } from "http://localhost:4600/client/dist/dabadata.js";
const db = createClient({
url: "http://localhost:4600",
token: "<user token>",
expiresAt: "<expiresAt>",
refreshToken: "<refresh token>",
});
db.subscribe("tasks", {}, {
onRows: (rows) => console.log("tasks now", rows),
onChange: ({ op, row }) => console.log(op, row),
});
await db.insert("tasks", { title: "Buy milk" });
</script>Alice is an admin, so the insert is allowed. dabadata fills owner with her id and done with its default, and every open subscription that may read the new row - this tab and any other - gets an insert change. Try the same insert with carol's token and you get a DabadataError whose explanation reads create tasks: denied, role "viewer" not in ["admin","editor"].
Open the demo
The live demo puts two people side by side on one database: pick who each pane is, change something on one side and watch it land on the other, with the trace underneath explaining every decision. Every visitor gets a private copy - a demo room in one shared demo database, started from projects/default.ts and its seed file, removed after 2 hours without use - so visitors never see each other's rows (POST /v1/demo/tokens). It needs DABADATA_DEMO=1 on a throwaway project. The viewer is at /?token=<view token>; get a view token with POST /v1/view-token (see the API reference).