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_SECRETmust be thewhsec_…thatbun run stripe:listenprints. 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.