Skip to Content
TechnicalDeployment

Deployment

This guide covers how Sustentus applications are deployed. Each app in the monorepo deploys independently to Vercel as its own project, with its own environment variables and custom domain. Shared packages are built and bundled with the apps that consume them — they are not deployed separately.

For the full pipeline from commit to production (pre-commit, CI, and Vercel preview/production builds), see CI/CD pipeline.

Per-app Vercel configuration

Each app is a separate Vercel project. Set the root directory and build command, then add the app’s environment variables in the project settings.

AppRoot directoryBuild commandOutput directory
Agentapps/agentcd ../.. && pnpm agent:build.next
Marketingapps/marketingcd ../.. && pnpm marketing:build.next
Docsapps/docscd ../.. && pnpm docs:build.next

Install command is pnpm install for all projects.

Continuous deployment

Vercel deploys automatically, phase- and class-aware (blind-until-ready — see CI/CD pipeline): pushing to a ready PR branch publishes preview builds of the affected product apps (draft pushes build nothing), the four quiet apps build only on merge, and merging to main promotes to production. To roll back, open Deployments, find a previous deployment, and click Promote to Production.

Custom domains

In Project Settings > Domains, add the domain and configure DNS; the SSL certificate is provisioned automatically.

The web app is served at two domains on the one project: platform.sustentus.com and demo.sustentus.com. The second is the public demo front door — the app decides which host it is answering on from the DEMO_HOSTS allowlist, which is read at build time, so setting or changing it requires a redeploy. See Demo environment for the full runbook.

Environment variables

Manage variables in the Vercel dashboard or via the CLI:

vercel env add CLERK_SECRET_KEY production vercel env pull .env.local

Variables by app

Marketing:

# Required NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_... CLERK_SECRET_KEY=sk_... # Optional NEXT_PUBLIC_POSTHOG_KEY=phc_... NEXT_PUBLIC_POSTHOG_HOST=https://...

Agent:

# Required NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_... CLERK_SECRET_KEY=sk_... AI_GATEWAY_API_KEY=... MONGODB_URI=mongodb+srv://... MONGODB_DATABASE_NAME=sustentus-... # Optional PLATFORM_APP_URL=https://... AGENT_AUTHORIZED_PARTIES=https://ai.sustentus.com,https://platform.sustentus.com

The two Clerk variables must point at the same Clerk instance as the platform — that is what makes a platform session valid in the agent app on a shared root domain. Without them the app builds and then fails on every request, because the Clerk middleware cannot initialise. The Vercel project is agentic-interface, rooted at apps/agent.

AI_GATEWAY_API_KEY is the Vercel AI Gateway credential, and it is needed on two projects — agentic-interface (the assistant and its availability probe — the customer’s requirements conversation included) and web (the onboarding concierge) — for Production and Preview on each. Declaring it in turbo.json → globalEnv governs what turbo passes into build tasks under strict env mode and nothing else; a serverless function reads its own Vercel project’s runtime environment, which turbo.json does not populate. A missing key is silent rather than loud: the SDK falls back to the deployment’s OIDC identity and, failing that, throws, which the availability probe reports as unknown — and unknown renders nothing. Read the variable directly (vercel env ls --scope sustentus) rather than inferring it from a quiet UI. See AI Gateway for the account, the balance, and the smoke-test procedure.

The two Mongo variables are newer: since agentic-tenant-context the agent resolves the caller’s tenant and reads that tenant’s data through @sustentus/services/server, so it opens a database connection like the platform does. They carry the same runtime-not-build-time caveat as the gateway key — both are in turbo.json → globalEnv already, which governs the build and populates nothing at request time — so set them on agentic-interface for Production and Preview, pointing at the same databases the web project uses. Without them a tenant user’s context resolves to “no workspace” and a staff or partner caller is told their tenant directory cannot be listed: honest empty states rather than a crash, which is exactly why the misconfiguration is quiet. Read the variables directly rather than inferring them from a working page.

PLATFORM_APP_URL is the origin the platform app (web) is served from: since agent-shell-layout-polish the agent’s header carries a labelled link back to the platform, and this variable is its target. It is parsed with the same rules as the console’s CONSOLE_APP_URL — a bare host is completed to https, http for localhost — and it is a runtime read with the same caveat as the variables above, so set it on agentic-interface for Production and Preview. Unset, it fails safe: the header simply renders no platform link, which is why this misconfiguration is quiet too.

AGENT_APP_URL is the other direction, and it is set on web, not here: the origin this app is served from, which the platform’s Complete customer conversation hands a customer off to (/start, the agent app). Same parsing rules, same runtime caveat — set it on web for Production. Unset, the platform offers no hand-off and the requirements page says the assistant is not configured; since the platform no longer has a BRD chat of its own, that leaves a customer with no way to write their requirements, so treat it as required wherever customers are.

