---
title: Deployment
description: Ship each zap.ts app to Cloudflare or to Vercel. You can also mix the two, and put each app where it fits best.
---

zap.ts supports two hosts: Cloudflare and Vercel.

Cloudflare is what the starter kit is set up for. Vercel is supported too, and
the parts that differ between them are already written.

You do not have to choose one host for everything. Each app ships on its own,
so you can put the marketing site on one and the API on the other. This page
covers all three ways.

## Each app ships on its own

There is no single command that deploys everything, and no command that leaves
the host out. Every script names one app and one host:

```bash
pnpm run deploy:web:cloudflare
pnpm run deploy:web:vercel

pnpm run deploy:marketing:cloudflare
pnpm run deploy:marketing:vercel

pnpm run deploy:api:cloudflare
pnpm run deploy:api:vercel

pnpm run deploy:admin:cloudflare
pnpm run deploy:admin:vercel

pnpm run deploy:docs:cloudflare
pnpm run deploy:docs:vercel
```

`pnpm run deploy:web` on its own does not exist. A deploy sends code and keys
somewhere real, so the host is written out every time, and no default decides
it for you.

`docs` is the one app with no varlock in its deploy. It is static: it has no
environment variable and no server, so both scripts only upload the built
folder. On Vercel, `apps/docs/vercel.json` says how to build it.

Deploying the web app does not touch the API. Deploying the API does not touch
the marketing site. You ship a fix to one app and leave the rest alone.

This is also what makes mixing hosts possible. Nothing ties the apps together
at deploy time.

## Why `emails` does not deploy

`apps/emails` has no deploy command, and that is on purpose.

It is a tool you run on your own computer. It shows you what your emails look
like while you write them. Your users never open it, so there is nothing to
put online.

The emails themselves live in [`@zap-ts/mail`](/packages/mail). That package
ships inside the apps that send email.

## Deploying to Cloudflare

This is the default path. Every app already has the files it needs.

An app runs as a Worker. A Worker is a small server that Cloudflare runs close
to your users.

Each app has a `wrangler.jsonc` file that tells Cloudflare how to run it. The
`name` field becomes the name of the Worker:

| App              | Worker name        |
| ---------------- | ------------------ |
| `apps/web`       | `zap-ts-web`       |
| `apps/marketing` | `zap-ts-marketing` |
| `apps/api`       | `zap-ts-api`       |
| `apps/admin`     | `zap-ts-admin`     |
| `apps/docs`      | `zap-ts-docs`      |

Change these to your own product name before your first deploy. The name shows
up in the Cloudflare dashboard and in the default URL.

### Bindings

A binding connects your Worker to another Cloudflare service. Bindings are not
settings and not keys. They do not go in a `.env.local` file. They belong in
`wrangler.jsonc`.

**Hyperdrive** connects the Worker to your Postgres database. It keeps a pool
of open connections, because a Worker cannot keep its own. The web app, the API
and the admin app each declare it:

```jsonc
"hyperdrive": [
  {
    "binding": "HYPERDRIVE",
    "id": "f926bbaf3a884ba7b0ab90f130bb7bad",
  },
],
```

That `id` points at the starter kit's own database. Replace it with the id of
your own Hyperdrive, which you create in the Cloudflare dashboard.

**A queue** holds work the app wants to do later, so the user does not wait.
Only `apps/web` declares it, as both a producer and a consumer:

```jsonc
"queues": {
  "producers": [{ "binding": "QUEUE", "queue": "zap-ts-jobs" }],
  "consumers": [
    {
      "queue": "zap-ts-jobs",
      "max_batch_size": 10,
      "max_batch_timeout": 5,
      "max_retries": 3,
      "dead_letter_queue": "zap-ts-jobs-dlq",
    },
  ],
},
```

**The dead-letter queue** is `zap-ts-jobs-dlq`. A job that fails three times
moves there instead of being lost. You can read it later and find out what went
wrong.

## Deploying to Vercel

Five things change between the two hosts. Nothing else does. Your routes, your
components, your database schema and your `.env.schema` files stay as they are.

### 1. The host of the build

Every app builds through `createViteConfig` from `@zap-ts/vite`. It takes a
`host`:

```ts
// apps/web/vite.config.ts
export default createViteConfig({ host: "vercel", port: 3000 });
```

