---
title: Going to Production
description: The checklist to work through before real users arrive. Demo data, test keys, placeholder ids and the zap.ts branding all need replacing.
---

[Deployment](/guides/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:

| Email              | 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:

```ts
// 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:

```ts
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](/guides/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`:

```ts
// 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.ts` sets `SITE_NAME`.
- `apps/marketing/src/lib/site.ts` sets `SITE_NAME`, and also
  `SITE_TWITTER_HANDLE` and `SITE_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`](/packages/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:

```bash
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:

```bash
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](/guides/deployment) — how each app ships
- [Environment](/guides/environment) — how settings and keys work
- [Billing](/packages/billing) — plans, checkout and test mode
- [Mail](/packages/mail) — providers and templates
- [Storage](/packages/storage) — file uploads to R2
- [Observability](/packages/observability) — Sentry
- [UI](/packages/ui) — colours and design tokens
