deployment / public site deployment

Public site deployment

Vercel public-site deployment, PostHog setup, and runtime separation.

Public Site Deployment

The public marketing and docs site is an Astro hybrid site in packages/site. It is separate from the Randal runtime, dashboard, Railway deployments, and self-hosted randal serve process.

Use this guide when you want a hosted marketing/docs preview or production site. Do not use it to deploy an agent runtime.

What Gets Deployed

  • Source package: packages/site
  • Build command: bun run site:build
  • Output mode: Astro hybrid output with @astrojs/vercel/serverless
  • Static pages: marketing/docs pages are prerendered by Astro
  • Server route: /api/personalize is a Vercel serverless function when classifier personalization is configured
  • Server routes: /api/auth/* and /api/account/* back web sign-up, sign-in and plan purchase (see "Accounts And Plan Purchase" below)
  • Server-rendered pages: /signup, /login, /plans, /account, /account/password and /auth/callback read the session cookie and cannot be prerendered
  • Docs content source: README.md and allowlisted docs/*.md files read at build time

The site does not run the Bun/Hono agent runtime in production. Vercel serves prerendered pages from its output and runs only the site API routes that are explicitly marked server-side.

Separation From Runtime Deployments

The public site is only the marketing/docs surface:

  • It does not start randal serve.
  • It does not host the dashboard or gateway runtime.
  • It does not require RANDAL_API_TOKEN, Discord secrets, LiveKit, Twilio, or Railway variables.
  • It only needs a model provider key if generated landing-page classification is intentionally enabled.
  • It does not change self-host or Railway runtime deploy behavior.
  • The site's PostHog snippet, when enabled, is included only in packages/site static pages. (Other surfaces have their own keys and transports — see docs/telemetry.md.)

Use docs/deployment-guide.md for local, Railway, Docker, and imported-service runtime deployments.

Use the Vercel GitHub integration for PR previews and production deploys. This keeps site deploys in Vercel and avoids a duplicate GitHub Actions deploy path.

  1. Create or log in to a Vercel account, ideally under the Hassion Studio team or org.
  2. Import the GitHub repository Hassion-Studio/randal.
  3. Configure the Vercel project with these settings:
SettingValue
Framework presetAstro
Install commandbun install
Build commandbun run site:build:vercel
Output modeVercel Build Output API via the Astro Vercel adapter, promoted from packages/site/.vercel/output to .vercel/output
Node runtimeNode 20 (package.json sets engines.node to 20.x)

The repo includes vercel.json with the same settings. The extra Vercel build script exists because the Astro app lives in packages/site, while Vercel's Build Output API expects .vercel/output at the deployed project root. Normal self-host users can ignore this file; it is inert unless the repo is connected to Vercel or a deploy command is run.

For local verification, use Node 20 before running bun run site:build if you need generated Vercel function metadata to match production. The adapter derives the function runtime from the Node process that runs the build.

Environment Variables

Copy packages/site/.env.example for local site development or set the same variables in Vercel.

VariableRequiredDescription
PUBLIC_SITE_URLRecommendedCanonical site origin, for example https://randal.bot or a Vercel preview URL.
PUBLIC_POSTHOG_ENABLEDOptionalSet to true to enable PostHog on the public site. Any other value disables it.
PUBLIC_POSTHOG_KEYOptionalPublic PostHog project key. Analytics stay disabled if this is missing.
PUBLIC_POSTHOG_HOSTOptionalPostHog ingest host. Defaults to https://us.i.posthog.com; use the EU host if needed.
OPENROUTER_API_KEYOptionalServer-only key for landing-page classifier personalization. Leave unset to use static presets only.
RANDAL_PERSONALIZATION_MODELOptionalClassifier model ID. Defaults to openai/gpt-4.1-mini.
PUBLIC_TURNSTILE_SITE_KEYRequired with TurnstilePublic Cloudflare Turnstile site key for protected classifier requests.
TURNSTILE_SECRET_KEYRequired in production if classifier is enabledServer-only Turnstile secret. When set, /api/personalize verifies tokens server-side before any model call.
RANDAL_PERSONALIZE_ALLOW_UNPROTECTEDEmergency/internal onlySet to true only to intentionally allow production classifier requests without Turnstile. Default behavior fails closed.
RANDAL_SITE_SESSION_SECRETRequired in production for accounts32+ character secret that encrypts the session cookie. Generate with openssl rand -base64 32. The site refuses to serve any account page in production without it; local dev falls back to a known development key.
PUBLIC_SUPABASE_URLOptionalHosted GoTrue origin. Defaults to https://auth.randal.bot — the same identity the desktop and mobile apps use.
PUBLIC_SUPABASE_ANON_KEYOptionalSupabase publishable key. Defaults to the production key the apps already ship.
RANDAL_PROXY_URLOptionalMetering proxy origin for billing and usage. Defaults to https://proxy.randal.bot.

Do not set runtime secrets such as RANDAL_API_TOKEN, DATABASE_URL, a runner's OPENROUTER_API_KEY, legacy migration keys, or any agent runtime credential on the public site project. If OPENROUTER_API_KEY is set for the landing classifier, keep it server-only and configure Turnstile protection or the explicit unprotected override.

Accounts And Plan Purchase

randal.bot signs people up and sells plans directly. It is the same account as the apps: the same hosted GoTrue (auth.randal.bot), the same metering proxy, the same Stripe subscription. Someone who buys on the web downloads the app and signs in — nothing is re-purchased.

How the session is held

The browser never holds a token. Auth happens in same-origin API routes (/api/auth/*), which seal the GoTrue access and refresh tokens into an httpOnly, Secure, SameSite=Lax cookie encrypted with RANDAL_SITE_SESSION_SECRET (AES-256-GCM). Every billing call goes out from the server with the Bearer attached (/api/account/* → the metering proxy), so a marketing-site XSS has nothing to steal and the proxy needs no CORS policy for browsers.

Social sign-in uses PKCE, not the implicit fragment flow, for the same reason: GoTrue returns a ?code= that /auth/callback exchanges server-side. Email confirmation and password-recovery links still arrive as URL fragments (that is how GoTrue's own /verify redirect works), so /auth/callback also has a small client-side path that posts those tokens to /api/auth/adopt, which verifies them against GoTrue before sealing anything.

One consequence of the server-side design needs naming: GoTrue now sees Vercel's egress IP on every call, not the visitor's, so its own per-IP rate limiting can no longer slow a password-guessing run. src/server/auth-rate-limit.ts puts that limit back where the real IP still exists — 10 credential attempts and 4 recovery emails per IP per minute. It is per serverless instance and therefore best-effort; GoTrue's limits and password rules remain the backstop.

One-time setup

  1. Site: set RANDAL_SITE_SESSION_SECRET in the Vercel project (production and preview, if previews should authenticate). Rotating it signs everyone out — sealed cookies simply stop opening — which is the intended emergency lever.

  2. Supabase → Authentication → URL Configuration: add these to the redirect allow-list (project ytnseohjyvisffnadpib), or every social sign-in and every confirmation link fails at the last hop:

    • https://randal.bot/**
    • https://randal-*-hassion-studio.vercel.app/** — Vercel preview and branch-alias hosts
    • http://localhost:5174/** — local development

    The globstars are load-bearing, not laziness. Every redirect_to this site sends carries a query string — /auth/callback?state=… for OAuth, /auth/callback?next=… for confirmation and recovery links — and Supabase matches the whole URL. An exact https://randal.bot/auth/callback entry therefore matches nothing we actually send. In Supabase's matcher the separators are . and /: * stops at them, ** crosses them, which is why the host wildcard is * (it spans the hyphenated deployment slug but not .vercel.app) and the path wildcard is **.

    Leave the existing randal://, randal-staging://, randal-dev:// and http://127.0.0.1:7600/onboarding entries alone — those are the mobile apps and the desktop onboarding.

    The last two entries belong to STAGING, not production (verified 21 Aug: production's list carries only https://randal.bot/**, the three app schemes and 127.0.0.1:7600). Since 21 Aug 2026 Vercel preview deployments and local development authenticate against the staging project (wbihhvdtalfpefseixcx; the site's Preview environment sets PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY and RANDAL_PROXY_URL), whose own redirect list carries https://randal-*-hassion-studio.vercel.app/** and http://localhost:5174/**. Those two must be removed from the production project — otherwise any preview deployment can mint a production session. Production's final list is the five entries in docs/staging-environment-plan.md §8 ("Auth redirect allow-lists, per project").

    Symptom when this is wrong: GoTrue silently discards the unlisted redirect_to and falls back to the project's Site URL, so a preview sign-in lands on https://randal.bot/?code=… — production, holding a code it has no verifier for.

  3. Metering proxy: set the web return URLs so Stripe sends the browser back to the site rather than to the desktop app's 127.0.0.1 onboarding:

    • RANDAL_BILLING_WEB_SUCCESS_URL=https://randal.bot/account?checkout=success
    • RANDAL_BILLING_WEB_CANCEL_URL=https://randal.bot/account?checkout=cancel

    Both default to exactly those values, so a stock deploy already behaves; set them explicitly when the site lives on another origin.

The flow

/pricing  →  /signup?plan=pro  →  /plans?plan=pro  →  Stripe Checkout  →  /account?checkout=success
             (GoTrue signup)      (POST /api/account/checkout,           (polls the subscription
                                   platform: "web")                        until the webhook lands)

The ?checkout=success bounce arrives a beat before Stripe's webhook writes the subscription on the proxy, so /account polls /api/account/summary for up to a minute and says so while it waits. It never claims a plan the ledger has not confirmed.

Deliberate omissions

  • No cancellation button. Cancelling issues a prorated refund through the proxy and belongs with the confirmation UI in the app; the site links to Stripe's billing portal instead.
  • No card fields anywhere. Stripe's hosted Checkout and billing portal collect payment details. The site never sees a card.
  • No account enumeration. /api/auth/reset always answers 200, whether or not the address has an account.

Landing Personalization Deployment

The homepage personalization endpoint is intentionally narrow:

  • It is a classifier/router only. Model output can select a static preset id, but rich homepage copy, CTAs, quote, FAQ, feature rows, and workflow cards all come from static site-owned preset data.
  • The endpoint rejects production requests without an exact allowed Origin header based on PUBLIC_SITE_URL.
  • If TURNSTILE_SECRET_KEY is set, requests must include a valid Cloudflare Turnstile token verified server-side.
  • In production, if no Turnstile secret is configured, the endpoint fails closed unless RANDAL_PERSONALIZE_ALLOW_UNPROTECTED=true is explicitly set.
  • If no model key is configured, the browser uses deterministic static presets and the site remains useful.

Safe rollout order:

  1. Deploy the hybrid site with OPENROUTER_API_KEY unset and verify static preset personalization works.
  2. Set PUBLIC_SITE_URL to the production origin and redeploy.
  3. Add Turnstile keys and verify challenge-protected requests in preview.
  4. Add OPENROUTER_API_KEY only after the protection path is verified.
  5. If anything fails, remove OPENROUTER_API_KEY; the site falls back to static presets.

PostHog Setup

PostHog is opt-in. This section covers the public site only; the desktop app, mobile app, and gateway have their own keys and their own privacy rules — see Telemetry for the full picture.

  1. Create a PostHog project.
  2. Copy the public project key.
  3. In Vercel, set PUBLIC_POSTHOG_ENABLED=true.
  4. Set PUBLIC_POSTHOG_KEY=phc_....
  5. Set PUBLIC_POSTHOG_HOST=https://us.i.posthog.com or the EU ingest host.
  6. Redeploy the site.

If PUBLIC_POSTHOG_ENABLED is absent, not true, or PUBLIC_POSTHOG_KEY is empty, the built site does not include the PostHog snippet.

Domain Setup

  1. In Vercel, open the site project settings.
  2. Add the production domain, for example randal.bot.
  3. Follow Vercel's DNS instructions for your registrar.
  4. Set PUBLIC_SITE_URL to the final production origin.
  5. Trigger a production redeploy so canonical URLs use the production domain.

Preview deployments can use Vercel's generated preview URLs. If you need exact canonical preview URLs, set PUBLIC_SITE_URL in the Vercel preview environment.

GitHub Actions Deployment

Do not configure a GitHub Actions Vercel deploy workflow by default. The current deployment path is the Vercel GitHub app, which owns PR previews and production deploys without repo-managed Vercel tokens.

A GitHub Actions deploy could be added later for a specific need, but it should remain an explicit alternative to the Vercel integration, not a second active deploy path for the same site.

Local Verification

Before pushing deployment changes, run:

bun run site:build
bun run test:site
bun run lint

To verify PostHog is disabled by default, build without public PostHog env vars and confirm the generated HTML has no PostHog snippet. To verify opt-in behavior, run a build with PUBLIC_POSTHOG_ENABLED=true and PUBLIC_POSTHOG_KEY=phc_test and confirm the static pages include the public-site snippet.