Getting Started

Project Structure

A Palbase backend is a directory a machine builds: a TypeScript app that Bun runs as it is, a Dockerfile that says how, and a .env.example that names what it must be given. Nothing in it is generated by a tool of Palbase's, and nothing is read by its location but the file src/main.ts starts with — what exists is what the modules list.

This page walks that directory file by file. The layout is the one Palbase's own examples and Studio's backend use.

The layout

notes-backend/                       ← the machine's directory (its `path`)
├── Dockerfile                       how the machine builds and runs it
├── .env.example                     the variables it must be given
├── package.json                     its dependencies — `palbase` among them
├── bun.lock                         what `bun install --frozen-lockfile` installs
├── tsconfig.json                    decorators on, with their emitted types
└── src/
    ├── main.ts                      PalbaseFactory.create(AppModule, config)
    ├── app.module.ts                the root module: imports every domain
    ├── db/
    │   └── public.ts                the `public` schema: tables, indexes, policies
    └── modules/
        ├── notes/
        │   ├── dto.ts               schemas: each a value and a type
        │   ├── notes.controller.ts  routes
        │   ├── notes.service.ts     logic
        │   └── notes.module.ts      what the domain owns, and exports
        └── core/
            ├── core.module.ts
            └── health.controller.ts

Two rules decide everything else:

  • A class exists when a module lists it. A @Controller, an @Injectable() or an @On handler that no module lists stops the boot, naming it. A folder grants nothing.
  • A domain is a folder with a module in it, named in the root module's imports (or in the imports of a module the root imports).

src/main.ts

The process starts here, and it is short:

// src/main.ts
import { PalbaseFactory } from "palbase/backend";
import { AppModule } from "./app.module";
import schema from "./db/public";

await PalbaseFactory.create(AppModule, {
  db: { schemas: [schema] },
  cors: { origins: ["https://app.example.com"] },
});

PalbaseFactory.create builds every class, runs their onStart() and serves — on $PORT, which the machine sets. Everything in its config is the backend's own, with a default:

OptionDefault
palbasefrom the environmentThe project: url (PALBASE_URL), projectId (PALBASE_PROJECT_ID), secretKey (PALBASE_SECRET_KEY), webhookSecret (PALBASE_WEBHOOK_SECRET)
autha signed-in userEvery route's auth unless the route or its controller says otherwise
rateLimit300 a minute per callerEvery route's limit unless it says otherwise; false for none
bodyLimit1 MiBThe largest request body
corsoffThe origins browsers may call from: { origins: [...] } or "*"
dbnoneThe app's database: its schemas, and its address unless PALBASE_DB_URL says
openapitrueServe the app's contract to the platform, which Studio's backend screens read
port, host$PORT (else 8080), 0.0.0.0Where it listens
boot5 attempts, 1 s doublingA start whose onStart() fails is tried again from scratch

A value missing or malformed stops the boot before any request is served, naming both ways to give it. What cannot heal by waiting — a missing setting, a DI mistake — stops at once; a database not up yet is waited for. See Backend SDK.

src/app.module.ts

// src/app.module.ts
import { Module } from "palbase/backend";
import { CoreModule } from "./modules/core/core.module";

// One import per domain: NotesModule joins this list beside CoreModule.
@Module({ imports: [CoreModule] })
export class AppModule {}

The root owns nothing of its own here; it is the list of domains. A small app may list a controller or two in it directly.

src/modules/<domain>/

One folder per domain, and in it the four files of Architecture: the module, the controller, the service and the dto.ts. A domain that needs another's class names it in a constructor, and its module imports the module that exports it:

// src/modules/core/health.controller.ts
import { Controller, Get } from "palbase/backend";

@Controller("/health", { auth: false })
export class HealthController {
  @Get("")
  health(): { ok: true } {
    return { ok: true };
  }
}
// src/modules/core/core.module.ts
import { Module } from "palbase/backend";
import { HealthController } from "./health.controller";

@Module({ controllers: [HealthController] })
export class CoreModule {}

A route's path is its own: /health above is the app's, at the machine's address. Two first segments are not yours — webhooks, where the platform delivers the app's events and each @Webhook class takes a third party's (Webhooks), and _palbase, the platform's own way in — and a controller mounted under either is refused.

src/db/

One file per Postgres schema, each with a default export, defineSchema("<name>", { tables }): public.ts for public, billing.ts for a billing schema. src/main.ts hands them to db.schemas.

// src/db/public.ts
import { defineSchema, defineTable, text, timestamp, uuid } from "palbase/backend";

const notes = defineTable("notes", {
  columns: {
    id: uuid().primaryKey().defaultRandom(),
    owner: text(),
    title: text(),
    created_at: timestamp().defaultNow(),
  },
});