On a preview the value is derived, not configured: every PR’s previews carry their own hosts, so web hands off to the agent app’s branch alias for the same branch (https://agentic-interface-git-<branch>-sustentus.vercel.app, from VERCEL_GIT_COMMIT_REF), and the agent app’s Back to your request returns to web’s alias the same way. The agent app builds a preview on every ready PR (scripts/vercel-ignore.sh @sustentus/agent --every-ready) so that alias always exists. Vercel shortens an alias longer than one DNS label (63 characters) with an undocumented hash, so a branch that long derives nothing and falls back to AGENT_APP_URL’s preview value, where one is set — otherwise that PR’s preview offers no hand-off.

AGENT_AUTHORIZED_PARTIES is the agent app’s counterpart to the console’s CONSOLE_AUTHORIZED_PARTIES, added by ai-foundation-hygiene: a comma-separated allowlist of the origins whose Clerk sessions this deployment will accept, passed to clerkMiddleware as authorizedParties. It is deliberately opt-in and empty by default. Sharing authentication across subdomains of one root domain is exactly what lets a platform session sign the user in here without satellite configuration, and it is the same property a compromised sibling subdomain would abuse — naming the origins shuts that door in production, while leaving it unset keeps preview deployments working, since they are served from per-commit hostnames no allowlist can predict. Set it on Production only, and note that until you do, the variable buys nothing: the code reads it, the security benefit starts when a value exists. The same is true of CONSOLE_AUTHORIZED_PARTIES on tenant-management.

Documentation: none required (static documentation).

Firewall rules (WAF)

Two projects meter a route with a Vercel WAF rate-limit rule. Both rules are dashboard configuration on their own project, not code — nothing in the repository creates them, which is why they are written down here at all. Each route stays correct whether or not its rule exists yet, which is what lets a rule be added or retuned with no deploy.

ProjectPathKeyed byLimitActionConsulted
web/api/embed/*IP address30 requests per minuteDeny — 429 from the edgeNothing; the edge decides
agentic-interface/api/chatSigned-in useras set on the project — see belowRate Limit API — id agent-chatcheckRateLimit in the route

The embed rule’s 30 per minute is a deliberate ceiling rather than a measurement: a visitor submits once, twice if they mistyped an address, and a whole office behind one NAT is still nowhere near it, while a caller sustaining more than that is not filling in a form. Retune it in the dashboard and update the row here; nothing needs a deploy. The agentic-interface threshold is whatever is currently set on that project and is not mirrored here — read it from that project’s Firewall tab rather than from a copy in these docs that nothing keeps honest.

The two are shaped differently on purpose, and the difference is where the key comes from.

The embed rule keys on the caller’s address, which the edge can read for itself, so it is enforced at the edge: a burst never invokes the function, costs no database operation, and leaves no row in lead_form_submissions. That is the whole point of it — /api/embed/* is public by construction, since the form key is printed in the snippet a vendor pastes into their own website, and the submission log is bounded only by its 30-day TTL multiplied by whatever rate a caller sustains. apps/web therefore carries no @vercel/firewall dependency and needs no /.well-known/vercel/rate-limit-api/** exemption in its proxy.ts.

The agent rule keys on the signed-in user, which the edge cannot see, so its rule is a Rate Limit API rule that the chat route consults through @vercel/firewall’s checkRateLimit — paying the function invocation because that is the only place the identity exists. It fails open by design, and its route-side details are in Agent → Spend envelope.

Neither rule is the per-tenant request budget the platform still owes itself; they are the two local shapes that budget would generalise.

Scheduled jobs (cron)

The web app runs scheduled work through Vercel cron jobs , declared in apps/web/vercel.json. Crons run only against the production deployment.

SchedulePathJob
0 1 * * */api/cron/demo-resetPurges every isDemo: true tenant back to the approved storyline, so the demo opens clean each morning.
0 2 * * */api/cron/csm-portfolio-snapshotWrites the daily per-CSM portfolio snapshot so the go live trend is a real day-over-day delta.
0 3 * * */api/cron/bid-deadline-sweepCloses bid-pool leads whose bid deadline has passed — Unmatched when nobody bid, No fit when every submitted proposal was rejected — and warns the CSM 48 hours ahead of a looming close.
0 4 * * */api/cron/gone-cold-sweepSends a follow-up to the customer of a lead that has gone quiet during its BRD, then alerts the assigned CSM if the silence continues. Each step sends first and stamps the lead on delivery, so a step nobody received is not recorded as taken; a failure is re-attempted on the next run exactly once. Due steps are collected across every tenant and taken most overdue first (business days past the earliest step not yet taken), so a lead the budget does not reach tonight goes first tomorrow. Each lead is re-read just before its step: one whose customer has been active since the sweep read it, or that has left the BRD stage or had its BRD approved, gets no step, and a stamp or retry marker is written only while the customer’s last activity is still the one the sweep read. A nudged lead with no CSM is counted as skipped and logged, never queued. Every lead whose stamps it wrote has its workspace revalidated. It never closes a lead — the Gone cold close stays a human decision.
0 5 * * */api/cron/notification-retry-sweepThe re-attempt for notifications whose sender never runs again. The invoice.raised that reached no manager is re-attempted once, oldest failure first across every tenant, and every raise is taken before any tenant’s pool work is even read; a second failure is recorded against the invoice rather than retried again. Then, as each tenant’s pool work is read, its match claims that never resolved — a match row still marked as told whose notifyClaimedAt stamp is over 10 minutes old (MATCH_CLAIM_GRACE_MS): a send whose release write failed, or a function that died mid-send — are released as one failed send each, on every lead in the pool or not, up to 50 per tenant oldest stamp first; the release that reaches the cap records one retry-exhausted notification abandonment, and one under it is re-sent with the pool’s untold leads below (matchClaimsReleased, matchClaimsAbandoned). Then the bid pool, in three phases each taken oldest owed first across every tenant: a pool lead whose matcherPendingAt is over 10 minutes old has its matcher re-run (up to 10 per tenant, fewest failed re-runs first — matcherFailedRuns, which a re-run that does not finish adds one to — then oldest stamp; and none started once less than 15 seconds of the work budget is left, SWEEP_SEND_RESERVE_MS, so the sends and SDM picks after them always keep that much); then every pool lead with a matched expert still untold and owed another go is re-sent (up to 200 per tenant, by its oldest such row’s createdAt, settled after the re-runs so a lead a re-run finished is skipped and one it left untold is included) — a match notification is sent at most twice across every caller (MATCH_NOTIFY_MAX_ATTEMPTS, counted in the match row’s notifyFailedAttempts), and the second failure leaves the expert untold, records one retry-exhausted notification abandonment against the lead, and is never collected again unless an SDM adds the expert by hand, which clears the count; then every lead in an SDM-load status with no SDM is handed one (up to 50 per tenant, oldest lead first). Matcher re-runs repeat every night until the lead leaves the pool, and SDM picks until the lead has an SDM. What the budget does not reach keeps its marker for the next night, and goes first in its phase then.
0 6 * * */api/cron/sdm-coverage-snapshotWrites the daily per-SDM coverage grid — its cells, their shortfalls and the revenue each exposes. The grid is derived fresh on every read and keeps no history of its own, so this row is the only thing the SDM’s coverage drill-downs can read an opening figure from.

The order is deliberate: the reset runs an hour before the snapshots so each morning’s snapshot describes the freshly reset world rather than the residue of yesterday’s walkthrough, and the two lead sweeps run after the CSM snapshot so their counts land in the next day’s portfolio. The retry sweep runs after those, so anything the gone-cold sweep failed to send an hour earlier is already marked by the time it looks. The coverage snapshot runs last, so the demand and availability it records are what the day’s sweeps have already settled.

The gone-cold and notification-retry sweeps share one work budget (SWEEP_WORK_BUDGET_MS, 40 seconds, in packages/services/src/db/services/sweep/budget.ts) inside their 60-second function. Each reads its due work for every tenant first — four tenants at a time, in a collection pass that starts no new tenant after 20 seconds (SWEEP_COLLECT_BUDGET_MS) or once the work budget is spent, over a tenant list rotated by UTC day — each day’s start sits a golden-ratio fraction (≈ 62%) of the list round from the day before, so an overrun leaves different tenants unread each night: a pass that reads 40% of the list or more leaves no tenant unread more than two nights running, 30% or more, four (sweep/collect.ts) — then sends one item at a time in its order (sequential, paced against the email provider’s rate limit) and starts nothing new once the budget is spent, so the route always gets back to revalidating the workspaces it changed. The retry sweep collects twice: its invoice raises, then — after the raises are taken — its pool work. Both answer 200 { ok: true, … } with deferred (work read but left for the next night), uncollectedTenants (tenants never read) and stoppedEarly (either is non-zero), and log a stopped early warning when they cut a run short.

All three sweeps skip isDemo: true tenants (SWEEPABLE_TENANT_FILTER in db/services/tenant/demo-guard.ts). They run two to four hours after the reset has re-seeded the demo storyline, so without the exclusion every night’s fresh documents would be re-evaluated as if they were real work — and demo seats are attached to live Clerk users by email, so a nudge, alert or automated close there would reach a real inbox.

Thresholds for the gone-cold sweep are tenant settings rather than constants — goneCold.nudgeBusinessDays (default 3) and goneCold.alertBusinessDays (default 7), both in business days and both editable under Settings → Automations.

Cron routes are protected by a shared secret. Set CRON_SECRET for the web app’s Production environment (declared in apps/web/.env.example); unset, every cron request is refused. Vercel sends it as Authorization: Bearer <CRON_SECRET> on each cron request, and the route rejects anything else with 401, so it is unreachable from a normal user session.

vercel env add CRON_SECRET production

Build verification

Test a production build locally before deploying:

pnpm marketing:build cd apps/marketing pnpm start # Visit http://localhost:3001

Static export (docs)

The documentation site can be exported as a static site by setting output: "export" in apps/docs/next.config.ts; pnpm docs:build then emits to apps/docs/out/ for upload to any static host.

Storybook (archived)

apps/storybook was archived on 2026-09-23: its Vercel project deploys nothing (vercel.json → git.deploymentEnabled: false, ignoreCommand: "exit 0") and it is not in .icm/project.json → deploy.projects. See Storybook and apps/storybook/README.md.

Learn More

Last updated on