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 and hreflang links and the sitemap.
  • emailFrom and supportEmail — 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.

SecretNeededNotes
APP_URLYeshttps://your-domain, no trailing slash
BETTER_AUTH_SECRETYesA new one for production: openssl rand -base64 32
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETYesThe app refuses to boot without them
PAYMENT_PROVIDERTo sellstripe or creem; empty keeps the purchase buttons closed
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRETWith StripeThe webhook secret of the production endpoint, not the one stripe listen prints
CREEM_API_KEY, CREEM_WEBHOOK_SECRETWith Creem
RESEND_API_KEYIn practiceWithout it no email is sent — including sign-in codes
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETOptionalBoth or neither
DEMO_MODENeverOnly 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.