Palbase
Sign inGet started

CLI

Running It Locally

palbase start brings up a whole Palbase on your machine — Postgres, the platform services, the runtime that executes your controllers, and the same edge proxy that fronts a deployed stack — and points this checkout at it by writing a record outside the checkout, under ~/.palbase/checkouts/<hash>/local.json, which wins over the linked project for as long as it exists. Your source is mounted rather than deployed: save a controller and the running stack serves the new version, with no build, no artifact and no version history. The reason to reach for it is that the answers it gives are the answers the deployed system gives — the same auth, the same Postgres with your row-level security in force, the same bundler, the same reserved-prefix table — so what is expensive to discover after a deploy is cheap to discover here.

Quick example

palbase start          # bring it up and point this directory at it
palbase stop           # shut it down and point the directory back at its project
palbase start --reset  # throw the database away and build it from db/public.ts
▸ starting todoapp
▸ applying every module's schema
  secrets: this checkout is linked to an address, so there is no environment to pull from
▸ http://127.0.0.1:54321 (local)
  edit a controller and the running stack serves it — no build, no deploy
  schema changes are applied with `palbase db apply` — `push` is for the cloud
  `palbase stop` points this checkout back at its project

palbase start needs a backend checkout — db/public.ts and at least one *.module.ts in the tree, the same pair push and plan require — and Docker with its compose plugin (run palbase doctor first; see below). The first start on a machine pulls the images it runs, so it takes a few minutes and needs a network. Every start after that is seconds, and nothing else has to be installed: not Go, not Bun, not the Palbase repository. The compose document travels inside the binary, so palbase init followed by palbase start works in a directory that has never heard of Palbase's own source tree.

Check Docker before you start

palbase doctor reports the three Docker facts a start depends on, each with the command that fixes it:

  ✓ docker     /opt/homebrew/bin/docker
  ✓ compose    Docker Compose version v2.29.0
  ✓ creds      docker-credential-osxkeychain

They are separate lines because they fail separately, and two of them fail in ways that read like something else. docker: unknown command: docker compose looks like a typo and is a missing plugin. docker-credential-desktop not found looks like a login problem and is a ~/.docker/config.json still naming a helper that left with Docker Desktop — the daemon is healthy, the config points at a binary that is gone. Both were hit on a real first-run, one after the other, each discovered only when start failed with Docker's own raw error.

One address, and it belongs to the edge

Four containers come up, and exactly one of them is on your machine's network: the edge. Everything you reach — your controllers, auth, the database API, realtime — arrives through that single address, the same way it arrives in the cloud. The runtime, palsvc and Postgres talk to each other on a private Docker network and publish nothing.

That is not a detail about ports. The edge decides which requests are yours and which are the platform's, refuses a cross-origin call your app would be refused after a deploy, and rate-limits the unauthenticated auth endpoints on the same budget a deployed stack uses. Before, that table was only exercised in the cloud, and a prefix somebody forgot to route showed up as a customer's endpoint answering 404 in production. Now it is exercised on every palbase start.

start picks a free port and stores it with the rest of the boot values, so a restart lands on the same address — a URL written into that machine-local record, a generated client and an iOS build all point at a number, and picking a fresh one every start would quietly break each of them. Two projects can therefore run side by side on one machine without either knowing about the other.

The database has no host port, so a GUI or a psql -h 127.0.0.1 has nothing to connect to. Open a session inside the container instead:

docker exec -it palbase-<group>-postgres-1 psql -U palbase palbase  # a session
palbase db query 'select count(*) from todos'                       # or one statement

<group> is the name palbase start prints beside ▸ starting: this directory's own name, lowercased, with anything outside a-z 0-9 _ - replaced by a hyphen. The compose project is palbase-<group>, which is where the container names come from.

Ten path prefixes belong to the platform

Ten prefixes are reserved and answer at the door rather than reaching your code:

/.well-known   /_artifacts   /_internal   /_panel   /admin
/auth          /oauth        /realtime    /rt       /v1

Matching is on a segment boundary, so /v1 and /v1/balance are the platform's while /v1beta and /administrators are still yours. This list is byte-locked against the edge's own route table, and it is the same list in the cloud and here — which is the point of running the edge locally.

Warning: Nothing refuses a push that declares a route under one of them. palbase push in a linked checkout sends the artifact straight to the project's own Management API, and neither the CLI nor the stack holds this list — so the push succeeds, the contract goes on declaring the route, palbase spec generates a client method for it, and every call answers 404 with nothing anywhere reporting an error. That is measured, not hypothetical: a project shipped four routes under /v1/* — its whole public API — and all four 404'd silently. The one gate that reads these prefixes lives in the a different push route, which no working configuration reaches, and even there it runs after the artifact has been activated.

