Build and deploy
Check and bundle an App once in CI with tablewalk build, then run that
build in a container behind a load balancer.
npx tablewalk build --app ./my-app --config ./tablewalk.json # typecheck, validate, bundlenpx tablewalk migrate up --config ./tablewalk.json --migrations ./my-app/migrationsnpx tablewalk migrate stores up --config ./tablewalk.json --app ./build/my-app # the stores its commands and jobs usenpx tablewalk --config ./tablewalk.json --app ./build/my-app \ --commands ./build/my-app/server/commands.mjsWhat you’ll see
Section titled “What you’ll see”- One line per step (typecheck, validation, bundle, database), and an exit code that names the first that failed.
- A
build/my-app/directory that starts without TypeScript, a watcher or a compile, and is byte for byte the same on every run of the same source. - A server that re-hashes the build before serving it, and refuses a changed file or a different tablewalk version.
When to use it
Section titled “When to use it”- Use a build for production: the start-time work is done once, in CI, and a broken App never reaches a server.
- Run the App from its source directory instead while you build it: a save reloads it, and the build is optional.
- The database browser has no build step at all:
npx tablewalk <db>is its whole deployment.
Options
Section titled “Options”| Flag | Does | Default |
|---|---|---|
--app <dir> |
The App; its manifest, pages/ and authority.lock travel together |
required |
--out <dir> |
Where the build goes; replaced only when every step passes | build/<dir name> in the current directory, as every path on the command line is; <dir name>-build beside an App built from its own directory |
--config <path> |
Also runs the database checks and writes the schema types | skipped |
--commands, --jobs, --policy |
The server entries a start takes, bundled | the commands.ts beside the App |
| Exit | 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 authority.lock is stale, or the database check found problems |
4 |
Bundle: a module could not be resolved or transformed |
The bundler is Rolldown, an optional dev dependency;
without it each module is type-stripped on its own. Bundles keep source maps:
run Node with NODE_OPTIONS=--enable-source-maps and a stack trace names the
App’s TypeScript line.
In a container
Section titled “In a container”Build in one stage; ship the build and the pruned node_modules:
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"]tablewalk build --app my-app --commands my-app/commands.ts --dockerfile Dockerfile
writes that file for you from the same options: the build stage runs the build
you named, and the runtime stage is tini, uid 1000, --host 0.0.0.0, PORT
(the platform’s, else 4111) and a health check on /healthz. Nothing is built
by the command itself; run docker build . from the project root, where
package.json and package-lock.json are. A --config is copied to
/config/tablewalk.json and read at start, so keep every secret in it a ${NAME}.
A build can sit anywhere — beside the App, or copied away from the project
that made it: its tablewalk/… imports are answered by the tablewalk that
serves it, the one version it is pinned to. Only a package the App installed
itself needs a node_modules above the build.
The build stage has no database, so also run tablewalk build --config …
against a staging database in CI.
examples/docker
builds Tick, Thread and Compass this way, and
DEPLOY.md covers
binding, publishing and allowed hosts.
On Cloudflare Containers, examples/deploy/cloudflare is a Worker, a
wrangler.jsonc and a once-a-minute cron that keeps the container awake, to
copy beside the generated Dockerfile. It runs a fixed number of instances
(Cloudflare does not autoscale) and is a template, not a tested deploy;
docs/deploy-targets.md in the repository says where it is unfit.
Where an App is served
Section titled “Where an App is served”One App is served at the root (https://host/leads/127). Several each keep
their id (https://host/billing/invoices/9), and --mount main=/ (or
"mount" in the config) puts one at the root beside them. Behind a proxy that
serves tablewalk under a path, say so once with --base-path /tools (or
"basePath", or TABLEWALK_BASE_PATH): every page, asset, cookie and emailed
link then carries it. tablewalk check --app ./main --routes prints the
addresses as served.
An App that signs people in needs a server of its own; put several behind one proxy, each under its own path. Its session cookie is scoped to that path, so two Apps on one host never read each other’s session.
Behind a load balancer
Section titled “Behind a load balancer”Run several instances of the same image and config, each 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.tablewalk check --app ./my-app --config ./tablewalk.json --deploy --replicas 3, run with the deployment’s environment, refuses a SQLite source orauth.store, the defaultauth.storeand a missingBETTER_AUTH_SECRETbefore a deploy does. - Valkey holds what instances share: rate limits and cached API answers, through a cache connection.
- Name the proxy:
--allowed-host(orTABLEWALK_ALLOWED_HOSTS),--trust-proxyfor the balancer whoseX-Forwarded-Forrate limiting may believe (a managed front end with no address to name, such as Cloud Run, takes--trust-proxy-hops 1; one behind Cloudflare alone takes--trust-proxy-cloudflare; without either, every visitor shares one rate limit and one sign-in lockout), andTABLEWALK_PUBLIC_URLfor the address sign-in answers to (with or without the--base-path; either names the same place). Apps that sign people in need a fixed--port. - Health checks poll
/healthzfor liveness and/readyzfor readiness (health and readiness). Under--base-path /deskevery route moves, the probes too:/desk/healthz,/desk/readyz. - Stopping: on SIGTERM
/readyzanswers 503 at once, then the listener closes, accepted work drains, and a response that never finishes is cut after--shutdown-grace(10 s). A balancer that probes needs a beat between the first 503 and the close:--drain-delay 5(orTABLEWALK_DRAIN_DELAY) keeps the listener answering that long. Cloud Run allows 10 s in all, so leave the drain at 0 there or lower the grace; ECS and Kubernetes allow more (stopTimeout,terminationGracePeriodSeconds), and the drain plus the grace must fit inside them. - Every instance runs
--jobs: a job’s slot runs once however many processes serve the App (leases in the job store), and--jobsis also what serves the App’s inbound endpoints, so whichever instance the balancer picks must have it. - Migrations run before the rollout, never at start; every instance only checks that the stores are there.
A suite: mounted Apps
Section titled “A suite: mounted Apps”A suite is several Apps under one host, such as a lender
at / and its other Apps at /leads, /dealer, /site, /marketplace and /customer. In
production, supervise each as its own process, terminate TLS at the proxy,
forward each path to its process with the host preserved, and set
TABLEWALK_PUBLIC_URL and each App’s --base-path. The lender’s / is the
proxy’s fallback after the five subpaths. Mounting under one host shares no
sign-in and grants no cross-App access. No shipped example is a suite;
Suites has the launcher.
Back up and restore
Section titled “Back up and restore”tablewalk keeps nothing of its own beside what it is told to use, so a backup is the databases and the stores the config and the App name:
- The business database, with the engine’s own tool (
pg_dump, or SQLite’s.backuporVACUUM INTO, not a copy of a live file). The command journal, receipts, audit rows and event store live in it, so one backup keeps them consistent with the rows. - The session store:
auth.store, or by default~/.config/tablewalk/auth/<app>.db($XDG_CONFIG_HOMEwhen set). Lost, it signs everyone out; nothing else. - Each other store the config names: an
analyticsstore, a tenant store, a cache needs none. - Files with
storage: 'bucket'are in the bucket: back it up with the database, since a row names its object.
Restore with the server stopped: the databases first, then the stores, then start. A start checks the journal and the stores and refuses what does not match, rather than creating anything.
Take this backup before tablewalk migrate stores up: a step has no
reverse. To go back, restore the backup and run the tablewalk that made it,
which refuses a newer layout by name. The tables __tablewalk_schema_snapshot
and __tablewalk_schema_epoch are caches tablewalk rebuilds, not stores.
Common mistakes
Section titled “Common mistakes”--commands ./my-app/commands.tsbeside a build is refused → name the build’s own entry,./build/my-app/server/commands.mjs; the refusal prints it.- A build refuses to start after an upgrade → a build is pinned to the tablewalk version that made it; rebuild in the pipeline that installs the new one.
- Exit
3with “the effective authority differs from the lock” → review the change, runtablewalk check --app <dir> --write-authorityin the source, then rebuild. Cannot find package '<name>'at start → a package the App’s commands or jobs import is resolved from the build’s folder upward: ship thenode_modulesthat holds it above the build.tablewalk/…itself needs none: a build’stablewalk/commands,tablewalk/jobsand siblings are the serving tablewalk’s own, wherever the build is put.
Related
Section titled “Related”- Connections and services: the config file the server reads.
- Change your schema: migrations before the rollout.
- Reload and restart: a build is never watched.
- Observe and operate: readiness, traces and redrive in production.
- Performance and pools: how many connections a rollout opens.