Prerequisites
Before you begin, make sure you have the following installed and configured.
- GitHub account - to clone
github.com/ottabase/ottabase. - Node.js ≥ 24 - use
nvm install 24 && nvm use 24if needed. - pnpm ≥ 10 - install via
corepack enable pnpm. - Cloudflare account - free tier works. No credit card required to start.
- Wrangler CLI - installed automatically by pnpm as part of the monorepo.
Clone & Install
Clone the repository and install dependencies. The monorepo uses pnpm workspaces - never use npm or yarn here.
# Clone the repository
$ git clone https://github.com/ottabase/ottabase.git my-saas
$ cd my-saas
# Install all dependencies (pnpm workspaces handles everything)
$ pnpm install
# Build shared packages first - required before first dev run
$ pnpm build:pkgCloudflare Setup
Ottabase includes a CLI command that creates all required Cloudflare resources: D1 database, KV namespace, the three R2 buckets, and the queue. Run it once; pnpm otta deploy runs it for you on the first deploy.
# Log in to Cloudflare via Wrangler
$ pnpm cf:login
# Create all required Cloudflare resources automatically
$ pnpm cf:setup
✓ D1 database 'my-saas-db' created
✓ KV namespace 'my-saas-kv' created
✓ R2 buckets 'my-saas-bucket', 'my-saas-files-private', 'my-saas-vault' created
✓ Queue 'my-saas-queue' created
✓ Their ids printed for CI (wrangler.jsonc stays a template)
# Validate your configuration looks correct
$ pnpm cf:validateThen run pnpm env:secrets. It creates apps/otta-web/.env.local and generates AUTH_SECRET, MIGRATION_SECRET, BOOTSTRAP_OWNER_SECRET, CRON_SECRET, and friends. It never overwrites a value you set, so it is safe to re-run, and it leaves third-party credentials (OAuth client secrets, provider API keys) for you to fill in.
Local Development
Two servers run in parallel. Vite handles the frontend with hot module replacement. Wrangler emulates the Cloudflare Worker locally with your real D1 data.
$ pnpm dev
┌─────────────────────────────────────────────┐
│ Vite dev server → localhost:3003 │
│ Wrangler worker → localhost:3004 │
└─────────────────────────────────────────────┘
# Or start them separately for more control:
$ pnpm dev:fe # Vite only
$ pnpm dev:be # Wrangler only
# Any app, any Wrangler environment - always local:
$ pnpm otta start otta-landing
$ pnpm otta start otta-web --env stage
$ pnpm otta new web my-app # scaffold another appBootstrap
On a fresh install you do not need special URLs by hand. With both dev servers running, open http://localhost:3003: the app redirects to /__bootstrap__, where a wizard runs migrations, seeds RBAC roles and permissions, creates the platform owner, and finalizes platform state.BOOTSTRAP_OWNER_SECRET in .env.local gates the flow (and the API below).
For automation, headless environments, or production scripting, the same steps are available as POSTs under /__bootstrap__/api/* with the X-Bootstrap-Secret header. After the platform is READY, those routes shut off; use Admin → Migrate for later schema changes from the UI.
# GUI path (default): open http://localhost:3003 → /__bootstrap__ wizard
# API path (CI / automation) - from repo root; path matches template app .env.local
$ SECRET=$(grep BOOTSTRAP_OWNER_SECRET apps/otta-web/.env.local | cut -d= -f2)
$ BASE=http://localhost:3004/__bootstrap__/api
# 1. Initialise - migrations, tables
$ curl -s -X POST $BASE/init -H "X-Bootstrap-Secret: $SECRET"
# 2. Seed - default roles and permissions
$ curl -s -X POST $BASE/seed -H "X-Bootstrap-Secret: $SECRET"
# 3. Create owner account
$ curl -s -X POST $BASE/create-owner \
-H "X-Bootstrap-Secret: $SECRET" \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"SecurePass1!","name":"You"}'
# 4. Finalise platform state
$ curl -s -X POST $BASE/finalize -H "X-Bootstrap-Secret: $SECRET"
# Done. Open localhost:3003 and log in.Deploy to Production
One command ships: pnpm otta deploy otta-web creates the Cloudflare resources the app's wrangler.jsoncnames, fills in their ids, builds only the packages the app uses, deploys, and sets the secrets the app cannot run without. After that, Cloudflare Workers Builds runs the same command on every push; GitHub runs the tests on pull requests and keeps a manual deploy and PR previews. Each app's cloudflare-config.jsonsays how it builds and ships. The same code runs locally and in production - no "it works on my machine."
# The first deploy, from your machine: resources, ids, build, deploy, secrets
$ pnpm otta deploy otta-web --owner-email you@my-saas.com --owner-password '…'
✓ D1, KV, R2 and queue created; their ids in wrangler.production.jsonc
✓ Built the packages it uses, then the app; deployed, tagged with the commit
✓ Secrets set; database bootstrapped with you as the owner
✓ https://otta-web-production.my-account.workers.dev
# Every push after that: connect the app in Cloudflare Workers Builds,
# deploy command:
$ pnpm otta deploy otta-web --ci
# A custom domain (optional): Cloudflare → Workers → Domains, then
$ pnpm otta deploy otta-web --url https://my-saas.comCreating a Fat Model
Models live in ottabase/models/. They inherit from BaseModel and declare a Drizzle table schema, static metadata, and domain methods.
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { BaseModel } from '@ottabase/ottaorm';
// 1. Define the schema (Drizzle + SQLite)
export const projectsTable = sqliteTable('projects', {
id: text('id').primaryKey(),
name: text('name').notNull(),
status: text('status').default('draft'),
orgId: text('org_id'),
});
// 2. Define the model (fat model pattern)
export class Project extends BaseModel {
static entity = 'projects';
static table = projectsTable;
// Domain method - logic lives here, not in a service
async publish() {
this.set('status', 'published');
return this.save();
}
}
// 3. Export the table in ottabase/db/schema.ts
// 4. Register in worker/lib/db-utils.ts → initDbConnection
// 5. registerPolicy(...) + add to GENERIC_CRUD_ALLOWLIST
// 6. POST /api/ottaorm/init to create the tableAuto CRUD API & Hooks
Generic CRUD is default-deny. Register the model, give it an RLS policy, and add it to GENERIC_CRUD_ALLOWLIST - then a full API answers at /api/ottaorm/{entity}. Generate client hooks in one line; client code never calls fetch() directly.
import { createModelHooks } from '@ottabase/ottaorm/client';
import type { Project } from '../models/Project';
export const {
useList: useProjects, // GET /api/ottaorm/projects
useDetail: useProject, // GET /api/ottaorm/projects/:id
useCreate: useCreateProject, // POST /api/ottaorm/projects
useUpdate: useUpdateProject, // PUT /api/ottaorm/projects/:id
useDelete: useDeleteProject, // DELETE /api/ottaorm/projects/:id
useInfiniteList: useProjectsInfinite,
} = createModelHooks<Project>({ entityName: 'projects' });Row-Level Security
Every model gets a policy - tenant, user, app, public, or custom - and a model without one is refused. The security context is built from the verified session, with org membership resolved from D1. The RLS filter is merged last, so a client where cannot widen it, and an unresolved membership list is a denial, never an unscoped query.
import { initRLS, registerPolicy, RLSPolicies } from '@ottabase/ottaorm';
// Once: register policies. No policy, no access.
initRLS();
registerPolicy({ model: 'projects', policy: RLSPolicies.TenantScoped(false) });
// Per request: build the context from the VERIFIED session.
// memberOrganizationIds is required whenever organizationId is set.
const ctx = await getSecurityContext(request, session, env);
// { userId, organizationId, appId, memberOrganizationIds, platformAdmin }
// The secure CRUD path merges the RLS filter last.
const res = await executeSecureCrudRequest(crudRequest, ctx);
// No policy → denied. Unresolved membership → denied.RBAC
Authorization is permission + scope, never a role name. A role is just a bundle of permissions; every gate asks whether a grant at the required scope carries the required permission. Checks are cached in KV so hot paths skip D1. Pair it with RLS: RLS draws the tenant boundary, RBAC decides what a user may do inside it.
- Platform admin - a system-scoped grant carrying
platform:admin. Onlyplatform_ownerhas it, via the bootstrap. - Org admin - an org-scoped grant carrying
org:admin, held byownerandadmininside their own tenant. - Customize by creating new roles. System roles are read-only and self-heal from
/__bootstrap__/seedafter an upgrade.
Auto-Migrations
No migration files. No SQL to write. OttaORM inspects the registered models and creates or alters tables to match. The first-time wizard runs this for you; afterward call POST /api/ottaorm/init or use Admin → Migrate whenever you add or change a model.
# Local development
$ curl -X POST http://localhost:3004/api/ottaorm/init
{ "created": ["projects"], "updated": [], "unchanged": 11 }
# Production (run after deploy)
$ curl -X POST https://otta-web-production.my-account.workers.dev/api/ottaorm/init \
-H "Authorization: Bearer $MIGRATION_SECRET"
# …or click Admin → Migrate
# Capabilities:
# ✓ Create new tables
# ✓ Add new columns (with DEFAULT values)
# ✗ Rename or drop columns (use custom migration)AI Agents
The repo teaches your coding agent its conventions. AGENTS.MD holds the rules, .claude/skills holds fifteen task-shaped skills, and pnpm docs:llms regenerates llms.txt and llms-full.txt from every guide and README. Ask for a feature and the agent follows the Ottabase way instead of inventing a second architecture.
AGENTS.MD the rules every agent reads first
.claude/skills/
ottabase-create-model model + every registration step
ottabase-crud allowlist · createModelHooks · ModelCrud
ottabase-form model-driven forms, custom forms
ottabase-rbac permission + scope, fail-closed context
ottabase-migration auto vs custom migrations
ottabase-theme Brand Engine tokens, kits, dark mode
ottabase-update take a newer Ottabase and Pro, keep every local change
… upload · email · queue · cron · component
package · testing · cloudflare
$ pnpm docs:llms
✓ llms.txt index
✓ llms-full.txt every guide and README, one fileWhere do we go from here?
- Read the Philosophy to understand why Ottabase works the way it does.
- Read
README.mdandARCHITECTURE.mdin the monorepo for the full system picture. - Open the package map to see what ships in the monorepo.
- Join Discord to ask questions and share what you're building.
- Read
docs/SOLO_FOUNDER_SAAS_GUIDE.mdfor a full product-building walkthrough, anddocs/PREMIUM_PACKAGES.mdif you want to sell add-ons. The paid packages for running a business on it (fleet board, billing, error tracking, status page, database admin, support inbox) are Ottabase Pro. - Use
AGENTS.MDin the repo root for AI assistant integration instructions.