Troubleshooting

Symptom-indexed fixes for the friction surfaces that come up most often during setup, deployment, and operation.

The entries below are grouped by subsystem. Each one is phrased as a symptom; expand it for the cause, a specific fix, and a link to the deeper docs. Use Cmd-F or the quick-jump cards to find a phrase that matches what you're seeing.

Start here

When you're stuck, run pnpm setup:doctor first. Most of the friction in this kit shows up there as a one-line disabled. Missing X, Y hint.

Reading The Setup Doctor

Three line shapes show up in the doctor's output:

$ pnpm setup:doctor
  • Billing (Stripe)OK
  • GitHub OAuthdisabledMissing GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET. Add ...
  • Email (log)OK

OK means the integration is working, including Email (log), which writes HTML files to the local outbox. To send real mail, set EMAIL_DELIVERY_MODE to plunk, resend, postmark, brevo, sendgrid, or smtp, plus that provider's credential and EMAIL_FROM. disabled. Missing X, Y means at least one required env var is empty; fix the listed ones and re-run. disabled intentionally means an optional integration was left off with nothing missing.

See also. Setup for the per-integration walkthroughs.

First-Run Setup

BETTER_AUTH_SECRET still uses the starter placeholder, or must be at least 32 characters long

The doctor rejects the placeholder string and any value shorter than 32 characters. Better Auth uses this secret to encrypt session cookies; a weak value would break sessions across deploys.

Fix. Generate one and paste it into apps/web/.env:

openssl rand -base64 32

See also. Quickstart.

Tables don't exist, or Prisma P2021 on first boot

Migrations haven't been applied yet, so Prisma is querying tables Postgres doesn't know about.

Fix. Run pnpm db:migrate:dev once. This creates the schema and generates the Prisma client.

See also. Database.

pnpm version mismatch or corepack errors

The kit pins a specific pnpm version via packageManager in package.json. On Node 22+, corepack activates it automatically; older Node versions miss it.

Fix. Use Node 22+ (check .nvmrc), then enable corepack:

corepack enable
corepack prepare pnpm@latest --activate

Authentication & OAuth

redirect_uri_mismatch from GitHub or Google

The OAuth app's authorized callback URL doesn't match what the kit constructs at runtime: <NEXT_PUBLIC_APP_URL>/api/auth/callback/<provider>. Common causes: trailing slash, http vs https mismatch, the production URL is registered but you're hitting localhost.

Fix. In the provider's developer console, set the callback URL to exactly your NEXT_PUBLIC_APP_URL plus /api/auth/callback/github or /api/auth/callback/google. Multiple URLs are fine; add both http://localhost:3000/... and your production URL.

See also. Setup: GitHub OAuth and Setup: Google OAuth.

OAuth button rendered greyed-out or disabled

Only one half of the provider's env-var pair is set. The kit hides providers it can't fully wire up: setting GITHUB_CLIENT_ID without GITHUB_CLIENT_SECRET (or vice versa) leaves the button visible but disabled rather than registering a broken provider.

Fix. Run pnpm setup:doctor and look for the provider's line. It tells you which env name is missing.

Sign-in works locally but fails on the deployed site

NEXT_PUBLIC_APP_URL doesn't match the deployed origin. The kit constructs the OAuth callback URL from this var; if your prod app boots with NEXT_PUBLIC_APP_URL=http://localhost:3000, the callback Better Auth advertises won't match what's registered with the provider.

Fix. Set NEXT_PUBLIC_APP_URL to the production origin in your host's environment (Vercel project settings, Fly secrets, Render env, Docker env). It's a NEXT_PUBLIC_* var, so it requires a rebuild on every host.

See also. Deployment: Build-Time vs Runtime Env.

Security Headers & CSP

My third-party script (Sentry, Hotjar, Intercom, etc.) is blocked by CSP

