Skip to content

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.

Terminal window
npx tablewalk build --app ./my-app --out ./build/my-app \
--commands ./my-app/commands.ts --jobs ./my-app/jobs.ts

The 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.

Each step prints one line; a failure exits with that step’s code.

  1. Typecheck. tsc -p on the App’s tsconfig.json, or the explicit check from Build with a coding agent when there is none. Without TypeScript installed the step says it was skipped.
  2. Validate, with the same loader a start runs, and compare authority.lock. A relative auth.store path is refused.
  3. 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.
  4. The database, with --config: write the generated types and run the real --check-app on 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.

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 any

Two builds of the same source by the same version are identical, byte for byte.

Terminal window
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.ts beside 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/commands resolves.
  • --check-app accepts a build; --write-authority does 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.

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 build
WORKDIR /srv
COPY package.json package-lock.json ./
RUN npm ci
COPY my-app ./my-app
RUN npx tablewalk build --app my-app --out build/my-app --commands my-app/commands.ts
RUN npm prune --omit=dev
FROM node:24-alpine
RUN apk add --no-cache tini
WORKDIR /srv
ENV NODE_ENV=production
COPY --from=build /srv/package.json ./
COPY --from=build /srv/node_modules ./node_modules
COPY --from=build /srv/build ./build
USER node
ENTRYPOINT ["/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.

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 (or TABLEWALK_ALLOWED_HOSTS) for the forwarded host names, --trust-proxy for the balancer whose X-Forwarded-For rate limiting may believe, and TABLEWALK_PUBLIC_URL for the origin sign-in answers to. Apps that sign people in need a stable nonzero --port.
  • Health checks poll /api/app, or /api/whoami for an App that signs people in.
  • Jobs run in one instance. Start --jobs in 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.

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 public schema: loans (the lending source, writable) and applicants (a platform-only source, never "writable": true). examples/databases/ignition/server.example.json is the config template.
  • Two secrets: BETTER_AUTH_SECRET (32+ characters, signs sessions) and TABLEWALK_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 (mode 0600), never to stdout.
Terminal window
export IGNITION_LENDING_SEED_URL=postgres://…/ignition_lending
export IGNITION_APPLICANTS_SEED_URL=postgres://…/ignition_applicants
export TABLEWALK_AUDIT_KEY="$(node -e 'console.log(require("crypto").randomBytes(32).toString("base64url"))')"
node --import tsx examples/databases/ignition/seed-postgres.mjs /tmp/ignition-fixture

The 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:

Terminal window
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.ts

There is no migration from an earlier Ignition: reseed into new databases. examples/databases/ignition/README.md has the full recipe.