Palbase
Sign inGet started

Getting Started

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:

<!-- `pb.todos` is generated FROM THIS PROJECT: `palbase build` augments `PB` with its own routes. A sandbox has no project, so the bare `@palbase/web` type has no `todos` — measured: TS2339. The block is right; it is the augmentation that cannot exist here. --> <!-- fragment -->
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, @Hook and @Room classes are listed the same way, in a module's providers. The one thing the runtime still reads by location is the schema: every file under db/ does require export 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.

CapabilityIn your backendOn the client
HTTP API from class controllersOverview, ControllersWeb calls, iOS calls
Postgres, with the schema declared in TypeScriptDatabase, Schema
Row-Level SecurityRow-Level Security
AuthenticationAuthenticationWeb auth, iOS auth
File storageStorageWeb uploads, iOS uploads
Direct-to-storage uploadsDirect Uploads
Document store (JSON, collections)Documents
Cache (JSON key-value, TTL)Cache
Scheduled (cron) jobsScheduled Jobs
Inbound webhooks (Stripe, GitHub, …)Webhooks
Lifecycle hooks on auth, document and file eventsEvent Hooks
Feature flags and per-user overridesFlagsWeb flags, iOS flags
Push, email, SMS and in-app notificationsNotifications
Realtime broadcastRealtimeWeb realtime, iOS realtime
Secrets in a per-Environment vaultSecrets
Disposable test users, and suites graded at deployTest Users, Testing
Product analyticsWeb analytics, iOS analytics
End-to-end encrypted messaging and callsWeb 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:

SurfaceWhat it isDocs
@palbase/backendThe server SDK your controllers, jobs, webhooks and hooks are written against — npm, currently 33.0.0Backend SDK
@palbase/webThe browser client SDK — one pb object, configured by the generated client your app imports through palbase/client.ts — npm, currently 8.0.0Web SDK
PalbeThe Swift SDK for Apple platforms, over Swift Package Manager, with the same pb entry pointiOS SDK
palbase CLIA single Go binary — login, projects, link, the local stack, schema, deploys, module settings, logs and codegenCLI
StudioThe dashboard at https://palbase.studio — endpoints, database, auth users, logs, usage, teampalbase 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 503 for a short while after project create, before it is ready to serve. You rarely meet it: palbase link and palbase push retry a 503 for 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, role anon) is what clients ship. It is safe in a browser bundle or a mobile app, and it is sent on the apikey header.
  • 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 push deploy. 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.ts is 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 a 409, 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, /rt and /v1. A controller mounted under one of them cannot be called, so mount /billing/v1/balance rather 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, @Webhook and @Hook is absent from the artifact. Check palbase --version before 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.