Nosecone applies a strict default Content Security Policy plus a curated allow-list (Turnstile, S3, PostHog). Anything not on the list, including new analytics or chat widgets you add, gets blocked at the browser. The browser console shows Refused to load the script ... because it violates the following Content Security Policy directive.

Fix. Extend the allow-list in apps/web/lib/security-headers.ts. Each third-party usually needs entries on script-src, connect-src, and sometimes img-src and frame-src. Test in dev (with the same CSP) before deploying.

See also. Security: Edge: Headers, CSP, CORS.

Turnstile widget doesn't render or token validation fails

Two common causes. First: only one of NEXT_PUBLIC_TURNSTILE_SITE_KEY or TURNSTILE_SECRET_KEY is set; the kit needs both. Second: the widget's domain list in the Cloudflare dashboard doesn't include localhost (for dev) or the production hostname.

Fix. Run pnpm setup:doctor to check both env vars are set. In the Cloudflare dashboard, edit the widget and add localhost (development) and your production domain.

See also. Setup: Cloudflare Turnstile.

Server actions break on the second request after a multi-instance deploy

Next.js encrypts server-action payloads with a per-process key by default. With more than one replica, request A goes to replica 1 (gets a payload encrypted with key 1), follow-up request B goes to replica 2 (which can't decrypt with its own key 2). Symptom: server actions work the first time and then fail with a decryption error.

Fix. Generate a 32+ byte secret with openssl rand -base64 32 and set it as NEXT_SERVER_ACTIONS_ENCRYPTION_KEY on every replica. Same value across all instances.

See also. Security: Operational Secrets.

Storage & Uploads

"Failed to upload to S3" toast with no other info

Almost always missing CORS on the bucket. The browser PUTs directly to a presigned URL; without AllowedMethods: ["PUT"] from your app origin, the PUT is silently rejected by the storage provider before the kit can see anything.

Fix. Apply this CORS rule to the bucket:

[
  {
    "AllowedOrigins": ["https://your-app.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag"]
  }
]

Add http://localhost:3000 for local development.

See also. Storage: Required Bucket Configuration.

Images upload successfully but never display

NEXT_PUBLIC_S3_PUBLIC_URL doesn't match where uploaded objects are publicly served. Cloudflare R2 is the common culprit: the endpoint URL (https://<account-id>.r2.cloudflarestorage.com) is for API calls, not public reads. Public reads go through https://pub-<bucket-id>.r2.dev or your custom domain.

Fix. Open one of the uploaded objects directly in your browser via the URL the bucket generates; that's your NEXT_PUBLIC_S3_PUBLIC_URL (minus the object key). Update .env and rebuild.

See also. Setup: Storage.

tmp/ keeps growing in the bucket

A user abandoned an upload between presign and finalize. The kit cleans up tmp/ keys when finalize succeeds, fails, or errors mid-way; the one case it can't observe is a user who walked away with the presigned URL still valid.

Fix. Add a bucket lifecycle rule that expires tmp/ after 1 day:

{
  "Rules": [
    {
      "ID": "expire-tmp-uploads",
      "Status": "Enabled",
      "Filter": { "Prefix": "tmp/" },
      "Expiration": { "Days": 1 }
    }
  ]
}

See also. Storage: Required Bucket Configuration.

Billing & Webhooks

Webhook signature verification failed, or 400 on every Stripe event

Wrong signing secret. The signing secret is per-endpoint and per-Stripe-CLI-session: the production endpoint's whsec_* is different from the one the Stripe CLI prints when you run stripe listen.

Fix. Match the secret to the endpoint sending events. For local dev, stripe listen --forward-to localhost:3000/api/webhooks/stripe prints a fresh whsec_* each session; paste that into STRIPE_WEBHOOK_SECRET in .env. For production, copy the secret from the Stripe Dashboard's webhook detail page.

See also. Webhooks And Async Workflows: Endpoint And Signature Verification.

400 on every Polar delivery: missing headers or invalid signature

Two distinct causes. Missing Polar webhook header(s): ... means the request did not carry the three Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature); a proxy that strips headers or a hand-made test request produces this. Invalid Polar webhook signature (or a timestamp-tolerance message) means POLAR_WEBHOOK_SECRET does not match the endpoint that is sending. Each Polar endpoint has its own secret, and the sandbox and production dashboards are separate, so a production secret against sandbox deliveries fails. The kit already accepts both encodings a Polar secret can have, so the secret's age is never the cause.

