Skip to content

Deploying

Three deployable parts, none of them exotic:

Part What it is Where it runs
apps/site The public site and this documentation — static HTML Cloudflare Pages on vercatus.com
API Hono server; also serves the built workspace from apps/web/dist A container, ROLE=api, on app.vercatus.com
Worker Claims queued runs and crawls; needs Chromium The same image, ROLE=worker, one or more instances
Postgres 17 + pgvector The only stateful service Neon, Railway Postgres, or any Postgres with the vector extension

The repository’s Dockerfile builds one image from Playwright’s base (Chromium and its libraries included) and runs either role by the ROLE variable. .github/workflows/ci.yml typechecks, pushes the schema into a throwaway Postgres, seeds it, builds the site and runs the tests on every push.

Cloudflare Workers (static assets), connected to the repository — this is what serves vercatus.com:

  • Root directory: apps/site (the wrangler.jsonc there names the worker and points it at dist)
  • Build command: npm ci && npm run build · Deploy command: npx wrangler deploy
  • Build watch paths: apps/site/**, so app commits do not rebuild the site
  • Variables: NODE_VERSION=22, SITE_URL=https://vercatus.com
  • Custom domains: vercatus.com and www.vercatus.com

Cloudflare Pages works with the same repository too (root directory empty, build npm ci && npm -w apps/site run build, output apps/site/dist); the Workers flow is what Cloudflare creates by default now.

apps/site/public/_headers sets the content type on /.well-known/http-message-signatures-directory, which is the crawler’s published key. robots.txt and llms.txt ship with the site.

  1. Postgres. Create a database with the vector extension available (create extension vector runs on first db:push). Note its DATABASE_URL.

  2. API service from the Dockerfile with these variables:

    ROLE=api
    RUN_MODE=worker
    AUTH_MODE=multi
    APP_URL=https://app.vercatus.com
    SESSION_SECRET=<32+ random bytes>
    DATABASE_URL=postgres://…
    ANTHROPIC_API_KEY=… # platform key; BYOK workspaces add their own later
    ANTHROPIC_WORKSPACE_ID=… # if the key is org-scoped
    CRUX_API_KEY=… # optional, real-user Core Web Vitals
    WEB_BOT_AUTH_DIRECTORY=https://vercatus.com
    WEB_BOT_AUTH_PRIVATE_KEY_PATH=/secrets/crawler-ed25519.pem # mount the key; never bake it into the image
    REQUIRE_DOMAIN_VERIFICATION=true
    LLM_PROFILE=full

    Expose port 4180; point app.vercatus.com at it.

  3. Worker service from the same image with the same variables and ROLE=worker, WORKER_CONCURRENCY=2. Give it more memory than the API — Chromium is the cost — and scale by adding instances; each run is claimed by exactly one.

  4. Once per deploy (a release command, or a one-off shell): npm run db:push && API_DB_PASSWORD=… npm run db:rls && npm run seed. db:push creates the vector extension and applies the schema; db:rls applies row-level security and creates the vercatus_api role; seed upserts the plans. All three are safe to repeat.

  5. Connect the API as vercatus_api, not as the owner: its DATABASE_URL uses that role and password. The worker and one-off commands keep the owner URL. With that split, a query that ever missed its organisation filter returns nothing rather than another tenant’s rows.

The crawler signs every request with the key at WEB_BOT_AUTH_PRIVATE_KEY_PATH; the matching public key is the one published on the site. Generate a new pair with npm run ca -- keygen <path> and update both places together.

Object storage for page bodies (they are written to .cache on the worker’s disk today, so a worker needs a persistent volume or accepts re-fetching after a restart), structured error reporting, and a status page. See the product plan.