Skip to content

Production Deployment

This is the runbook for deploying The Counsel to your own production domain. The hosted edition at the-counsel.co.uk follows exactly these steps.

The stack is Vercel + Clerk + Neon Postgres + Cloudflare R2 + Resend. Each piece is free to start with; the steps below assume you control the domain you want to deploy under.

Prefer to skip the deployment dance?

Use the hosted edition at the-counsel.co.uk. You only need this guide if you want your own branded instance.


What you'll need before starting

  • A registered domain (this guide uses your-domain.example as a placeholder — replace with yours throughout)
  • A Vercel account with the GitHub integration connected
  • A Clerk account
  • A Neon Postgres database
  • A Cloudflare R2 bucket
  • A Resend account with the domain verified
  • Optional: a Google Cloud project with OAuth Client (for "Continue with Google")
  • DNS access for the domain — Cloudflare DNS works well

Estimated time: 30–45 minutes for the first deployment.


Step 1 · Vercel project

Connect the repo and set the Root Directory. This is the single most common deployment mistake.

  1. Vercel dashboard → Add newProject → import the GitHub repo (ai-legal-uk or your fork).
  2. On the import screen, set Root Directory to dashboard. The repo has a minimal root package.json (only docx for tooling); the real Next.js app lives in dashboard/. If Root Directory is ./, Vercel runs npm install from the root and the build fails with "No Next.js version detected".
  3. Framework preset: Next.js (auto-detected once Root Directory is correct).
  4. Build command, install command, output directory: leave the auto-detected values.
  5. Don't deploy yet — env vars come first. Click Save instead of Deploy.

Already deployed and getting build errors?

If you connected the GitHub integration before fixing Root Directory, you'll see GitHub-triggered builds fail with "No Next.js version detected" while CLI deploys (vercel --prod) succeed. The CLI uses dashboard/.vercel/project.json; the GitHub integration uses the project's stored Root Directory. Fix it under Settings → Build & Development Settings → Root Directory → dashboard and redeploy.


Step 2 · Clerk production migration

Two instances per Clerk app

Each Clerk app has a Development and a Production instance. They have separate API keys, separate user pools, and separate Social Connection configs. Make sure you are operating on the Production instance for every step below — the dropdown in the top-left of the Clerk dashboard is the switch.

2a · Create the production instance

  1. Clerk dashboard → your app → top-left toggle → switch to Production.
  2. If your app is still development-only, click Activate Production under Settings → Plan and follow the prompts.
  3. On the Domains page, set your Primary domain to your apex (e.g. the-counsel.co.uk). Clerk will display a list of CNAMEs that need to land in DNS.

2b · Add the Clerk DNS records