Fix. Copy the secret from the exact endpoint in the Polar dashboard that points at your URL (Settings -> Webhooks) into POLAR_WEBHOOK_SECRET and restart. If you rotated the endpoint or recreated it for a new tunnel URL, the secret changed with it.

See also. Webhooks And Async Workflows: The Polar Webhook.

Checkout completes but the dashboard still shows the free plan

The Subscription row is created from the provider's webhook (customer.subscription.created for Stripe, subscription.created / subscription.active for Polar, subscription.active / subscription.paid for Creem), not from the success redirect. If the webhook didn't reach the kit (firewall, wrong URL, signature mismatch, or, with Polar, an endpoint that was disabled after 10 consecutive failures), the dashboard reads no subscription and falls back to the free phase.

Fix. Check the provider's webhook delivery log for the offending event (Stripe Dashboard -> Developers -> Webhooks; Polar dashboard -> Settings -> Webhooks; the webhook settings in the Creem dashboard). A red entry means the provider couldn't reach you or got a non-2xx; click into it to see the response body. Re-deliver after fixing, or run pnpm billing:reconcile to pull the live subscription state directly.

See also. Billing: How A Subscription Comes To Life.

Paid features blocked with "Billing has a configuration error"

Two issue codes land here. provider_price_not_configured: a live subscription's price reference (Stripe price.id, Polar product id, or Creem product id) isn't in the kit's billing catalog. Either you created a new Price or Product but didn't mirror it in the matching STRIPE_PRICE_ID_* / POLAR_PRODUCT_ID_* / CREEM_PRODUCT_ID_* variable, or you bumped the env var but didn't restart (these are server-only env vars). billing_provider_mismatch: the subscription belongs to a provider other than the active BILLING_PROVIDER (see the next item).

Fix. For a catalog mismatch, ensure the STRIPE_PRICE_ID_*, POLAR_PRODUCT_ID_*, or CREEM_PRODUCT_ID_* values for the active provider match their ids in the provider dashboard, run pnpm billing:check-prices, and restart. The configuration_error phase is intentional: the kit refuses to grant paid features when it can't map a subscription back to a plan.

See also. Billing: Subscription Phases.

Doctor reports live subscriptions from another billing provider, or the UI says billing_provider_mismatch

BILLING_PROVIDER was changed on an installation that already has non-terminal subscriptions from the other provider. Switching never migrates subscriptions: those rows resolve to configuration_error / billing_provider_mismatch, paid entitlements stop, checkout is blocked, and the portal is denied because the active adapter cannot open another provider's customer. The doctor prints N live subscription(s) belong to a billing provider other than <Provider> (BILLING_PROVIDER=...) when it can reach the database.

Fix. Switch BILLING_PROVIDER back to the provider that owns the subscriptions. If the change is intentional, cancel those subscriptions in the old provider's dashboard so they become terminal (canceled); the organizations can then re-subscribe through the new provider. There is no automated migration.

See also. Billing: Switching never migrates anything, Upgrading.

Doctor says BILLING_PROVIDER must be one of: stripe, polar, creem

The value is misspelled or names a provider the kit does not ship. There is no silent fallback: an unrecognized value is a fatal setup error, so the app serves the Configuration error page and /api/health returns 503.

Fix. Set BILLING_PROVIDER to stripe or polar, or leave it unset for Stripe. The same shape applies to POLAR_SERVER must be one of: sandbox, production.

Warning: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET are set but BILLING_PROVIDER=polar; stripe is ignored