Running the edge locally is what actually catches it: mount the controller, call it here, and the 404 arrives before a deploy can hide it.

It rebuilds as you save

There is no watch flag and no separate dev server. The runtime serves this directory: save a controller and it rebuilds and reloads, in a couple of seconds.

[runtime] dev: reloaded in 2483ms — endpoints=39 digest=9f3c1a2b7e04

Settings do not work that way, and no file makes them: flags, buckets, notification senders, the egress allowlist and the auth module's settings live on the stack and are written by their own commands, here exactly as in the cloud. palbase flags add, palbase storage add, palbase egress add, palbase notifications add and palbase auth settings set all land immediately against the local stack. There is no config/*.ts that a save applies — nothing evaluates that directory any more. See Stack Settings.

What acts on the stack, and what still means the cloud

Where a verb acts is decided by the checkout, not by a flag: the machine-local record palbase start writes exists, so it wins. In a checkout start has pointed at itself, these act on your stack:

build plancompile and check your controllers with the same bundler a push uses
db plan db apply db queryyour schema and your data
secret set/list/remove runthe stack's own vault
test-user create/list/templates/clone/deleteaccounts you can sign in as
flags storage egress notifications authsettings, written straight to the stack
specthe contract, and the generated client for every linked app
status deploys logswhat is running and what it is doing

Run palbase stop and the same verbs go back to the project palbase/project.json names.

Two behave differently on purpose, and each says so rather than pretending:

push refuses. The dev runtime serves the directory it has mounted and never follows the deploy pointer, so a push here would build an artifact and activate a version nothing loads. It refuses rather than warning, because the failure it would otherwise cause is silent — "I pushed" would be true and "it shipped" would not:

this checkout is pointed at the stack running on this machine, which already serves this
directory — a push here would activate a version nothing loads.
  palbase stop       point it back at the project, then push
  palbase db apply   if it was the schema you wanted applied here

logs reads the containers. A local stack keeps no log history to fetch, so the command reads the four containers directly — your runtime first, then palsvc, then the edge, then Postgres — instead of naming the docker logs command that would. --source envoy is worth knowing: it is where a request that reached neither your code nor the platform shows up, which is exactly the request the other sources cannot explain. --source, --level, --since, -q/--query, --limit (100 by default, 1–500) and -f/--follow all work here, and --source takes a container name — runtime, palsvc, envoy or postgres — with an unknown one answered by the list of valid ones. --follow interleaves the containers, and one that is down is reported and skipped, which is exactly when the others matter.

Warning: --json is accepted against a local stack and silently ignored — the local reader has no JSON mode, so you get the same human output with no message saying the flag did nothing. It works against a cloud project.

palbase db is local-only in the other direction: it acts only on a stack running on this machine, and a cloud-linked checkout is refused with the same fixed message a directory with no stack at all gets — it names palbase start and palbase push as the two ways out and never names the project this checkout is linked to. See Database.

Where it writes

Three places on this machine, and none of them is a credential copied into your repository — nor, any more, anything gitignored inside the checkout:

PathCommitted?What it holds
~/.palbase/checkouts/<hash>/local.jsonnot in the checkoutthe running stack's address; it wins over palbase/project.json
~/.palbase/stacks/<group>/not in the checkoutthe boot values, the port, and the vendored compose file
~/.palbase/local-stacks.jsonnot in the checkoutthe register: which group is running where

The boot values are secrets and they are per-machine, which is why they live under your home directory: a .env in a repository is a .env somebody commits.

start writes no credential anywhere. It used to copy the stack's key into ~/.palbase/credentials.json so every verb could find it, but a copy is a thing to keep in step — stop left it behind, and --reset gave the stack a new key while the copy went on claiming the old one. The key exists once, in the group's state directory, and the credential resolver reads the original. That is also what makes an app checkout in another directory work with no extra step: the register says which group owns this address, and the group's directory holds the key.

PALBASE_STACK_DIR overrides the vendored compose file with a directory containing your own docker-compose.dev.yml. It is an escape hatch for people editing the stack definition itself, not a requirement.

The images are named by three variables, each with a default start checks for before compose does, so a missing image is this command's own refusal with the line that builds it rather than a registry error from compose:

VariableDefault
PALBASE_PALSVC_IMAGEghcr.io/palgroup/palbase/palsvc:0.36.1
PALBASE_RUNTIME_IMAGEghcr.io/palgroup/palbase/runtime-dev:0.36.1
PALBASE_EDGE_IMAGEghcr.io/palgroup/palbase/edge:0.36.1

The runtime default's -dev suffix is load-bearing: the shipped stack builds its runtime under the plain name, so writing a dev image over that tag would leave a production stack running a dev image the next time it is recreated.

Secrets on the way up