`cloudflare` builds a Worker. `vercel` builds with [Nitro](https://nitro.build),
which outputs what Vercel runs. That one word is the whole build change.

`wrangler.jsonc` is then unused. Leave it if the app may move back, delete it
if it will not.

### 2. The database connection

Hyperdrive is a Cloudflare service, so it does not exist here. Add the URL to
the `.env.schema` of the repository root:

```bash
# @required @type=url
DATABASE_URL=postgres://postgres:postgres@localhost:5432/zap
```

Then one file per app reads it instead of the binding:

```ts
// apps/web/src/lib/database-url.ts
import { env } from "@zap-ts/environment";

export const databaseUrl = (): string => env.DATABASE_URL;
```

`apps/api/src/index.ts` reads the same binding and changes the same way.

:::warning
The URL has to be a **pooled** one. On Cloudflare, Hyperdrive keeps the pool. On
Vercel nothing does, and a function that opens its own connection on every
request will run your Postgres out of connections. Use the pooled URL of your
database host, such as the pooler of Neon or the pgBouncer port of Supabase.
:::

### 3. Background jobs

Swap `@zap-ts/queues-cloudflare` for `@zap-ts/queues-vercel` in the
`package.json` of the app. Your job types do not move: they live in
`@zap-ts/queues`, which knows about neither host.

The consumer changes shape. Cloudflare delivers a batch to the `queue` handler
of the Worker. Vercel calls a route of your app, once per job. So delete the
`queue` handler from `apps/web/src/server.ts` and add the route:

```ts
// apps/web/src/routes/api.jobs.ts
import { createFileRoute } from "@tanstack/react-router";
import { handleJob } from "@zap-ts/queues-vercel/consumer";
import { Effect } from "effect";

export const Route = createFileRoute("/api/jobs")({
  server: {
    handlers: {
      POST: handleJob((job) => Effect.log(`sending to ${job.to}`)),
    },
  },
});
```

Then point the queue at that route in the Vercel dashboard, and create the
queue with the same name you pass to `JobQueueLive`. Retries and the dead-letter
queue are settings of the queue there, where on Cloudflare they sit in
`wrangler.jsonc`. See [Queues](/packages/queues).

### 4. Error reporting for the API

`@zap-ts/observability` ships both `./hono/cloudflare` and `./hono/vercel`:

```ts
import { sentryMiddleware } from "@zap-ts/observability/hono/vercel";
```

See [Observability](/packages/observability).

### 5. Settings and keys

The two scripts of an app differ only in the second half:

```json
// apps/web/package.json
"deploy:cloudflare": "varlock-wrangler deploy",
"deploy:vercel": "varlock run -- vercel deploy --prod"
```

`varlock-wrangler` does two things at once: it resolves your values, and it
ships them to Cloudflare as vars and secrets. On Vercel you keep the first half
and drop the second. `varlock run` resolves your `.env.schema` and `.env.local`
the same way it always does, so the build sees the same values.

What Vercel does not get is the second half. Nothing pushes your values there,
so you set them yourself, once, in the project settings or with the CLI:

```bash
vercel env add STRIPE_SECRET_KEY production
```

`pnpm run env:web` prints every value the app needs, and where it came from, so
you know what the list is.

:::warning
This is the one guarantee you lose. On Cloudflare, varlock overwrites the vars
and secrets on every deploy, so the dashboard cannot drift from your files. On
Vercel, a value you add by hand stays until you change it by hand. When you
change a value in `.env.schema`, change it in Vercel too.
:::

## Mixing the two

You can host each app wherever it suits it. Nothing in zap.ts assumes all apps
share a host.

A common split:

| App         | Host          | Why                                     |
| ----------- | ------------- | --------------------------------------- |
| `marketing` | Either        | Static pages. Both are good at this     |
| `docs`      | Either        | Same                                    |
| `web`       | Cloudflare    | Hyperdrive and Queues are already wired |
| `api`       | Either        | Both adapters exist                     |
| `admin`     | Same as `web` | It shares the database setup            |

Two rules when you mix.

**Keep apps that share a database on the same host.** Otherwise you maintain
two different connection setups for one database.

**Point the apps at each other by URL.** `API_URL`, `SITE_URL` and `APP_URL`
are settings. Set them to the real addresses, whatever host each app ended up
on. Nothing else needs to know.

## How your settings reach Cloudflare

Every Cloudflare deploy runs through `varlock-wrangler`. Varlock reads your
`.env.schema` and `.env.local` files. See [Environment](/guides/environment).

At deploy time, varlock sorts your values into two groups:

- **Non-sensitive values** go up as Cloudflare **vars**. A var is plain text.
  You can read it in the dashboard. `SITE_URL` and `MAIL_PROVIDER` are vars.
- **Sensitive values** go up as Cloudflare **secrets**. A secret is hidden after
  you send it. Nobody can read it back. `STRIPE_SECRET_KEY` and
  `BETTER_AUTH_SECRET` are secrets.

The schema decides which is which. A value is sensitive unless the schema marks
it `@sensitive=false`.

:::warning
Varlock owns these values. If you add a var or a secret by hand in the
Cloudflare dashboard, the next deploy removes it.

Add the value to your `.env.schema` and `.env.local` instead, then deploy
again. That way the dashboard and your files never disagree.
:::

## Before you deploy

Check that every value the app needs is really there:

```bash
pnpm run env:web
```

There is one of these per app: `env:web`, `env:marketing`, `env:api` and
`env:admin`. Each one prints where every value came from. A missing required
value stops the build and names it.

Then work through the [Going to production](/guides/going-to-production)
checklist. It lists the things a deploy will not catch, such as demo data, test
payment keys and the zap.ts branding.

## Related

- [Going to production](/guides/going-to-production) — the checklist before launch
- [Environment](/guides/environment) — how settings and keys work
- [Queues](/packages/queues) — jobs that run later, on either host
- [Observability](/packages/observability) — error reporting on either host
- [Self-host](/recipes/self-host) — running it on your own server instead