Secrets for a provider other than the active one are present. Only the active provider's group is used; the other is ignored, and the doctor warns so a half-migrated deploy is visible. It is a warning, not an error, and the app boots.

Fix. Remove the unused provider's variables from the runtime env, or switch BILLING_PROVIDER if the other provider was the intended one. Price references (STRIPE_PRICE_ID_*, POLAR_PRODUCT_ID_*, CREEM_PRODUCT_ID_*) for an inactive provider are not checked and can stay.

Warning: POLAR_SERVER=sandbox in production

A production build (NODE_ENV=production) is billing through Polar's sandbox: checkouts complete against test data and no real payments are collected. The doctor flags it as a warning rather than an error because staging deploys legitimately run production builds against the sandbox.

Fix. On the real production host, set POLAR_SERVER=production together with a production access token, production product ids, and a production webhook endpoint (sandbox and production are separate environments). See Going To Production: Pricing And Polar Production. Run pnpm setup:doctor --production locally to see production-only gates before deploying.

Polar checkout fails with a customer or subscription conflict

Polar enforces one customer per email per Polar organization and one active subscription per customer. The adapter surfaces both as typed errors (PolarCustomerConflictError, PolarActiveSubscriptionConflictError) with a clear message instead of silently attaching another organization's customer: a second SyntaxKit organization checking out with the same billing email hits the customer conflict (also reported as drift kind polar_customer_conflict), and an organization whose subscription is already active in Polar hits the subscription conflict (usually because a webhook was missed and the dashboard still shows the free plan).

Fix. For the email conflict, use a different billing email for the second organization, or bill through Stripe, which has no such rule. For the subscription conflict, run pnpm billing:reconcile (or Resync billing in the support console) so the local row catches up with Polar, then use the portal to manage the existing subscription.

See also. Billing: Customers and the one-email limitation.

Email

Why aren't my emails being sent?

EMAIL_DELIVERY_MODE auto-resolved to log (the default in dev, no setup required) or noop (the default when NODE_ENV=test). Both succeed silently without sending real email, which is correct behavior outside production.

Fix. For local inspection, open .local/email-outbox/. To enable real delivery, set EMAIL_DELIVERY_MODE and the credential for that mode (PLUNK_API_KEY, RESEND_API_KEY, POSTMARK_SERVER_TOKEN, BREVO_API_KEY, SENDGRID_API_KEY, or SMTP_HOST plus SMTP_USER and SMTP_PASS). If the mode is unset, the first configured credential wins, with Plunk first. pnpm setup:doctor reports the active mode on the Email line.

See also. Email: Delivery Modes.

Provider emails go to spam or bounce

The sender domain isn't authenticated. SPF, DKIM, and DMARC DNS records establish your domain's authority to send mail; without them, Gmail and Outlook deliverability drops sharply or rejects entirely.

Fix. In the dashboard of the provider you pinned with EMAIL_DELIVERY_MODE, add and verify the domain used in EMAIL_FROM. After verification, send a test email and confirm it lands in the inbox.

See also. Going To Production: Email Sender Configuration.

AI

AI chat fails immediately with an authentication error from the gateway

AI_GATEWAY_API_KEY is missing, not being read by the process, or rejected by the gateway. The variable is included in apps/web/.env.example, and the setup doctor reports whether a non-empty value is configured.

Fix. Add the key to apps/web/.env:

AI_GATEWAY_API_KEY="vck_..."

Get the value from vercel.com/dashboard/ai-gateway. Restart the dev server after editing .env.

Run pnpm setup:doctor and confirm AI chat (Vercel AI Gateway): OK before retrying the request. The doctor checks presence; if the gateway still rejects the request, rotate or replace the key in Vercel.

See also. Setup: AI.

"AI chat is temporarily unavailable" toast for a signed-in user

The abuse policy is failing closed. In production this happens when Upstash is unreachable, partially configured (one of UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN is set, the other isn't), or when the request context is missing a required characteristic (userId or organizationId). In non-production environments missing_config is auto-bypassed (you'll see a dev_no_upstash warning in the server log), so this toast in dev almost always points to a missing characteristic.