You will see five CNAMEs. Add each to your DNS provider as CNAME records, DNS only (not proxied — Cloudflare proxy breaks Clerk's TLS):

HostTarget
accountsaccounts.clerk.services
clerkfrontend-api.clerk.services
clk._domainkeydkim1.<your-clerk-instance>.clerk.services
clk2._domainkeydkim2.<your-clerk-instance>.clerk.services
clkmailmail.<your-clerk-instance>.clerk.services

The DKIM/clkmail values are unique per Clerk instance — copy from the Clerk Domains page exactly. Common gotchas to verify:

  • Type is CNAME, not A. Cloudflare's "Quick Add" defaults to A.
  • Names have no typos. clk._domainkey not lk._domainkey. googleusercontent.com not googleuserconten at.com (yes, watch for paste artifacts that introduce spaces).
  • Targets match exactly, including the host. accounts points at accounts.clerk.services; clerk points at frontend-api.clerk.services. They are not interchangeable.

After saving all five, click Verify configuration in Clerk. All five rows should flip green within minutes. Clerk then auto-issues TLS certs for clerk.<your-domain> and accounts.<your-domain>.

You can sanity-check propagation from a terminal:

bash
dig +short CNAME clerk.your-domain.example       # → frontend-api.clerk.services
dig +short CNAME accounts.your-domain.example    # → accounts.clerk.services

2c · Copy the production keys

On the API keys page (production mode):

  • Copy the Publishable key (pk_live_…)
  • Click Show on the Secret key and copy (sk_live_…)

2d · Configure the webhook

On the Webhooks page:

  1. Click Add Endpoint.
  2. URL: https://your-domain.example/api/webhooks/clerk
  3. Subscribe to events: user.created and user.updated.
  4. Save, then on the new endpoint's page copy the Signing Secret (whsec_…).

The webhook handler in dashboard/app/api/webhooks/clerk/route.ts upserts the users row in Postgres and triggers the Resend welcome email on user.created.

2e · Optional — wire up Google OAuth

If you want a "Continue with Google" button:

  1. Google Cloud Console → APIs & Services → OAuth consent screen:
    • User Type: External
    • App name, support email, app domain (your-domain.example), authorised domains (your-domain.example).
    • Add yourself as a Test user, or click Publish app when ready for the public.
  2. Credentials → Create credentials → OAuth client ID:
    • Type: Web application
    • Authorised JavaScript origins: https://your-domain.example and https://accounts.your-domain.example
    • Authorised redirect URIs: https://clerk.your-domain.example/v1/oauth_callback (this is the Clerk-side callback — note clerk. not accounts.)
    • Save and copy the Client ID and Client Secret.
  3. Clerk dashboard → Configure → User & authentication → SSO connections → Google:
    • Toggle on "Use custom credentials" (this toggle gates whether your custom Client ID is actually used; if it's off, Clerk falls back to its shared default which doesn't exist in your Google project and Google returns invalid_client).
    • Paste the Client ID and Client Secret.
    • Save.

Common Google OAuth errors

  • invalid_request — Missing client_id → Clerk's Use custom credentials toggle is off, or the Client ID field is empty.
  • invalid_client — The OAuth client was not found → The Client ID stored in Clerk doesn't match what Google has. Re-paste from Google with copy/paste (don't retype).
  • Access blocked: Authorization Error for your Gmail → OAuth consent screen still in Testing mode and your Gmail isn't in the test users list. Add it, or publish the app.
  • redirect_uri_mismatch → Authorised redirect URIs in Google must include https://clerk.your-domain.example/v1/oauth_callback. The accounts. subdomain is for JS origins, not redirect URIs.

Step 3 · Neon Postgres

  1. Create a Neon project. The free tier is plenty for first launch.
  2. From Neon's connection details, copy the connection string. Make sure it ends with ?sslmode=require.
  3. Save it as DATABASE_URL for use in Step 6.

The Drizzle schema is committed at dashboard/lib/db/schema.ts. Migrations are generated and committed under dashboard/lib/db/migrations/.

To apply migrations against a fresh Neon database:

bash
cd dashboard
DATABASE_URL='postgres://...' npx drizzle-kit migrate

Subsequent schema changes follow the standard Drizzle workflow: edit schema.ts, run npx drizzle-kit generate, commit the new SQL file, redeploy.


Step 4 · Cloudflare R2

R2 stores uploaded documents and cached audio briefings.

  1. Cloudflare dashboard → R2Create bucket (e.g. the-counsel-documents).
  2. Manage R2 API Tokens → create a token with Object Read & Write scope on this bucket.
  3. Capture: Account ID, Access Key ID, Secret Access Key, Bucket name.

The dashboard's R2 client lives in dashboard/lib/r2.ts and signs presigned URLs for uploads.


Step 5 · Resend

  1. Resend dashboard → DomainsAdd Domain → enter your apex (e.g. your-domain.example).
  2. Resend issues SPF, DKIM and DMARC records — add all three to DNS.
  3. Wait for the domain status to flip to Verified.
  4. API Keys → create a key (Sending Access scope is enough). Save it.

The welcome email template is in dashboard/lib/email.ts. The tagline (skill count, agent count) is read from skill-registry.json at module load — it stays correct as the registry grows.


Step 6 · Vercel environment variables

Set the following on the Vercel project (Settings → Environment Variables → Production scope). Use the CLI for non-secret values, paste the secret ones interactively so they don't end up in shell history.

bash
cd dashboard

vercel env add NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY production
# paste pk_live_… (this one is technically public — it ships in the client bundle)

vercel env add CLERK_SECRET_KEY production
# paste sk_live_…

vercel env add CLERK_WEBHOOK_SIGNING_SECRET production
# paste whsec_…

vercel env add DATABASE_URL production
# paste the Neon connection string

vercel env add R2_ACCOUNT_ID production
vercel env add R2_ACCESS_KEY_ID production
vercel env add R2_SECRET_ACCESS_KEY production
vercel env add R2_BUCKET_NAME production

vercel env add RESEND_API_KEY production
vercel env add RESEND_FROM_EMAIL production
# e.g. "The Counsel <hello@your-domain.example>"