After the stack is up and this checkout is credentialed, start copies the secrets of the environment this checkout resolves to into the local vault — the same environment every verb would act on: --env <name> or PALBASE_ENV, otherwise this machine's selection, otherwise the project's only environment. Values move vault to vault, never through a file and never through the terminal, so the first thing after a start is not setting every credential again by hand. A name whose local value has moved since it was last pulled is kept, tracked by hash rather than by value, so a SENTRY_DSN you deliberately pointed at a throwaway project survives tomorrow's start. The line names where the values came from and counts them rather than listing them:

  secrets: 4 pulled from todoapp/main · 1 kept (changed here since)

Nothing is deleted, and what stayed behind is counted. The pull adds and overwrites; a name your local stack holds that the chosen environment does not have is somebody's work, so it is left alone — and said, because a stack still carrying another environment's key while the line reads "pulled from todoapp/staging" is the quiet version of the accident this whole mechanism exists to prevent:

  secrets: 2 pulled from todoapp/staging · 3 left (not in todoapp/staging)

When the environment cannot be read at all — no cloud session, an --env name the project does not have, an environment that does not answer — the stack still comes up and the line carries the cause:

  secrets: not pulled from todoapp/staging — no credential for https://p8vn2c4rm.palbase.studio

It never fails a start. With several environments and none chosen it pulls nothing and says how to choose — palbase start --env staging pulls staging's secrets for that start without remembering the choice:

  secrets: not pulled — todoapp has 2 environments and none is selected:
  main      k3xq81w4m
  staging   p8vn2c4rm
  palbase env use <name>        remember one for this checkout
  palbase <verb> --env <name>   act on one without remembering it

A checkout linked to a stack by address has no environments to choose between, and says so:

  secrets: this checkout is linked to an address, so there is no environment to pull from

Set what the local stack needs with palbase secret set — see Secrets.

--lan, for a phone on the same wifi

palbase start --lan

By default the edge binds loopback. --lan publishes it on this machine's network address instead, and writes that address into the machine-local record and the register — which is where palbase link reads it, so a phone gets an address it can actually resolve rather than 127.0.0.1, which on a phone is the phone.

--reset, and what a stop keeps

palbase start --reset

--reset takes the containers down with their volumes and brings the stack back up empty, so the schema comes back from db/public.ts on the next boot rather than from a dump. It prints ▸ removing the local database before it does. This is the only verb that throws local data away: there is no db reset, and nothing in palbase db does it.

palbase stop is not a reset. It removes that machine-local record first — a stop that failed halfway used to leave it behind pointing at a dead address, and every later verb answered with a connection refusal instead of "there is no local stack" — then deregisters the stack and takes the containers down, keeping the volumes. Start again and the data you left is there.

▸ stopped
  back to https://k3xq81w4m.palbase.studio

If the stack does not answer within 90 seconds of coming up, start says so and leaves the containers running, naming the command that explains why:

The containers are still up — `docker compose -p palbase-todoapp logs` says what happened

Signing in against it

Auth is real, including the parts that usually surprise people: a sign-in attempt with no proof-of-work solution is answered 403 pow_required with a challenge, exactly as it would be in production, and the client SDKs solve it and retry without you doing anything. The token you get back is a real token, and the gates in your controllers — a route that requires a verified address answering 403 email_not_verified, a missing role — answer here the way they will after a deploy.

If you just want an account to sign in as, palbase test-user create mints one and hands you its id, email, password and access token, once. test-user clone and test-user create --template <name> work here too: the stack reads your live schema to work out who owns a row and which foreign key attaches a nested one, so a minted user arrives holding the data the template stored on this stack declares.

Note: A fresh stack holds no templates. config/test-users.ts is no longer read by anything — evaluated by no build, shipped in no artifact — and the CLI has no verb that creates a template — the only writer is PUT /v1/management/test-users/templates on the stack's own management surface. palbase test-user templates lists what this stack actually holds. See Test Users.

What it is not

A development and test rail, not a way to run Palbase in production. Its edge serves plain HTTP rather than TLS and binds loopback unless you ask for --lan — a certificate you made yourself is one every client on your machine refuses, so the local stack does without one — and it holds one project.

It is also not where a deploy comes from: an artifact is built and activated by palbase push against a cloud Environment, and the local stack never produces one. What it gives you is the same behaviour, earlier.

  • Linking a Checkout — the two files that decide what a directory acts on, and why the local record wins
  • Databasedb plan, db apply and db query, which act only on this stack
  • Secrets — the vault these commands write, and palbase run
  • Stack Settings — flags, buckets, senders and the egress allowlist, on the stack rather than in a file
  • Test Users — minting accounts and cloning their data
  • Deploying — what happens after palbase stop
  • Overview — the full command surface