Deploy
Put ShipKit on Cloudflare Workers — create D1 and R2, push secrets, migrate, deploy, attach a domain, and wire OAuth, webhooks and email for production.
A ShipKit deployment is one Cloudflare Worker. It serves the prerendered
marketing pages from its static assets and everything else from code, and it
talks to three things Cloudflare provides as bindings in wrangler.jsonc: a
D1 database (DB), an R2 bucket (BUCKET) and two rate limiters. A daily
cron trigger runs the maintenance jobs. There is no other server to run.
If you use Claude Code, /deploy does everything on this page and asks you
only for production values. The steps below are the same ones.
Before the first deploy
Log in to Cloudflare:
bunx wrangler login
bunx wrangler whoami
Name the project. wrangler.jsonc says shipkit in three places — name
(the Worker), database_name and bucket_name — and so does name in
package.json. Change them to your project's name. Every script addresses
the database by its binding, DB, so nothing else has to follow.
Then work through src/config/app-config.ts, which is all placeholders:
url— your production origin. It drives canonical andhreflanglinks and the sitemap.emailFromandsupportEmail— the sender address and the Reply-To on every email (see the checklist below).googleClientId— the second branch of the ternary is the production id; One Tap needs it in the browser.legalEntity— printed on the Terms and Privacy pages.
See Configuration for the rest of src/config/.
Provision D1 and R2
bunx wrangler d1 create <database_name>
Copy the database_id it prints into wrangler.jsonc, replacing
REPLACE_ME. Then:
bunx wrangler r2 bucket create <bucket_name>
bun run cf-typegen
bun run db:migrate:remote
Keep the R2 bucket even if you delete the files feature: profile photos are stored there too.
Secrets
Local development reads .dev.vars; the deployed Worker reads secrets you
push with wrangler secret put. Nothing in .dev.vars reaches production.
bunx wrangler secret put APP_URL
bunx wrangler secret put BETTER_AUTH_SECRET
# …one per key
If you prefer a file, keep the production values in .prod.vars (gitignored)
and push them all at once with bunx wrangler secret bulk .prod.vars. Keep a copy in a
password manager either way.
| Secret | Needed | Notes |
|---|---|---|
APP_URL | Yes | https://your-domain, no trailing slash |
BETTER_AUTH_SECRET | Yes | A new one for production: openssl rand -base64 32 |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Yes | The app refuses to boot without them |
PAYMENT_PROVIDER | To sell | stripe or creem; empty keeps the purchase buttons closed |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | With Stripe | The webhook secret of the production endpoint, not the one stripe listen prints |
CREEM_API_KEY, CREEM_WEBHOOK_SECRET | With Creem | |
RESEND_API_KEY | In practice | Without it no email is sent — including sign-in codes |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET | Optional | Both or neither |
DEMO_MODE | Never | Only for a separate public demo deployment |
The full list, with what each key does, is on Configuration. Env is validated when the Worker first reads it: a missing or malformed required key throws an error that names the key, visible in the Worker's logs.
Deploy and check
bun run deploy
This builds, prerenders the marketing pages, writes the sitemap and runs
wrangler deploy. Check the result from a terminal:
curl -s -o /dev/null -w "%{http_code}" https://your-domain/ # 200
curl -s -o /dev/null -w "%{http_code}" https://your-domain/dashboard # 307, to /login
curl -s https://your-domain/pricing | grep -c hreflang # 3
Custom domain
The Worker is reachable on its workers.dev subdomain as soon as it deploys,
which is fine for a smoke test. For the real origin, uncomment the routes
line in wrangler.jsonc and put your hostname in it:
"routes": [{ "pattern": "your-domain.com", "custom_domain": true }],
The domain's zone must be in the same Cloudflare account. You can also attach the domain in the dashboard (Workers → your Worker → Settings → Domains & Routes). Google sign-in only works on the origin users actually visit, so do this before inviting anyone.
For www → apex, a Cloudflare redirect rule is the simplest option. If you
cannot add one, infra/www-redirect/ is a tiny Worker that does only that:
set your hostname in its wrangler.jsonc and run bun run deploy:www.
Production checklist
These live outside the repo and are the usual reason a fresh deployment half-works.
Google. In the same OAuth client you use locally, add
https://your-domain to Authorized JavaScript origins and
https://your-domain/api/auth/callback/google to Authorized redirect URIs.
One Tap checks the origins list; the redirect flow checks the redirect list.
GitHub (if configured). Add
https://your-domain/api/auth/callback/github as the OAuth app's callback.
Payments. For Stripe, create a webhook endpoint at
https://your-domain/api/webhooks/stripe subscribed to
checkout.session.completed, checkout.session.async_payment_succeeded,
invoice.paid, invoice.payment_failed, customer.subscription.updated,
customer.subscription.deleted, charge.refunded, credit_note.created and
charge.dispute.closed, and push its signing secret. Going live means a
live-mode catalog, so every price id in src/config/plans.ts changes too. For
Creem, point the webhook at https://your-domain/api/webhooks/creem and
switch to the production key and product ids. See Payments.
Email. Verify your sending subdomain in Resend (the template assumes
send.<your-domain>, so its SPF and DKIM records do not collide with your
inbound mail), set emailFrom to an address on it, and make sure
supportEmail reaches a real inbox — customers who reply to a receipt land
there. Without RESEND_API_KEY, emailed sign-in codes are only written to
the Worker's log, so users relying on them cannot sign in. See
Email and notifications.
First admin. Sign in once, then:
bunx wrangler d1 execute DB --remote \
--command "UPDATE user SET role='admin' WHERE email='you@example.com'"
Later deploys and migrations
A deploy after a schema change is two steps, in this order:
bun run db:migrate:remote
bun run deploy
Migrate first. Code that expects a column the database does not have yet fails on every request that touches it. Migrations are always applied by hand; nothing in the repo runs them for you. See Database.
Deploying from CI
.github/workflows/ci.yml runs typecheck, lint, unit tests, the build and
the Playwright suite on every push and pull request. On a push to main it
then deploys — but only once you give it credentials: add
CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID as secrets of an
environment named production in your GitHub repository. Without them the
deploy job prints that it skipped and succeeds. The token needs to deploy
Workers and read your D1 database.
CI never migrates. Before deploying it lists remote migrations and refuses to
continue if any are pending. Run bun run db:migrate:remote yourself, then
re-run the workflow (gh workflow run CI, or the Run workflow button).
Runtime secrets are never read from CI; they stay where
wrangler secret put put them. CI builds with placeholder values.
After a deploy, logs, cron jobs and rate limits are covered in Operations, and common failures in Troubleshooting.