Going to Production
The checklist to work through before real users arrive. Demo data, test keys, placeholder ids and the zap.ts branding all need replacing.
Deployment shows you how to ship. This page shows you what you must not forget before you do.
zap.ts starts with demo data, test keys and its own branding, so it runs on your computer with no paid account. None of that belongs in production. Work through these nine steps in order.
1. Remove the demo users and the demo organisation
pnpm run db:seed fills the database with example data. It lives in
packages/database/scripts/seed.ts.
It creates one organisation, Demo Organization, and three users:
| Name | Role | |
|---|---|---|
owner@demo.test |
Olivia Owner | owner |
admin@demo.test |
Adam Admin | admin |
member@demo.test |
Mia Member | member |
All three sign in with the password demo1234. The owner also carries the
admin role, so that one account opens the back office too.
Never run pnpm run db:seed against your real database. If you already did,
delete those three users and that organisation before you go live.
2. Replace the placeholder plan ids
Open packages/billing/src/plans.ts. The first line says:
// TODO: Replace these placeholder ids with the ones of your own provider.
The file ships two plans, starter and team. Their ids are fake. Stripe, for
example, has price_replace_me_starter. No such price exists in your account,
so checkout will fail.
Create your plans in your billing provider. Then copy each real id into the field for that provider. A plan holds the ids of every provider at once, and each provider reads only its own field.
3. Switch billing from test mode to live
zap.ts runs payments in test mode by default. No real card can be charged.
For Stripe, the key itself decides. packages/billing/src/mode.ts checks the
start of the key:
const mode = isLiveKey(env.STRIPE_SECRET_KEY, "sk_live_") ? "live" : "test";
The default STRIPE_SECRET_KEY is sk_test_local_dev. Replace it with your
sk_live_ key. Also replace STRIPE_WEBHOOK_SECRET with the one your live
webhook gives you.
Other providers use a setting instead of the key:
| Provider | Setting | Change it to |
|---|---|---|
| Polar | POLAR_SERVER |
production |
| Dodo Payments | DODO_PAYMENTS_ENVIRONMENT |
live_mode |
| Creem | CREEM_ENVIRONMENT |
live |
The billing page says on screen when the app is in test mode. Open it after the change and check that the message is gone.
4. Point mail at a real provider
MAIL_PROVIDER is smtp by default. That is Mailpit, the fake inbox that
docker compose up starts. Emails land there and go nowhere.
Set MAIL_PROVIDER to resend, postmark, sendgrid or brevo, then set
the key that provider needs: RESEND_API_KEY, POSTMARK_SERVER_TOKEN,
SENDGRID_API_KEY or BREVO_API_KEY.
Also change MAIL_FROM. It ships as zap.ts <noreply@zapstudio.dev>, which is
not your domain.
5. Point storage at a real bucket
The R2_* values point at MinIO, the local file store from
docker compose up. The user name and password are both minioadmin.
Replace R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY and
R2_BUCKET with your Cloudflare R2 values.
Leave R2_ENDPOINT empty on Cloudflare. R2 builds its own address from the
account id. The schema says so in a comment.
6. Point the database at a real server
DATABASE_URL in packages/database/.env.schema is the Postgres from Docker:
postgresql://postgres:postgres@127.0.0.1:5432/postgres
That server only exists on your computer. Use a hosted Postgres in production, and create a Hyperdrive for it. See Deployment for how Hyperdrive is wired.
Set BETTER_AUTH_URL too. It ships as http://localhost:3000.
7. Check the two robots.txt files
A robots.txt file tells search engines which pages they may read. Each app
builds its own.
apps/web blocks every crawler:
User-agent: *
Disallow: /
This is on purpose. The web app sits behind the sign-in, so there is nothing useful for a search engine to find. Leave it as it is.
apps/marketing allows crawlers, and blocks only /api/. It also lists
eleven AI crawlers by name, such as GPTBot, ClaudeBot, PerplexityBot and
Google-Extended. Today they are all allowed. The file carries a TODO:
// TODO: To block these crawlers, swap their `Allow: /` below for `Disallow: /`.
Decide what you want. If you do not want AI crawlers to read your site, follow that comment before you launch.
8. Set the Sentry and PostHog keys
Sentry collects your errors. PostHog collects how people use the app. Both values are empty by default, so neither one is sending anything.
Set SENTRY_DSN, SENTRY_ORG, SENTRY_PROJECT and SENTRY_AUTH_TOKEN for
errors. Set POSTHOG_KEY for usage. POSTHOG_HOST already points at
https://eu.i.posthog.com, so change it only if your project is elsewhere.
9. Replace the zap.ts branding with your own
Your app still says zap.ts in a lot of places. Your users will see it.
Start with the two files that hold the name. Both carry a
// TODO: Replace with your informations. comment:
apps/web/src/lib/site.tssetsSITE_NAME.apps/marketing/src/lib/site.tssetsSITE_NAME, and alsoSITE_TWITTER_HANDLEandSITE_AUTHOR.
Changing SITE_NAME covers a lot at once. The logo, the page titles, the
sharing image and the web app manifest all read it.
Then work through the rest:
| What | Where |
|---|---|
| The logo mark | apps/web/src/components/brand.tsx. It uses a Zap icon from lucide-react. Swap in your own |
| App icons | icon-192.png and icon-512.png in apps/web/public and apps/marketing/public |
| Colours | The token files in @zap-ts/ui |
| Worker names | The name field in each apps/*/wrangler.jsonc. They all start with zap-ts- |
| Email templates | packages/mail/src/templates. They carry a TODO about placeholder designs |
| Legal pages | apps/marketing/content/legal. These are placeholders, not legal advice |
| Blog post | apps/marketing/content/blog holds one example post. Delete it |
When you think you are done, search the whole repository for what is left:
grep -ri "zap.ts\|zap-ts\|zap studio" apps packages --include="*.ts" --include="*.tsx" --include="*.mdx" --include="*.json*"
Ignore hits inside @zap-ts/ import paths. Those are package names, not your
brand, and they do not reach your users.
Last check
Print how every value resolves, one app at a time:
pnpm run env:web
pnpm run env:marketing
pnpm run env:api
pnpm run env:admin
Read the output and look for anything that still says local_dev,
replace_me, localhost or minioadmin. A missing required value stops the
build and names itself, but a leftover demo value will not. You have to spot
that one yourself.
Related
- Deployment — how each app ships
- Environment — how settings and keys work
- Billing — plans, checkout and test mode
- Mail — providers and templates
- Storage — file uploads to R2
- Observability — Sentry
- UI — colours and design tokens