export default defineSchema("public", { tables: [notes] });

A release migrates the database to them beside the version that serves; there are no migration files (Schema, Schema changes).

Database.public.<table> is typed by a declaration file written from those schemas with Palgres' makeEnvDts — commit it beside them, and write it again when a schema changes (Database).

An app with no database has no src/db/, and no db in its config.

package.json

{
  "name": "notes-backend",
  "private": true,
  "type": "module",
  "scripts": { "start": "bun run src/main.ts" },
  "dependencies": {
    "palbase": "^0.51.5"
  }
}

The SDK is one npm package, palbase: npm install palbase (or bun add palbase). A backend imports the framework from palbase/backend, which brings what it is built on — palbase/server (the project's modules, as Palbase) and palgres (the database) — and reflect-metadata, which the framework loads itself. The package ships compiled JavaScript with its type declarations; Bun runs the app's own TypeScript as it is. z from palbase/backend is the zod the framework is built with: import it from there, and the app and the framework agree on one. palbase init writes this package.json.

tsconfig.json

{
  "compilerOptions": {
    "target": "es2023",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

experimentalDecorators and emitDecoratorMetadata are what make constructor injection work: the container reads each constructor's parameter types from the metadata TypeScript emits. Bun reads them from this file. A runner that does not emit the metadata — esbuild, and the tools built on it — leaves the container nothing to read, and the boot stops naming the class and the cure. noEmit is right: nothing is compiled ahead of time, and tsc --noEmit is the type check.

Dockerfile

FROM oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
COPY tsconfig.json ./
COPY src ./src
USER bun
CMD ["bun", "run", "src/main.ts"]

The machine builds the directory's Dockerfile when it has one; without one, Railpack detects what the directory is and plans the image (Builds). A backend ships its own, for two reasons: the decorators need tsconfig.json beside the sources, and bun run src/main.ts is exactly how the app is meant to start. Pin the base image by digest, so a rebuild of the same commit builds the same image.

The process listens on $PORT and stops cleanly on SIGTERM: it finishes the requests in flight (20 seconds at most), runs onShutdown(), and exits — which is what a rollout asks of the version it replaces.

.env.example

# The names this backend reads. The machine is started with them;
# their values are never in the repository.
PALBASE_URL=
PALBASE_PROJECT_ID=
PALBASE_SECRET_KEY=

A build reads it as the list of variables the program needs, and does not roll out until the machine has every one — its own environment, its secret set, or what the platform gives it. It names variables, never their values: commit it, and keep the values in the project's secrets (Builds).

What the process is given

On a machine, the program finds these in its environment:

VariableSet by
PORTthe platformThe port to listen on
PALBASE_URLthe platformhttps://<ref>.palbase.studio
PALBASE_PROJECT_IDthe platformThe project's id — the project_id its access tokens carry
PALBASE_REPLICASthe platformHow many copies of the machine run
PALBASE_PLATFORM_SECRETthe platformThe key the platform signs its calls to the machine with
PALBASE_DB_URL, PALBASE_DB_CONNECTIONSthe platform, given a databaseIts address as the app login, and one copy's share of its connections
PALBASE_SECRET_KEYthe platformThe machine's own secret key of the project (machine: <name> among its API keys): Palbase.* calls, and checking callers' sessions
PALBASE_WEBHOOK_SECRETthe platformThe secret the project's events reach the machine signed with, at the endpoint the platform keeps for it

And what it answers the platform, at paths the internet never reaches: GET /_palbase/health — 200 while it serves, 503 while it stops, the machine's health check. Its contract is written by its build (run with PALBASE_CONTRACT_OUT, the app writes it and exits), not asked of it while it serves.

Running it on your own machine

The same directory runs anywhere Bun does:

PALBASE_URL=https://<ref>.palbase.studio \
PALBASE_PROJECT_ID=<project id> \
PALBASE_SECRET_KEY=pb_<ref>_s… \
PORT=8080 bun run src/main.ts

It is a client of the project like any other: the secret key reaches the project's modules, and a caller's token is checked against the project. Studio does not show the project's id, which a machine is given; it is the project_id claim of any access token the project issues — the one your web app's session holds. Off a machine there is no PALBASE_PLATFORM_SECRET, so the contract is not served. An app with a database also needs PALBASE_DB_URL, a database whose schema it can bind to; a release migrates the database before the version that reads it starts — locally, run the app once with PALBASE_DB_MIGRATE=start and the owner's address in PALBASE_DB_OWNER_URL (Schema changes).

In a repository of several apps

A backend need not be a repository of its own. In a monorepo it is one directory of it — the machine's path — and an app that imports a sibling package is built with the repository's root as its context, its Dockerfile copying from there (Connecting GitHub). A push builds the machines whose directory it changed, and no other.