# Optional — OCR fallback for scanned PDFs
vercel env add OCR_PROVIDER production            # "openai"
vercel env add OPENAI_API_KEY production          # sk-...
vercel env add OCR_OPENAI_MODEL production        # "gpt-5-nano"

Don't paste secrets into chat

The non-public values above (sk_live_*, whsec_*, DATABASE_URL, R2 keys, Resend API key, OpenAI key) are credentials. Paste them into the vercel env add interactive prompt so they go straight to Vercel without entering shell history or AI assistant transcripts. If a secret does leak — even into a private chat — rotate it. Most providers make rotation a single click; nothing is lost by being paranoid here.


Step 7 · Deploy

Two ways:

A. GitHub-triggered (recommended): push any commit to main. The GitHub → Vercel integration auto-builds with the new env vars. Subsequent deploys happen automatically on every merge.

B. CLI-triggered:

bash
cd dashboard
vercel --prod --force

--force skips the build cache so client-bundled env vars (anything NEXT_PUBLIC_*) get refreshed.

Once the deployment status flips to Ready, attach your apex domain under Settings → Domains if you haven't already.


Verification checklist

Open the site in a fresh browser window and confirm:

  • [ ] Homepage loads at https://your-domain.example with no console errors
  • [ ] Browser console does not show "Clerk: Clerk has been loaded with development keys" — if it does, you're still on pk_test_* instead of pk_live_*
  • [ ] Network log shows requests to clerk.your-domain.example rather than <random>.clerk.accounts.dev
  • [ ] /sign-up mounts a Clerk form. Sign up with email + password, complete the email verification, and land at /onboarding
  • [ ] Welcome email arrives in your inbox (check spam if not — DKIM mis-configuration is the usual culprit)
  • [ ] After onboarding, /chambers greets you by name and shows the empty matters state
  • [ ] Create a matter, upload a sample PDF, run /legal review — the live analysis returns a real Contract Safety Score and findings
  • [ ] If Google OAuth is configured: /sign-up → Continue with Google redirects to Google's consent screen, you allow, and you land back at /onboarding

Common issues

"No Next.js version detected" on a GitHub-triggered build

Your Root Directory is ./ not dashboard. Settings → Build & Development Settings → Root Directory → dashboard. Trigger a redeploy.

Build succeeds locally with vercel --prod but fails when GitHub auto-deploys

Same root cause as above — the CLI reads dashboard/.vercel/project.json which already knows the project lives in this directory. The GitHub integration uses the project-level Root Directory setting and bypasses the local file.

Welcome email arrives but says the wrong skill count

The tagline is read from dashboard/lib/skill-registry.json at module load. If you forked and added skills, the count auto-updates. If the deployed version is stale, redeploy.

Clerk has been loaded with development keys warning after env-var swap

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is bundled into the client at build time, not read at runtime. Force a rebuild: redeploy with Use existing build cache unchecked, or run vercel --prod --force from dashboard/.

Sign-up "Continue" button hangs in headless tests but works in real Chrome

That's Cloudflare Turnstile flagging the headless browser as a bot — exactly what bot detection is supposed to do. Real users in real browsers will not see this. Don't try to "fix" it on the app side.

redirect_uri_mismatch from Google after configuring OAuth

The Authorised redirect URI in Google must be https://clerk.your-domain.example/v1/oauth_callback (note clerk., not accounts.). After fixing, Google warns "It may take five minutes to a few hours for settings to take effect" — usually 5 min, sometimes longer. Wait, then retry.


Day-2 operations

Rolling out a code change

Push to main → Vercel auto-deploys → you can spot-check at the deployment preview URL before promoting.

Rotating a Clerk secret

Clerk → API keys → Create new secret key → update CLERK_SECRET_KEY on Vercel → redeploy → delete the old key. Zero-downtime.

Rotating the webhook signing secret

Clerk doesn't expose direct rotation. Delete the webhook endpoint and recreate it with the same URL and events; copy the new whsec_…, update on Vercel, redeploy.

Inspecting auth events

Clerk → Logs (per instance) shows every sign-in attempt with the resolved error. Far more useful than reading the Vercel function logs first.

Database migrations

Edit lib/db/schema.tsnpx drizzle-kit generate → commit the SQL → push to main. The migration runs as part of the build only if you add it explicitly (the default next build does not migrate the database). Most teams run migrations from a CI step or manually before promoting a deploy.

AI Legal UK · The Counsel — Established MMXXVI · Built for England & Wales · Not legal advice.