Build and deploy an App
This page turns an App into a production build and runs it: in a container, behind a load balancer, or as the multi-tenant Ignition sample.
npx tablewalk build --app ./my-app --out ./build/my-app \ --commands ./my-app/commands.ts --jobs ./my-app/jobs.tsThe build is optional: an App still runs from its source directory, and the database browser has no build step. A build does the start-time work once, in CI — typecheck, validation, bundling — and writes a directory a server starts from without loading TypeScript or watching files.
| Flag | Meaning |
|---|---|
--app <dir> |
The App directory. Its manifest, pages/ and authority.lock travel together. |
--out <dir> |
Where the build goes; default build/<dir name>. Replaced only when every step passes, and only if it is new, empty or an earlier build. |
--config <path> |
A connection config. With it, the database checks run too. It may list this App (and no other): its apps[].env is what the definition is built with. |
--commands, --jobs, --policy |
The same server entries a start takes, bundled. --jobs needs --commands. |
The build works in a staging directory and moves it into place at the end, so a failed build leaves the previous one untouched.
What it checks
Section titled “What it checks”Each step prints one line; a failure exits with that step’s code.
- Typecheck.
tsc -pon the App’stsconfig.json, or the explicit check from Build with a coding agent when there is none. Without TypeScript installed the step says it was skipped. - Validate, with the same loader a start runs, and compare
authority.lock. A relativeauth.storepath is refused. - Bundle the definition and the server entries as ESM with source maps,
then load the bundled App and refuse it unless it matches the source.
Custom components are written as a
components/graph, content-named and hashed, served without Rolldown. - The database, with
--config: write the generated types and run the real--check-appon the staged build. Without--config, or when the database does not open, these checks run at start instead.
| Exit code | Meaning |
|---|---|
0 |
Built. |
1 |
Could not build: a bad flag, no --app, an --out it will not replace. |
2 |
Typecheck errors. |
3 |
Validation: the App does not load, its lock is stale, or --check-app found problems. |
4 |
Bundle: a module could not be resolved or transformed. |
What the App wrote is bundled; what it installed is not. The bundler is
Rolldown, an optional peer
(npm install --save-dev rolldown); without it each module is type-stripped
on its own. Bundles are unminified: run Node with
NODE_OPTIONS=--enable-source-maps and a stack trace names the App’s
TypeScript line.
The output
Section titled “The output”build/my-app/ tablewalk-build.json versions, checks, entries and a hash of every file app.json the compiled App the server starts from authority.lock copied from the App, when it has one tablewalk-schema.d.ts the generated types, when --config reached the database definition/app.mjs the App definition, bundled server/commands.mjs --commands, bundled (and jobs.mjs, policy.mjs) server/chunks/ modules the entries share components/ compiled custom components, when the App declares anyTwo builds of the same source by the same version are identical, byte for byte.
Serve the build
Section titled “Serve the build”npx tablewalk --config ./tablewalk.json --app ./build/my-app \ --commands ./build/my-app/server/commands.mjs \ --jobs ./build/my-app/server/jobs.mjs- Name the build’s own entries.
--commands ./my-app/commands.tsbeside a build is refused with the path to pass instead. - The server re-hashes the build before serving it; a changed or missing file is refused.
- A build is never watched or reloaded. A change is a rebuild and a restart.
- Keep the build inside the project that installs tablewalk, so
tablewalk/commandsresolves. --check-appaccepts a build;--write-authoritydoes not (write the lock in the source, then rebuild).
A build is pinned to the tablewalk version that made it. Any other version refuses to start it and says to rebuild, so an upgrade is a rebuild in the same pipeline that installs the new version.
In a container
Section titled “In a container”Build in one stage and ship the build with the runtime. tablewalk is a
dependency; typescript and rolldown are dev dependencies the prune removes:
FROM node:24-alpine AS buildWORKDIR /srvCOPY package.json package-lock.json ./RUN npm ciCOPY my-app ./my-appRUN npx tablewalk build --app my-app --out build/my-app --commands my-app/commands.tsRUN npm prune --omit=dev
FROM node:24-alpineRUN apk add --no-cache tiniWORKDIR /srvENV NODE_ENV=productionCOPY --from=build /srv/package.json ./COPY --from=build /srv/node_modules ./node_modulesCOPY --from=build /srv/build ./buildUSER nodeENTRYPOINT ["/sbin/tini", "--", "node_modules/.bin/tablewalk"]CMD ["--host", "0.0.0.0", "--config", "/config/tablewalk.json", \ "--app", "build/my-app", "--commands", "build/my-app/server/commands.mjs"]The build stage has no database, so run tablewalk build --config … against a
staging database in CI as well.
examples/docker
builds Tick, Thread and Compass this way, and
DEPLOY.md covers
binding, publishing and allowed hosts.
Behind a load balancer
Section titled “Behind a load balancer”Run several instances of the same image and config. Each must be stateless:
- PostgreSQL holds the state: business data, the command journal, events,
jobs, and sessions in a
postgres://auth.store. A SQLite source or the default session file ties the App to one instance. - Valkey holds what instances share: public-form rate limits and cached API answers, through a cache connection.
- Name the proxy:
--allowed-host(orTABLEWALK_ALLOWED_HOSTS) for the forwarded host names,--trust-proxyfor the balancer whoseX-Forwarded-Forrate limiting may believe, andTABLEWALK_PUBLIC_URLfor the origin sign-in answers to. Apps that sign people in need a stable nonzero--port. - Health checks poll
/api/app, or/api/whoamifor an App that signs people in. - Jobs run in one instance. Start
--jobsin one worker replica and serve requests from the rest. See events and jobs. - Migrations run before the rollout, never at start: journals, the auth store and job stores are provisioned by a maintenance step, and every instance only attests them.
A multi-tenant App: deploying Ignition
Section titled “A multi-tenant App: deploying Ignition”Ignition is the multi-tenant sample and the one with its own requirements. It is not a Docker Compose target.
- Two dedicated PostgreSQL databases, each with an empty
publicschema:loans(thelendingsource, writable) andapplicants(a platform-only source, never"writable": true).examples/databases/ignition/server.example.jsonis the config template. - Two secrets:
BETTER_AUTH_SECRET(32+ characters, signs sessions) andTABLEWALK_AUDIT_KEY(the support audit store’s key). - Seed once, into new databases. The seed never replaces anything; an
existing object refuses the run. Credentials go to
credentials.json(mode0600), never to stdout.
export IGNITION_LENDING_SEED_URL=postgres://…/ignition_lendingexport IGNITION_APPLICANTS_SEED_URL=postgres://…/ignition_applicantsexport TABLEWALK_AUDIT_KEY="$(node -e 'console.log(require("crypto").randomBytes(32).toString("base64url"))')"node --import tsx examples/databases/ignition/seed-postgres.mjs /tmp/ignition-fixtureThe seed provisions every store startup attests, through the public
tablewalk/maintenance entries: the tenant store, the administration journal,
invitations, the command journal, tenant command events, the job store and the
support audit store. Then serve it with all four entries:
BETTER_AUTH_SECRET=… TABLEWALK_AUDIT_KEY=… \npx tablewalk --config /absolute/path/server.json \ --app examples/apps/ignition \ --policy examples/apps/ignition/tenant-policy.ts \ --commands examples/apps/ignition/commands.ts \ --jobs examples/apps/ignition/jobs.tsThere is no migration from an earlier Ignition: reseed into new databases. examples/databases/ignition/README.md has the full recipe.