Fix. Run pnpm setup:doctor and check the Abuse protection line. Set both Upstash env vars to the values from your Upstash database's REST API panel, or unset both to fall back to the dev auto-bypass.

See also. Security: Abuse Protection (Upstash).

Image attachments fail when web search is on

This is documented behavior, not a bug. Web search routes the chat through Perplexity Sonar, which doesn't accept image inputs.

Fix. Disable web search before attaching images, or remove the attachments before enabling web search.

See also. AI: What's Wired In.

Analytics & Monitoring

Stack traces in PostHog are still minified after configuring POSTHOG_API_KEY

POSTHOG_API_KEY and POSTHOG_PROJECT_ID are read at build time, not runtime. The kit's withPostHogConfig wrapper uploads source maps during next build; if those env vars aren't present when the build runs, the upload silently no-ops. They are personal API keys, so the kit deliberately never accepts them as Docker build args — meaning self-hosted image builds (Docker Compose, Fly, Render, GHCR) don't upload source maps at all, and their production stack traces stay minified.

Fix. Make sure the build runs with both keys present. On Vercel, set them in the Environment Variables panel (available at build by default). In CI (e.g. the GHCR docker.yml workflow), set them as CI secrets and wire your own upload step. On a self-hosted image build there is no supported build-arg path — either build on Vercel/CI, or add your own source-map upload to your pipeline.

See also. Monitoring: Source Maps.

PostHog events don't arrive even though setup:doctor says PostHog analytics: OK

Ad-blockers strip requests to i.posthog.com and similar known telemetry hosts. The kit tracks events client-side, so a blocked browser sees no data.

Fix. Configure the same-origin reverse proxy. Set POSTHOG_PROXY_INGEST_HOST and POSTHOG_PROXY_ASSET_HOST in your environment; the kit's next.config.ts rewrites /ingest/* to PostHog. Ad-blockers can't strip same-origin requests.

See also. Monitoring: The Reverse Proxy.

PostHog UI shows no events or wrong project

Wrong region. EU-region projects need EU host URLs; US-region projects need US host URLs.

Fix. Match the region you picked at PostHog signup:

NEXT_PUBLIC_POSTHOG_HOST="https://eu.i.posthog.com"
NEXT_PUBLIC_POSTHOG_UI_HOST="https://eu.posthog.com"

Replace eu with us for US-region projects. Restart and rebuild after editing.

Deployment & Self-Hosting

Changing a NEXT_PUBLIC_* env var doesn't take effect

Every NEXT_PUBLIC_* value is inlined into the browser JS bundle at build time. Editing the var on a running server changes nothing; the bundle the browser downloads still contains the old value.

Fix. Trigger a rebuild on every host that reads it. Vercel: redeploy. Fly: fly deploy with --build-arg mirroring the new value. Render: trigger a deploy. Docker: rebuild the image with --build-arg.

See also. Deployment: Build-Time vs Runtime Env.

Migrator container fails on Docker self-host with "could not connect to server"

DATABASE_URL is using localhost instead of the Docker network hostname. Inside Docker Compose, services reach each other by service name, not by localhost.

Fix. In your .env for Compose, point DATABASE_URL at the service name:

DATABASE_URL="postgresql://user:password@db:5432/syntaxkit"

db here is the service name from docker-compose.yml, not a hostname.

See also. Deployment: Self-Hosted Docker.

pnpm setup:doctor runs locally, but my deployment uses different env vars

The doctor reads apps/web/.env and validates that file. Production env vars come from your host's environment (Vercel project settings, Fly secrets, Render env, Docker env), which the doctor can't reach.

Fix. Mirror the same env vars in your host's environment, then re-deploy. The pre-launch checklist on Going To Production: Operational Environment Variables walks through every variable that needs to land in production.

Where To Go Next

On this page