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@Onhandler 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:
| Option | Default | |
|---|---|---|
palbase | from the environment | The project: url (PALBASE_URL), projectId (PALBASE_PROJECT_ID), secretKey (PALBASE_SECRET_KEY), webhookSecret (PALBASE_WEBHOOK_SECRET) |
auth | a signed-in user | Every route's auth unless the route or its controller says otherwise |
rateLimit | 300 a minute per caller | Every route's limit unless it says otherwise; false for none |
bodyLimit | 1 MiB | The largest request body |
cors | off | The origins browsers may call from: { origins: [...] } or "*" |
db | none | The app's database: its schemas, and its address unless PALBASE_DB_URL says |
openapi | true | Serve the app's contract to the platform, which Studio's backend screens read |
port, host | $PORT (else 8080), 0.0.0.0 | Where it listens |
boot | 5 attempts, 1 s doubling | A 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:
| Variable | Set by | |
|---|---|---|
PORT | the platform | The port to listen on |
PALBASE_URL | the platform | https://<ref>.palbase.studio |
PALBASE_PROJECT_ID | the platform | The project's id — the project_id its access tokens carry |
PALBASE_REPLICAS | the platform | How many copies of the machine run |
PALBASE_PLATFORM_SECRET | the platform | The key the platform signs its calls to the machine with |
PALBASE_DB_URL, PALBASE_DB_CONNECTIONS | the platform, given a database | Its address as the app login, and one copy's share of its connections |
PALBASE_SECRET_KEY | the platform | The machine's own secret key of the project (machine: <name> among its API keys): Palbase.* calls, and checking callers' sessions |
PALBASE_WEBHOOK_SECRET | the platform | The 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.
Related
- Architecture — what goes in each file, and why
- Quickstart — this layout, end to end
- Modules & Dependency Injection — the four lists, and what the boot refuses
- Builds, Logs & Rollback — what the Dockerfile and
.env.exampledecide - Machines — the environment, the health check, and the port