Troubleshooting

The failures you are most likely to hit with ShipKit — env validation, migrations, stale generated files, OAuth, webhooks — and how to fix each one.

Each entry names the symptom you see, why it happens, and the fix. Most of them come down to three facts: env is read once at boot, migrations are applied by hand, and the code runs in workerd, not Node.

Setup and local dev

"Missing or invalid environment secrets" at startup

src/core/env.ts validates env with zod the first time anything reads it and throws with a list of the offending keys. Required: APP_URL (a URL), BETTER_AUTH_SECRET (at least 16 characters), GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. Everything else is optional.

Fix: cp .dev.vars.example .dev.vars if you have not, fill in those four (openssl rand -base64 32 for the secret), and restart bun run dev. If you do not have Google credentials yet, any non-empty placeholder passes validation; the Google button will not work, but the emailed sign-in code will. See Getting started.

I changed .dev.vars and nothing happened

Env is read at boot and cached for the life of the process. Restart the dev server after every edit. Production does not read .dev.vars at all: its values are set with wrangler secret put.

"Secret X is not set"

An optional module asked for its key at the moment it needed it, through requireEnv(). Add the key to .dev.vars (local) or run wrangler secret put X (production). Optional keys are checked at use rather than at boot so that deleting a feature never breaks startup.

bun run typecheck cannot find @/paraglide/messages

src/paraglide/ is generated by the Vite plugin from messages/*.json and is not committed. Run bun run build or start bun run dev once, then run typecheck again. CI builds before it typechecks for this reason.

Locale routing breaks after compiling messages by hand

Never run paraglide-js compile yourself. It ignores the options in vite.config.ts (the APP_LOCALE cookie name and the URL strategy), and the output it writes breaks /zh routing. Restart the dev server; the Vite plugin regenerates src/paraglide/ with the right options. See Internationalization.

Port 3000 is already in use, or the first dev run fails on a stale cache

A previous dev server is still running: pkill -f "vite dev". If the very first run fails on a Vite cache left by another package manager, delete it with rm -rf node_modules/.vite and start again.

Wrangler warns about the D1 database_id

wrangler.jsonc ships with "database_id": "REPLACE_ME". Local dev ignores it, so the warning is harmless until you deploy; wrangler d1 create gives you the real id. See Deploy.

Database

"no such table" or "no such column"

The code expects a migration that has not been applied to this database. Locally: bun run db:migrate:local. In production: bun run db:migrate:remote. After pulling a new ShipKit release, check for new files in drizzle/ (see Upgrading).

CI fails with "Schema changed without a migration"

You edited a schema.ts and did not generate its migration. CI runs bun run db:generate and fails if it writes anything. Run it yourself, commit drizzle/ (including drizzle/meta), and push again.

CI refuses to deploy over "Unapplied D1 migrations"

Correct behaviour: the deploy job never migrates, and it will not ship code whose migrations the production D1 has not seen. Run bun run db:migrate:remote from your machine, then re-run the workflow (gh workflow run CI, or Re-run in the Actions tab). Always migrate before deploying code that needs the new schema.

db.transaction() throws

D1 has no interactive transactions. Use db.batch([...]) for several statements that must go together, or a single conditional statement when a check and a write must be atomic. See Database.

Build and runtime

"Cannot read properties of null (reading 'useEffect')"

Two copies of React ended up in the dev server's bundle, usually because src/paraglide/ did not exist when Vite scanned dependencies. Run bun run build once, then restart the dev server.

The dev server breaks after importing cloudflare:workers

cloudflare:workers may be imported only in src/core/server/cf.ts. Anywhere else it leaks into the client bundle and crashes Vite in dev. Read bindings through getBindings() from that file, and env through getEnv().

A library that works in Node fails in production

The Worker runs in workerd with nodejs_compat, not in Node. Anything that evaluates code at runtime (eval, new Function) is forbidden — that is why MDX is compiled at build time instead of by a runtime content layer. Prefer libraries that support Workers, and test with bun run dev, which runs the same runtime.

A server function answers 403 "Cross-site request refused"

Server functions only accept calls from pages this origin served. A browser request from another origin or a sibling subdomain is refused on purpose. Call it from your own pages, or expose an API route if an outside client needs the data (see API keys).

Sign-in

Google shows redirect_uri_mismatch

The redirect URI in Google Cloud Console must match <APP_URL>/api/auth/callback/google exactly: scheme, port, no trailing slash. For production, add the production origin and redirect URI to the same client, and check that the APP_URL secret is the production URL, not localhost. invalid_client means a wrong or swapped secret. The /google-oauth skill walks through it.

Google One Tap does not appear

One Tap checks Authorized JavaScript origins, not redirect URIs. Add the origin; for local dev add both http://localhost:3000 and http://localhost. Origin changes can take minutes to propagate. Ad blockers can hide the prompt, and Google suppresses it for a while after a user dismisses it.

No GitHub button

It appears only when both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are set. Set both, restart, and register <APP_URL>/api/auth/callback/github as the callback URL of the GitHub OAuth app.

The sign-in code email never arrives

Without RESEND_API_KEY, no email is sent: the code is printed to the server log as [auth] no mail provider — code for …. That is how local sign-in works with no mail provider. In production, set the key and make sure emailFrom in src/config/app-config.ts is an address on a domain you have verified in Resend (the shipped value is a placeholder). Failed sends appear as failed rows in Admin → Email log, with the provider's error. A 429 means the address or IP asked for too many codes in a minute.

The admin area does not appear after promoting myself

Set role='admin' on your user row (see Getting started), then reload the page. Check you ran the command against the right database: --local for dev, --remote for production.

Payments

Purchase buttons say "Coming soon"

Nothing can be bought until the active provider has its key. PAYMENT_PROVIDER picks the provider for new checkouts (unset, it prefers Stripe when STRIPE_SECRET_KEY exists); that provider's secret key must be set. Restart after editing. See Payments.

The webhook answers 401 "Invalid signature"

The signing secret does not match the sender.

  • Stripe locally: STRIPE_WEBHOOK_SECRET must be the whsec_… that bun run stripe:listen prints. In production it must be the secret of the dashboard endpoint, not the CLI's.
  • Creem: the secret must come from the webhook you created in the Creem dashboard. Anything in front of the dev server (such as an ngrok tunnel) must pass the request body through untouched, because Creem signs the exact bytes.

Restart after changing either secret.

The webhook answers 500 "Handler failed"

A feature's handler threw. The event is released rather than marked as seen, so the provider's retry gets a full second attempt. The cause is in the log under stripe webhook <type> <id> failed (or creem webhook …).

Checkout fails as soon as I click buy

src/config/plans.ts ships with REPLACE_ME placeholders for every stripePriceId and creemProductId, and the provider rejects an id it does not know. Create the catalog (the /stripe or /creem skill does it) and put the real ids in each slot. Test mode and live mode are separate catalogs, so going live means replacing every id.

A stripe trigger event records an order with no user

Expected. The CLI's fixture carries no metadata.userId, so it proves the signature, dedupe and translation steps but grants nothing to anyone. To test a real grant, buy through the UI with card 4242 4242 4242 4242.

Operations

An admin setting change does not take effect

Settings are cached per Worker isolate for 60 seconds. Wait a minute.