Quickstart
In about fifteen minutes you will install the CLI, create a Palbase project, bind a directory to it, declare a database table, write a small todos API, deploy it, and call it — first with curl, then from a web app. There is exactly one deploy rail on this cloud: palbase link binds a checkout to a project, and palbase push ships code and schema to it in one request. No repository is created, no Git branch is mapped, and there is nothing to configure beyond the two files the CLI writes for you. Every command below is real; run them in this order and the last one returns your own data over HTTPS.
Before you start
| You need | Why |
|---|---|
| Node.js and npm | palbase init asks npm which @palbase/backend is newest; palbase build runs the same staging a deploy runs |
| Bun — bun.sh | the stack runs Bun, so palbase push builds the bundle with it and refuses without it |
| Docker (optional) | only for step 7, palbase start, which runs the whole stack on your machine |
| A Palbase account | free; palbase login --create opens one from the terminal |
1. Install the CLI
The palbase CLI is a single Go binary — it is not an npm package:
curl -fsSL https://raw.githubusercontent.com/palgroup/palbase-cli/main/install.sh | sh
or with Homebrew:
brew install palgroup/tap/palbase
Verify:
palbase --version
# palbase version <version>
Warning: The install script's closing line prints
palbase backend init my-app. That is a stale string in the script: there is nopalbase backendcommand, andpalbase inittakes no arguments. The correct first command ispalbase init, in step 4.
2. Log in
palbase login
# Signed in as you@example.com
palbase login opens your browser for an OAuth sign-in — authorization code with PKCE — and stores the session in ~/.palbase/session.json. You never type a password into the terminal. Add --create to open a new account on this cloud instead of signing in to an existing one:
palbase login --create
Note: Creating an account signs you in immediately. There is no verification step and nothing to click before you can continue. Palbase does email you a six-digit code from
donotreply@palbase.studiowith the subject "Verify your email", and that code expires in five minutes — you do not need it. Nothing in Palbase is blocked until you enter it, and there is currently no screen that accepts it. Carry on with step 3.
For CI and other headless runs there is no browser flow: export a machine credential as PALBASE_ACCESS_TOKEN and every command resolves it. See palbase login.
3. Create a project
palbase project create "Todo App"
# Created Todo App — j06bwtuum (Running)
#
# Link it with:
# palbase link 'Todo App'
The positional argument is the name. Quote it if it contains spaces. The only flags are --tier (default free) and --json — there is no --name, no --region, no --github-account and no --repo. --name in particular is not merely ignored: cobra rejects the command outright with unknown flag: --name, because the flag is not declared.
Creating a Project also creates its first Environment — its own Postgres, its own vault, its own two API keys and its own address. j06bwtuum is that Environment's ref: eight random characters from [a-z0-9] plus the letter m, minted by the server and carrying no part of the name. Creating a project called Todo App does not mint todoappm; the ref is whatever the command printed, and that is what you link by. Its address is https://j06bwtuum.palbase.studio — the only address set on this cloud. The Project lands in your Personal Organization, which pays for it.
create returns once the project is running, but it serves nothing until your first push, and for a short while after creation a request to it can still answer 503. link and push both wait that out for you; a curl of the address inside that window is not a sign that anything is wrong.
4. Scaffold the code and link the directory
mkdir todo-app && cd todo-app
palbase init
▸ installing @palbase/backend@<version>
AGENTS.md
CLAUDE.md
db/public.ts
modules/digest/digest.job.test.ts
modules/digest/digest.job.ts
modules/digest/digest.module.ts
modules/health/health.controller.ts
modules/health/health.module.ts
modules/notes/dto/create.ts
modules/notes/dto/list.ts
modules/notes/dto/note.ts
modules/notes/dto/update.ts
modules/notes/notes.controller.ts
modules/notes/notes.e2e.test.ts
modules/notes/notes.module.ts
modules/notes/notes.service.test.ts
modules/notes/notes.service.ts
package.json
test-users.json
tsconfig.json
.gitignore
▸ resolving the project's dependencies
▸ @palbase/backend <version>
palbase start run it on this machine
init takes no arguments and no flags. It scaffolds into an empty directory, installs @palbase/backend and copies the template out of the installed package — so the scaffold and the SDK that compiles it are the same version by construction. It creates nothing in the cloud; that was step 3, and the two are separate on purpose.
Note: What you just scaffolded is two worked domains, not a bare minimum.
notesruns fromdb/public.tsthroughmodules/notes/dto/,modules/notes/notes.service.ts(with its unit test) andmodules/notes/notes.controller.ts, all of it named bymodules/notes/notes.module.ts— andmodules/notes/notes.e2e.test.tsis the kind the deploy itself runs.digestis a second domain whose@Jobreachesnotesthrough moduleimports/exports, which is the only way one domain reaches another. That module file is the registration: a class no module lists does not exist. Read both before writing your own; the shape you find there is the shape to copy. See Architecture.
Now bind this directory to the project you created:
palbase link 'Todo App'
# ▸ no client app here (…) — linking the backend only
# no contract yet: https://j06bwtuum.palbase.studio cannot describe itself: nothing is deployed yet — a backend is what makes a contract, so this ends with `palbase push`
# the link is recorded; `palbase spec` fills the contract in once something answers
#
# linked to Todo App (proj_01)
# contract read from main; each verb resolves its own environment
# commit palbase/project.json
This is the step the rest of the guide depends on. palbase link writes the committed palbase/project.json, which names the project, and from here every command in this directory — push, plan, status, deploys, apikey, secret, storage — acts on that project without being told again. You do not have to name the platform: link reads the checkout and prints what it found. This directory is a backend, so there is nothing per environment to write here; the web app in step 10 gets every environment's config and contract. A new project has one environment, main, and every command acts on it; once a project has more, palbase env use <name> chooses one — see Which environment a command acts on.
Note:
palbase/is ONE visible directory and every file in it is committed — that is why a fresh clone builds without a login and without the network. Nothing inside it is gitignored, and the.gitignorepalbase initwrites says nothing about Palbase at all. If an older CLI left a hidden.palbase/directory here,palbase linkrefuses the checkout rather than migrating it: delete that directory, commit the deletion, and link again — everything in it is regenerated from the project.
The full story — what link writes, which file wins, and the 503 retry — is on Linking a Checkout.
5. Declare the table
db/public.ts is the single source of truth for your Postgres tables — one file per schema, and public is the one you start with. There are no migration files anywhere in Palbase: this declaration is diffed live against whichever database you point at, and the change travels with your next push. Replace the scaffolded file with a todos table:
// db/public.ts
import {
boolean, defineSchema, defineTable, ownedByUser, policy, text, timestamp, uuid,
} from "@palbase/backend";
const todos = defineTable("todos", {
columns: {
id: uuid().primaryKey().defaultRandom(),
// The column that OWNS the row. `ownedByUser()` implies text, NOT NULL and
// ON DELETE CASCADE — a real foreign key to your project's own users, so
// deleting an account takes its rows with it instead of leaving orphans.
user_id: ownedByUser(),
title: text().notNull(),
done: boolean().default(false),
created_at: timestamp().defaultNow(),
},
// Row-Level Security is on by default and a table with RLS and no policies
// denies everything, so the table needs a policy before it can read or write
// a single row. `policies` is a FUNCTION: the callback receives the column
// context, so a policy can name this table's columns.
policies: () => [
// Postgres enforces ownership, not the handler: a query that forgets its
// `where user_id = …` still cannot see another user's rows.
policy("todos_owner")
.for("all")
.to("authenticated")
.using("user_id = (select auth.uid())")
.withCheck("user_id = (select auth.uid())"),
],
});
export default defineSchema("public", { tables: [todos] });
Four things to notice:
- The table carries its own name.
defineTable("todos", …)names it once, anddefineSchematakes an array of those tables plus the schema's own name — so a second table in this file could point attodosby writingreferences(() => todos.id). - The file must
export defaultthedefineSchema(...)call. This is one of the few places where the default export is genuinely required. - Columns are
NOT NULLby default;.nullable()opts in toNULL. - The policy is not optional decoration. RLS is on by default, and the rule the SDK computes is one line —
rls = policies.length > 0 || table.rls !== false— so a table declared with columns alone denies everything: thePOSTin step 9 would fail and theGETwould return[], with no error naming RLS anywhere. The DSL itself does not touch roles:.to("authenticated")stores exactly what you wrote, and the stack's schema planner expands it server-side, so look for the expansion in the stack rather than in the SDK.
Note: Ownership is declared in two places on purpose, and they are not redundant. The policy above is the backstop Postgres enforces on every query; the service still writes its own
user_idfilter, because a backstop that is the only guard is one nobody notices when a query drifts. See Row-Level Security.
Then generate the typed environment declaration:
palbase build
→ installing zod-to-json-schema (extractor tool, --no-save) ...
✓ @palbase/backend <version>
✓ palbase/palbase-env.d.ts
GET /health → modules [check]
build OK — 1 route(s) across the controllers would deploy cleanly
The first line appears only on the first build in a fresh project: build fetches its schema extractor with npm install --no-save once, and every later build finds it and says nothing — which is why the block in step 6 does not have it.
palbase build runs the same staging and metadata extraction a deploy runs, so decorator mistakes that pass tsc are caught here. It also writes palbase/palbase-env.d.ts from db/public.ts, which is what makes Database.public.todos fully typed in your editor. Run it before typechecking — tsc --noEmit first will error with Property 'todos' does not exist on type 'EnvTables', because the declaration has not been generated yet.
6. Write the endpoints
The request and response schemas first. Each one is exported as a zod value and a type with the same name, because a route's success response is its return type and the extractor reads a named zod schema out of it:
// modules/todos/dto/create.ts
import { z } from "@palbase/backend";
export const Todo = z.object({
id: z.string(),
title: z.string(),
done: z.boolean(),
created_at: z.string(),
});
export type Todo = z.infer<typeof Todo>;
export const TodoList = z.array(Todo);
export type TodoList = z.infer<typeof TodoList>;
export const CreateTodoBody = z.object({
title: z.string().min(1),
});
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
One file, because all three endpoints share Todo. Nothing discovers dto/ — it is an ordinary import path, not a scanned directory — so the layout inside your domain is yours. What is not optional is the double export above: a zod value and a same-named z.infer type.
Then the service. A controller never imports Database — the decisions about which rows and in what order live one layer down, in an @Injectable() class the container builds:
// modules/todos/todos.service.ts
import { Database, Injectable } from "@palbase/backend";
import type { CreateTodoBody, Todo, TodoList } from "./dto/create";
@Injectable()
export class TodosService {
/** Ownership is written HERE, from the caller — never from the request body,
* which a client controls. */
create(userId: string, body: CreateTodoBody): Promise<Todo> {
return Database.public.todos.insert({ user_id: userId, title: body.title });
}
/** The filter is written out even though the policy already scopes the read:
* the policy is the backstop that holds when a query forgets. */
list(userId: string): Promise<TodoList> {
return Database.public.todos.findMany({ where: { user_id: userId } });
}
/** No `userId` argument, and that is not an oversight: another user's row is
* invisible to this read, so it comes back `null` exactly as a missing id
* does. Postgres decides that, not this method. */
get(id: string): Promise<Todo | null> {
return Database.public.todos.findById(id);
}
}
And the controller, which names that service in its constructor and nothing else:
// modules/todos/todos.controller.ts
import { Body, Controller, Get, NotFound, Param, Post, User } from "@palbase/backend";
import type { UserT } from "@palbase/backend";
import { CreateTodoBody, Todo, TodoList } from "./dto/create";
import { TodosService } from "./todos.service";
// Routes require a signed-in caller unless they say otherwise, so `@User()` is
// non-null here and nothing has to check for it.
@Controller("/todos")
export class TodosController {
constructor(private readonly todos: TodosService) {}
@Post("")
create(@User() user: UserT, @Body(CreateTodoBody) body: CreateTodoBody): Promise<Todo> {
return this.todos.create(user.id, body);
}
@Get("")
list(@User() user: UserT): Promise<TodoList> {
return this.todos.list(user.id);
}
@Get("/{id}")
async get(@Param("id") id: string): Promise<Todo> {
const todo = await this.todos.get(id);
if (!todo) throw new NotFound("todo not found");
return todo;
}
}
Neither class exists yet. One module declaration is what makes them exist, and without it palbase push refuses the bundle by name — being inside modules/todos/ grants nothing:
// modules/todos/todos.module.ts
import { Module } from "@palbase/backend";
import { TodosController } from "./todos.controller";
import { TodosService } from "./todos.service";
// The lists are TYPED: a non-class here is a compile error, so nothing needs a
// cast on the way in.
@Module({
controllers: [TodosController],
providers: [TodosService],
exports: [],
imports: [],
})
export class TodosModule {}
That mounts three endpoints:
| Method | Verb | Path | Generated client call |
|---|---|---|---|
create | POST | /todos | pb.todos.create({ title }) |
list | GET | /todos | pb.todos.list() |
get | GET | /todos/{id} | pb.todos.get(id) |
Six things to notice:
- Routes are secure by default — the effective rule is route → controller → required, and only a literal
falseopts out, so a route that declares nothing is closed rather than open. These three declare nothing, which is why@User()is non-null and the rows are already scoped per caller. Identity arrives as a Bearer token; the publishable key says which Environment you are talking to, never who is calling — see Authentication. - There is no response decorator. The response schema is the method's return type, and it must be a named zod schema that exists as a value in scope. An inline
Promise<{ id: string }>is rejected by the deploy withreturn type must be a NAMED zod schema. - The module list is the registration.
@Controllerdescribes the class; it does not enrol it, and neither does the folder the file sits in. A class no module names is refused at build, by name, and never reaches the route table or the OpenAPI document. Controllers and services are exported by name — no file here is default-exported. The one placeexport defaultis still required isdb/*.ts, which is the only thing the runtime reads by location. - Background work is a class in
providerstoo.@Job,@Webhook,@Hookand@Roomgo in the same module'sproviderslist. There is nojobs/,webhooks/orhooks/directory and nothing reads one. - Path parameters use OpenAPI braces with a leading slash:
/{id}, injected by name with@Param("id"). A literal/:idworks too and means the same thing;{id}is the house style. Omitting the leading slash mounts/todos{id}and every request 404s. - The client namespace comes from the class name, not the path:
TodosController→pb.todos. Rename the class and you rename the call in every app that uses it, so treat the class and method names as your public API.
Now delete the two domains the scaffold shipped — modules/health/ and modules/notes/ — so the counts below match what you see. notes has to go in any case: step 5 replaced its table, so its service no longer compiles.
rm -rf modules/health modules/notes
Run palbase build again. It prints one line per route, and every surface answers for itself — a bare total once hid four dropped @Job classes on a live project:
✓ @palbase/backend <version>
✓ palbase/palbase-env.d.ts (unchanged)
POST /todos → modules [create]
GET /todos → modules [list]
GET /todos/{id} → modules [get]
build OK — 3 route(s) across the controllers would deploy cleanly
7. Optional: run it on this machine first
If Docker is running, you can exercise the whole thing locally before it touches the cloud. palbase start brings up Postgres, the platform services, the runtime and the same edge proxy that fronts a deployed stack, and points this checkout at it by writing local.json under ~/.palbase/checkouts/<hash>/, which wins over the link while it exists. That key is machine state, so it lives in your home directory rather than in the checkout — there is nothing new to commit or ignore here:
palbase start
palbase db plan # what it would take to make this database match db/public.ts
palbase db apply # apply it
Your source is mounted rather than deployed, so saving a controller serves the new version with no build and no version history. palbase db acts only on the stack running on this machine, and says so plainly in a checkout without one.
Warning: While the local stack is up,
palbase pushrefuses — that stack already serves this directory, so a push there would activate a version nothing loads. Runpalbase stopfirst; it shuts the stack down and points the checkout back at its project.
palbase stop
More on the local loop in Running It Locally and Database.
8. Deploy
palbase plan shows what a push would change and writes nothing:
palbase plan
▸ Todo App/main
code
built 1 controller file(s) → 1 controller(s) [bun 1.3.9]
schema
create table todos
enable RLS todos
add policy todos_public on todos
Three schema lines, not one: a brand-new table is diffed against nothing, so the RLS it turns on and every policy declared on it are changes in their own right. A table you declared with columns alone would print only the first line — which is exactly the shape of the deny-everything trap step 5 warns about, visible here before you ship it.
Then ship it:
palbase push
▸ Todo App/main
built 1 controller file(s) → 1 controller(s) [bun 1.3.9]
sending /Users/you/todo-app (412 KB)
schema:
created table todos
enabled row-level security on todos
added policy todos_public on todos
live: 3 endpoint(s), 57788ca062dc
push takes no arguments; its two flags, --approve and --accept-breaking, are below. It acts on the environment this checkout resolves to — here the project's only one, main — and --env <name> is the one way to name another for a single command. The first line is the destination banner push and plan print to stderr before doing any work. Most remote verbs do this, but not all of them — palbase apikey list in step 9 prints its own copy to stdout instead, so redirecting stderr away does not silence every banner. The schema: block appears only when db/public.ts differs from the database, which it does on a first push because the project was created with an empty one.
Note the tense. plan proposes (create table, enable RLS), and push reports what the apply did (created table, enabled row-level security on) — two different vocabularies for the same three changes, which is how you tell a block you are reading apart from a block you are only being offered. The last line is the whole result: how many endpoints are answering, and the short digest now serving them. On success the CLI also refreshes the committed contract for you.
A brand-new project can answer 503 for a short while. push and link retry a 503 for up to 3 minutes at 6-second intervals and announce the wait on stderr:
the project is not serving yet — waiting for it (up to 3m0s)
That is a normal wait, not a failure. Only 503 is retried; every other status is answered on the first try, because a 4xx is a decision.
Nothing is swapped by a refused push — the previous release keeps serving. Two refusals are worth knowing before you meet them. A bundle carrying no controllers is refused outright (the bundle carries ZERO controllers — nothing would answer), and a schema change that would take data away comes back as a 409, itemised with row counts, and needs --approve:
this push would remove data:
drop column todos.notes (1284 rows, 903 non-null)
repeat with --approve when that is what you mean
--approve is the flag for a destructive schema change; the other, --accept-breaking, overrides the running release's schema compatibility check. There is no --yes on this rail. Then check what is live:
palbase status # the active version and deploy state
palbase deploys # deploy history, newest first
Or open it in the browser at https://palbase.studio. (palbase open does not read the link file, so it opens the Studio root — which is still where you want to be.)
The whole rail — what travels, what refuses, and rollback — is on Deploying.
9. Call it with curl
Every request carries two things: the Environment's publishable key on the apikey header, which says which Environment you are talking to, and a Bearer token, which says who is calling. palbase apikey list prints the key and masks the other one:
palbase apikey list
# ▸ Todo App/main
# publishable pb_project_cA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6
# service-role (hidden — `palbase apikey reveal` prints it)
These routes need a signed-in caller, so mint a throwaway one. palbase test-user create returns a real account on this Environment and prints its credentials once:
palbase test-user create
# ▸ Todo App/main
# ✓ created 1 test user(s)
# id: usr_123
# email: test-9f3c1a2b@test.invalid
# password: a7f2c1d80b4e
# token: eyJhbGciOi…
# (creds shown once — store them now)
ENDPOINT="https://j06bwtuum.palbase.studio"
KEY="pb_project_cA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6" # paste from the list above
TOKEN="eyJhbGciOi…" # the token printed above
curl -X POST "$ENDPOINT/todos" \
-H "apikey: $KEY" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Try Palbase"}'
{
"id": "6f2d1c1e-9a3b-4c7d-8e2f-1a2b3c4d5e6f",
"title": "Try Palbase",
"done": false,
"created_at": "2026-08-27T10:15:00.000Z"
}
curl "$ENDPOINT/todos" -H "apikey: $KEY" -H "Authorization: Bearer $TOKEN"
The list comes back holding only that caller's rows — the policy decides it in Postgres, so a second test user sees none of them. Drop the Authorization header and the same request is refused: the publishable key alone never establishes who is calling.
The project in the middle of that key is the stack's own constant identity, not your ref — every Environment on this cloud is minted with keys reading pb_project_c… and pb_project_s…, so the key does not tell a client which Environment to talk to. That is why a generated client is configured with an explicit URL and key, never with the key alone.
Warning: Every Environment is minted with two keys, and
palbase apikey revealprints both. They differ by one character — thecorsafterpb_project_— and that character is the entire difference between a credential designed to be public and one that opens the Environment's whole Management API: arbitrary SQL, schema apply, secret values, session revocation. The service-role key must never reach a browser bundle, a mobile app, a public repository or a client-side environment variable. See the two API keys.
10. Call it from a web app
In your web app's directory — any project with a package.json — bind it to the same project, then generate the client:
cd ../my-web-app
palbase link 'Todo App' --platform web
palbase link # later: refresh every environment's files after the project changes
palbase link writes palbase/environments/<env>/openapi.json and …/web-config.json for every environment of the project, installs @palbase/web if the project does not have it, generates palbase/environments/<env>/palbe.gen.ts from the contract, writes the barrel palbase/client.ts that re-exports the selected environment, splices an import of THAT into your entry file, and adds predev/prebuild scripts that keep it fresh. Then call your endpoints through the global pb, fully typed:
// src/main.tsx — one side-effect import configures pb and types it
import "../palbase/client";
import { pb } from "@palbase/web";
// The routes require a caller, and `pb` carries the session once it has one.
await pb.auth.signIn({ email: "test-9f3c1a2b@test.invalid", password: "a7f2c1d80b4e" });
const todo = await pb.todos.create({ title: "From the web" });
const todos = await pb.todos.list();
There is no createClient(url, key) and no URL or key in your application code: palbe.gen.ts calls __configure({ url, apiKey, appId }) at import time with the values the link fetched. Your app imports palbase/client.ts and never that file directly — the barrel's path carries no environment name, so switching environments never edits your own source. Commit the whole of palbase/: the predev/prebuild hooks regenerate the client from the committed contract, which is what lets a fresh clone build with no login and no network. More in Codegen and Calling your backend.
Building a native Apple app instead? Run palbase link 'Todo App' --platform ios in the app's own checkout — the one holding the .xcodeproj. It writes palbase/environments/<env>/ios-config.json and generates the committed Swift client at palbase/environments/<env>/PalbaseGenerated.swift beside that environment's Palbase-Info.plist. There is no configure(url:apiKey:) to call — the SDK self-configures from that plist on the first pb.* access:
import Palbe
let created = try await pb.todos.create(.init(title: "Ship the iOS app"))
let todos = try await pb.todos.list()
link writes nothing into your build system. It prints, once, the three lines that select an environment — PALBASE_ENV plus the two EXCLUDED_SOURCE_FILE_NAMES / INCLUDED_SOURCE_FILE_NAMES patterns — and you place them wherever you already keep build settings. Add palbase/environments to your app target, and set PALBASE_ENV: Xcode expands an unset variable to nothing, so the include pattern would match nothing and the app would ship unconfigured.
See iOS Overview and iOS Codegen.
Related
- Project Structure — what a
modules/<domain>/folder holds, whatdb/is, and whatpalbase/is generated into - Linking a Checkout —
palbase link, what it writes, and which file wins - Deploying —
build,plan,push,deploysandrollback - Running It Locally — the whole stack on your machine with
palbase start - Row-Level Security — replace this guide's wide-open policy with an owner-scoped one
- Authentication — turn the public routes into per-user ones
- Schema · Schema changes — the database half, and how an edit reaches one
- Introduction — the two keys an Environment is minted with, and what each one opens
- Web SDK · iOS SDK — the generated clients in full