Deployment
How to deployrekkal to Vercel, configure its environment variables and the Stripe webhook, and point an OVH-registered domain at it.
Deploy to Vercel
The project targets Vercel. Import the Git repository into Vercel (or runvercel with the Vercel CLI); Vercel auto-detects Next.js and uses pnpm build as the build command with no extra configuration.
Set environment variables
In the Vercel project settings, add every variable from.env.example — Supabase, Stripe, Resend (including RESEND_FROM), the answer-engine keys, CRON_SECRET, RATE_LIMIT_ADDRESS_SECRET, and the two host URLs. The exceptions are REKKAL_ONBOARDING_FIXTURE and REKKAL_DASHBOARD_FIXTURE, which are local development switches and are ignored in a production build; leave them unset here. Unlike the rest, the engine keys are genuinely optional: an engine with no key is skipped rather than failing the run. CRON_SECRET is not — both scheduled routes fail closed without it (see below). Use test keys for Preview environments and live keys for Production. You can manage these with the Vercel CLI (vercel env) instead of the dashboard.
Two URL variables, not one. rekkal serves two hostnames from this one project: NEXT_PUBLIC_APP_URL is the application origin (auth email links, Stripe’s success and return URLs, the dashboard link in transactional email) and NEXT_PUBLIC_MARKETING_URL is the marketing origin (canonical URLs, metadataBase, the sitemap, JSON-LD, and Stripe’s cancel URL). The marketing one is optional and falls back to the app one, so a single-host deployment keeps working with a single variable — setting the second is what switches the split on. hosts.md has the full contract; below has the operator steps.
Two secrets fail closed, and one of them is easy to miss on a project that already exists. CRON_SECRET is the known one. The other is RATE_LIMIT_ADDRESS_SECRET, the HMAC key the onboarding rate limit pseudonymises the client address with: without it, lib/rate-limit/address-subject.ts refuses rather than storing the address or bucketing every visitor together, so onboarding generation stops working for everyone and says “We could not check your usage. Try again in a moment.” — copy that reads like a transient glitch, not like missing configuration. Add it to each environment before the deploy that first needs it; any high-entropy value does (openssl rand -base64 32). Rotating it changes every stored id at once and therefore resets every in-flight address window, so rotate deliberately, not while it is holding an abuser off. See onboarding.md.
Apply database migrations
Before the app can serve traffic, apply the schema insupabase/migrations/ to your Supabase project — supabase db push (linked project) or run them from the SQL editor. A green db push does not prove the hosted project has the migration; verify by querying it, using the recipe in supabase.md.
Configure the Stripe webhook
In the Stripe dashboard, create a webhook endpoint pointing at the app host:checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted, then copy the endpoint’s signing secret into the STRIPE_WEBHOOK_SECRET environment variable in Vercel. See stripe.md for how the handler verifies and dispatches events.
Schedule the runner
Both schedules ship with the code invercel.json:
That file is the single source of truth for when they fire; there is nothing to register in the dashboard, and nothing should be — a schedule that exists in only one of two places is a schedule that quietly stops matching the cadence the plans advertise. This section is the single source of truth for why that interval and that concurrency, because JSON cannot carry a comment.
One trigger runs the whole measurement pass: reclaim, extraction recovery and orphan close-out are phases of the dispatch fire rather than a second endpoint, so they are exercised every hour and there is no second schedule to drift out of step with this one. The prune is the one thing that is not a phase of it, and deliberately: it shares neither the runner’s data nor its cadence, and folding a deletion into the 240s dispatch window would drop it exactly when the system is busiest. It costs nothing to run — one bounded
delete against an index, daily — and both routes authorise identically, so CRON_SECRET covers both.
Why hourly, and what it costs
The runner’s expression invercel.json is 0 * * * *, hitting /api/cron/run-prompts. That expression requires a Vercel Pro plan — Hobby refuses any cron firing more than once a day, and rejects such a deployment at creation rather than at runtime. Pro also lifts the function limits this runner already presses against, which is why the 300s dispatch budget appears as a binding constraint in the arithmetic below.
The interval is a product decision, not a free knob. It multiplies directly into the run meter and therefore into real COGS, because rekkal absorbs engine cost. What makes hourly load-bearing is capacity: total dispatch is budget × fires, so the interval is what supplies it, and rotation only decides which workspace is served — rotation and interval has that argument and what the schedule carries has the ceiling table.
Firing hourly does not raise engine spend. Spend is bounded by the run meter, and a fire that finds its slot already filled returns alreadyRun without calling any provider — the resumable cursor makes those fires cheap. What it raises is invocation count, which is negligible.
Hourly fires, a weekly measurement
The customer-facing cadence is unchanged and is not the trigger interval:runs.run_datebuckets by ISO week, so a prompt × engine is measured exactly once per week however often the cron fires. The unique key on(prompt_id, engine_id, run_date)andclaim_run_slot()returningexistsenforce that in the database, and a test fires the pass seven times inside one slot to prove it: one row per pair, the meter moved exactly once, zero engine calls on fires two through seven.- It matches the refresh cadence both plans advertise (measurement.md).
- The quotas in
config.limitsare derived asprompts × engines × 5 weekly slots, and are unaffected by the trigger interval — extra fires cost nothing because they find the slot already filled.
Rotation and interval are two different fixes
DISPATCH_BUDGET_MS gives one fire a 240s dispatch window, and that window is not divided across workspaces. A fire works each workspace at full speed until the window is gone, stops, and reports the rest as deferredWorkspaces. Two mechanisms make that safe, and they solve different problems — neither substitutes for the other:
- Rotation fixes allocation.
workspaces.last_dispatch_started_atis stamped when a fire begins dispatching a workspace, and each fire orders by it ascending, nulls first. So the tail of one fire is the head of the next and no workspace starves behind a larger one. The order is derived from data rather than from a stored cursor, which would silently skip a workspace whenever one is added or removed. A test drives three fires where each workspace consumes the entire window and asserts all three are served, in turn. - The hourly interval fixes capacity. Rotation changes which workspace is served, not how much total dispatch there is: capacity is
window × fires. Rotating full slices on a daily cron gives 10 workspaces ~101 calls each per week at a 20s p95 — identical to dividing the window evenly, and equally short of the 300 a full Pro slot needs. Do not revert the interval to daily on the belief that rotation carries it; it does not.
What the schedule actually carries
These are the numbers that multiply into real COGS, because rekkal absorbs the engine cost.config.runner.dispatchConcurrency allows 4 calls in flight per engine (12 across the three), and one fire has the full 240s window, so a turn completes 288 grounded calls at a 10s mean and 144 at a 20s p95. Against the 300 a full Pro workspace needs per weekly slot:
The p95 column is the operative one. The daily row is there for contrast, to show why hourly is required rather than merely nicer.
Read every figure as per-workspace capacity at N workspaces: the ceiling is how many workspaces can each drain 300 calls a week, so at N = 53 a full Pro workspace still clears its slot with 1.5× to spare, and past ~80 it does not clear at all. Nothing is lost when it does not — undispatched pairs come back through
loadCompletedPairs on the next fire — but the measurement arrives late, which is a product failure even though it is not a data failure.
Two caveats a reader should carry with the table:
- It assumes every workspace is a full Pro workspace needing 300 calls a week. A realistic mix goes considerably further: a Free workspace is 10 prompts × 1 engine = 10 calls a week, thirty times cheaper.
- It rests on exactly two assumptions — per-call latency (10s mean / 20s p95) and
DISPATCH_BUDGET_MS(240s). Change either and the whole table has to be redone; the arithmetic is(240 ÷ latency) × 12 × fires ÷ 300.
config.ts next to the value.
Beyond that ceiling: fan-out
The durable answer past ~53 full Pro workspaces is per-workspace fan-out — one invocation per workspace rather than one invocation walking them all — because it removes the shared window entirely and makes capacity scale with the workspace count instead of being divided by it. It is deliberately not built: it needs a queue or a self-invoke path, and the repo has neither today. Reach for it whendeferredWorkspaces stays non-zero fire after fire, which is the signal that the shared budget has been outgrown.
What the declaration cannot carry
CRON_SECRETmust be set in the Vercel project environment. Vercel sends it asAuthorization: Bearer <value>; both routes return401to everything else and fail closed when the variable is unset, so the crons authorise nothing until it exists. On the prune that is load-bearing in its own right: an open deletion endpoint would let anyone clear the counters holding an abuser off. It is one of the two fail-closed variables —RATE_LIMIT_ADDRESS_SECRETis the other, and it gates onboarding generation rather than the runner (above).- The project needs a plan that allows
maxDuration = 300, which the route asks for.
200 and writes nothing, not even a rotation stamp. With workspaces but no active prompts or no engine key configured, the only write is each workspace’s last_dispatch_started_at — stamped before dispatch, deliberately, so a workspace that turns out to have no work still moves to the back of the rotation — and the meter stays untouched in every case. Firing more often than the cadence is likewise free, because the pipeline is idempotent per slot. A pass that runs out of budget resumes on the next fire rather than re-walking. See analytics-pipeline.md.
Serve the two hosts
DNS is registered with OVH (there is no agent skill for OVH — this is a manual step). rekkal serves the marketing site from the apex domain and the application from anapp. subdomain, both from this one Vercel project; see hosts.md for what that split guarantees.
In order:
-
Vercel → Settings → Domains: the apex (
tryrekkal.com) is already attached and serving this project — leave it as the primary domain, since it is the host every canonical URL and the sitemap advertise. What is missing is the subdomain (app.tryrekkal.com): add it, and Vercel shows the exact record it needs. Do not add a redirect from one to the other; the application’s proxy routes between them per path. -
DNS zone: create the record Vercel specifies for the subdomain — typically a
CNAMEforapp→cname.vercel-dns.com. It is a record in the apex zone, alongside the apex’s ownArecord and theCNAMEforwwwthat are already there; no delegation or separate zone is needed for the subdomain.wwwis not one of the two configured origins, and the application does not handle it.surfaceForHostreturnsnullforwww.tryrekkal.com, exactly as it does for a*.vercel.apppreview: no host routing (an app path reached onwwwis served in place rather than moved to the app host), and post-launch/robots.txtonwwwanswers with the marketingallow: /policy for what is a duplicate host. That is harmless only because Vercel redirectswwwto the apex by default, so no request ever reaches the application on it. The “do not add a redirect from one to the other” instruction in step 1 is about the apex andapp.— it does not apply towww, whose redirect is the thing keeping this correct. If someone ever turns that platform redirect off,wwwmust either be removed or added as a real origin. - Wait for propagation, then confirm the subdomain verifies in Vercel and that HTTPS is issued for it — the apex already has both.
-
Environment variables (Production, and Preview if it has its own hosts): set
NEXT_PUBLIC_APP_URLtohttps://app.tryrekkal.comandNEXT_PUBLIC_MARKETING_URLtohttps://tryrekkal.com, then redeploy — both areNEXT_PUBLIC_*and are baked in at build time. Until this step, the deployment behaves as a single host: correct, but everything is served from whichever originNEXT_PUBLIC_APP_URLnames. -
Supabase → Authentication → URL Configuration: set the Site URL to
https://app.tryrekkal.comand addhttps://app.tryrekkal.com/**to the Redirect URLs allow-list. Sign-up confirmation and magic links carryemailRedirectToon the app host and Supabase silently drops a redirect it does not recognise, sending the user to the Site URL instead — so a missed entry here looks like “confirmation does nothing”. -
Stripe: point the webhook endpoint at
https://app.tryrekkal.com/api/webhooks/stripeand copy the new signing secret intoSTRIPE_WEBHOOK_SECRET. The endpoint is a shared path served by both hosts, so either would work — the app host is the deliberate choice, since it is where the rest of the billing round trip lives. Checkout’s success, cancel and portal return URLs are sent per session by the code and need no dashboard change.
Going live
A deployment is pre-launch by default:config.features.preLaunch in config.ts keeps every page noindex, nofollow, makes robots.txt disallow all crawling, empties the sitemap, shows a pre-launch banner on the marketing and auth pages, and adds the sentence on /privacy saying no live card payment has been processed (a claim that stops being true the moment the flag flips, which is why it is a function of the flag rather than prose). Flip it to false — and redeploy — once the advertised product actually ships, or the site stays invisible to search engines.