Introduction
Palbase is a Backend-as-a-Service platform: you write a TypeScript backend as plain class controllers, and Palbase runs it for you in an isolated environment that also holds its own Postgres database, authentication, file storage, feature flags, realtime, notifications and analytics. Typed client SDKs for the web and for Apple platforms are generated straight from your deployed API, so the code your frontend calls is always the code your backend serves. You never run a server, wire a database or hand-write an API client — you write controllers, palbase push, and call them.
A taste
An endpoint is a decorated class method. The request is validated by a zod schema, and the response schema is the method's return type — which is why it has to be a named schema that exists as a value:
// modules/todos/dto/todos.ts
import { z } from "@palbase/backend";
export const CreateTodoBody = z.object({ title: z.string().min(1) });
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
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>;
// modules/todos/todos.controller.ts
import { Controller, Post, Body, User, Database } from "@palbase/backend";
import type { UserT } from "@palbase/backend";
import { CreateTodoBody, Todo } from "./dto/todos";
@Controller("/todos")
export class TodosController {
@Post("")
async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<Todo> {
return Database.public.todos.insert({ title: body.title, user_id: user.id });
}
}
One more file, and it is the one that makes the controller exist. Every domain lives in its own folder under modules/, and a *.module.ts beside it declares what that folder owns:
// modules/todos/todos.module.ts
import { Module } from "@palbase/backend";
import { TodosController } from "./todos.controller";
@Module({
controllers: [TodosController],
providers: [],
exports: [],
imports: [],
})
export class TodosModule {}
There is no root module and nothing to mount one into — adding a domain is adding a folder. There are no root controllers/, services/ or models/ directories either; the schema is the one thing that stays at the top, in db/.
After palbase push, the generated web client calls it fully typed:
import { pb } from "@palbase/web";
const todo = await pb.todos.create({ title: "Ship the docs" });
The namespace comes from the class name, not from the base path: TodosController.create becomes pb.todos.create. Treat those two names as your public API — renaming the class renames the client method. The same endpoint is available to Apple platforms through generated Swift, and to anything else over plain HTTPS. Follow the Quickstart to build this end to end.
Note: Every class is exported by name, and no file here is default-exported — the module imports it as
import { TodosController }, and curly braces cannot see a default export. What a class needs is a module that lists it: an entry point no module names is refused at build, by name, and never reaches the route table or the OpenAPI document.@Job,@Webhook,@Hookand@Roomclasses are listed the same way, in a module'sproviders. The one thing the runtime still reads by location is the schema: every file underdb/does requireexport default, one schema per file.
What you get
Everything below is served by one Environment, at one address, behind its own keys. Most capabilities are an imported service inside your backend, and many have a client-side counterpart.
| Capability | In your backend | On the client |
|---|---|---|
| HTTP API from class controllers | Overview, Controllers | Web calls, iOS calls |
| Postgres, with the schema declared in TypeScript | Database, Schema | — |
| Row-Level Security | Row-Level Security | — |
| Authentication | Authentication | Web auth, iOS auth |
| File storage | Storage | Web uploads, iOS uploads |
| Direct-to-storage uploads | Direct Uploads | — |
| Document store (JSON, collections) | Documents | — |
| Cache (JSON key-value, TTL) | Cache | — |
| Scheduled (cron) jobs | Scheduled Jobs | — |
| Inbound webhooks (Stripe, GitHub, …) | Webhooks | — |
| Lifecycle hooks on auth, document and file events | Event Hooks | — |
| Feature flags and per-user overrides | Flags | Web flags, iOS flags |
| Push, email, SMS and in-app notifications | Notifications | — |
| Realtime broadcast | Realtime | Web realtime, iOS realtime |
| Secrets in a per-Environment vault | Secrets | — |
| Disposable test users, and suites graded at deploy | Test Users, Testing | — |
| Product analytics | — | Web analytics, iOS analytics |
| End-to-end encrypted messaging and calls | — | Web messaging, iOS messaging, Web calls, iOS calls |
One first-order fact about the outbound half: an allowlist is opt-in, and it is all-or-nothing. A project that has never run palbase egress add — including the one the Quickstart creates — installs no fence at all, and a fetch() from a handler reaches any public host. Declare the first host with palbase egress add api.example.com and push, and from that deploy on the fence is up: everything you did not declare fails by name inside your handler. Removing the last host does not close it again — it goes back to unrestricted. See Outbound Network.
The surfaces
You interact with Palbase through five surfaces:
| Surface | What it is | Docs |
|---|---|---|
@palbase/backend | The server SDK your controllers, jobs, webhooks and hooks are written against — npm, currently 33.0.0 | Backend SDK |
@palbase/web | The browser client SDK — one pb object, configured by the generated client your app imports through palbase/client.ts — npm, currently 8.0.0 | Web SDK |
Palbe | The Swift SDK for Apple platforms, over Swift Package Manager, with the same pb entry point | iOS SDK |
palbase CLI | A single Go binary — login, projects, link, the local stack, schema, deploys, module settings, logs and codegen | CLI |
| Studio | The dashboard at https://palbase.studio — endpoints, database, auth users, logs, usage, team | palbase project |
Your project also has a Management API of its own, at https://<ref>.palbase.studio/v1/management/*. It is what the CLI drives: it takes the Environment's service-role key on the apikey header, and it covers that one Environment — its schema, its secrets, its deploys. Account-level things (who owns what, which plan you are on) are not part of it; those live in the Studio.
Organizations, Projects, and Environments
Palbase has three levels, and each one has exactly one job:
Organization pays the plan, the pooled allowances, one invoice
└── Project groups a name, app registrations, the Environments under it
└── Environment runs its own database, address, keys, secrets, your code
An Environment is the thing that actually runs: its own Postgres, its own vault, its own two API keys and your deployed backend. Nothing is shared with another Environment — not a row, not a key, not a variable — and that holds on every plan, free included.
palbase project create mints a Project together with its first Environment. A second Environment under the same Project is how you get a staging copy that shares nothing with production; you name it staging, and the name is free text that carries no meaning to the platform. There are no Environment kinds — every Environment reports kind: "primary" and is_production: true — and no preview Environments, no per-pull-request automation and no Git-branch mapping anywhere on the platform. A branch that needs its own database is its own project.
Every Environment is reachable at one address:
https://<ref>.palbase.studio
A ref is nine characters minted at random — eight drawn from [a-z0-9] plus the letter m — and it carries no part of the name. Creating a Project called todoapp does not produce a ref called todoappm; project create prints the ref it actually minted, and that ref never changes, because it is embedded in the address and in DNS. Renaming a Project changes its display name and nothing else.
Note: A brand-new project can answer
503for a short while afterproject create, before it is ready to serve. You rarely meet it:palbase linkandpalbase pushretry a503for up to 3 minutes at 6-second intervals.
An Organization is the payer. You are never asked to create one — a free Organization called Personal is there the first time something needs a payer. It matters when you start paying, because the plan and the invoice belong to the Organization, and every Project under it shares them.
The two API keys
Every Environment is minted with two keys, and it is minted with both at once:
palbase apikey reveal
# publishable pb_project_cA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6
# service-role pb_project_sZ9y8X7w6V5u4T3s2R1q0P9o8N7m6L5k4
- The publishable key (
_c, roleanon) is what clients ship. It is safe in a browser bundle or a mobile app, and it is sent on theapikeyheader. - The service-role key (
_s) opens that Environment's entire Management API — arbitrary SQL, schema apply, secret values, deploy activation. It must never reach a browser, an app, a public repository or a client-side environment variable.
The two differ by one character. Read that character before you paste either of them anywhere.
The project in the middle is a compile-time constant naming the stack's own identity, not your Environment's ref — so every Environment on this cloud has keys reading pb_project_c… and pb_project_s…, and the ref is not in the key. Nothing can derive an address from a key, and nothing tries: a client is configured with an explicit url and apiKey, which palbase link writes into the generated config for you.
There are three key commands and none of them takes an argument — palbase apikey list | reveal | rotate. There is no individual key revocation: rotation replaces both keys atomically, with no grace period, and restarts the project. Privileged work inside your own backend does not need a credential at all — Database.$asService() bypasses Row-Level Security in code you wrote and deployed.
One deploy rail
Code reaches an Environment one way:
palbase init # scaffold a backend in an empty directory
palbase project create "Todo App" # mint the Project and its first Environment
palbase link <ref> --platform web # bind this directory to it
palbase push # ship
link reads the checkout and says what it found before doing anything: ▸ ios, web. It used to default to ios, which meant the bare form wrote an iOS slot and an Xcode build configuration into a backend-only directory and then looked for a Swift generator that is not there — a wrong default looks exactly like a right one. Pass --platform only to ask for LESS than what is present.
palbase link writes the committed palbase/project.json, and that file is what makes a directory act on a project — every later push, plan, status, secret, storage, flags and apikey reads it instead of a flag you have to remember.
palbase push bundles your controllers with Bun on your machine (a missing Bun is a hard refusal, not a fallback), packs the working directory into a gzip tarball with the compiled bundle and its job and hook manifests alongside it, and posts it to the Environment's own Management API. That one request applies the schema and activates the code together, and nothing is torn down to do it: the new release is loaded beside the live one, graded by your own tests, and promoted by a pointer swap, so no request meets a gap.
Four things follow, and each replaces something an older version of these docs claimed:
- There is no repository binding and no
git pushdeploy. There is no GitHub integration on this cloud, no webhook receiver, no deploy modes to choose between, and no preview Environments for pull requests. - There are no migration files.
db/public.tsis the declaration; the push diffs it against the database the project is actually running and applies the result. A change that would take data away comes back as a409, itemised with row counts, and needs--approve. - Zero endpoints is a refusal, not a success. A deploy cannot quietly ship nothing.
- Reserved path prefixes are never handed to your app —
/.well-known,/_artifacts,/_internal,/_panel,/admin,/auth,/oauth,/realtime,/rtand/v1. A controller mounted under one of them cannot be called, so mount/billing/v1/balancerather than/v1/balance.
palbase rollback <digest> serves a stored version again — a pointer move, with nothing rebuilt. See Deploying.
Note: Jobs, webhooks and hooks became real deploy surfaces on 2026-08-26. A CLI older than that emits an empty array for each silently — the push succeeds, the endpoints come up, and every
@Job,@Webhookand@Hookis absent from the artifact. Checkpalbase --versionbefore pushing a project that declares any of them.
Before you push anything, you can run the whole thing on your own machine: palbase start brings up a Docker stack that mounts your source, so saving a controller serves the new version with no build and no version history, and palbase db plan / db apply finally have a database to act on. palbase stop points the checkout back at its project. See Running It Locally.
Limits
Your plan sets how many Projects and Environments you may run and how many requests per
minute they answer. Over that ceiling a call comes back 429 with retry-after: 60, which
the SDKs honour before it ever reaches your catch — see Error handling.
Usage is counted per Organization, and what you are on, what you have used and what it costs are shown in the Studio. Nothing in your code has to know any of it.
Related
- Quickstart — create a project and deploy your first endpoint
- Project Structure — how a backend project is laid out
- Backend SDK Overview — the full authoring model
- CLI: Overview — installing, logging in, and the whole command surface
- Linking a Checkout —
palbase link, the central verb - Running It Locally — the whole stack on your machine with
palbase start - Deploying —
build,plan,push,deploysandrollback - Managing Projects —
palbase project create | list | status | delete - Web SDK · iOS SDK — the generated clients