Deployment
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:
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. 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:
"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:
"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:
// apps/web/vite.config.ts
export default createViteConfig({ host: "vercel", port: 3000 });
cloudflare builds a Worker. vercel builds with Nitro,
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:
# @required @type=url
DATABASE_URL=postgres://postgres:postgres@localhost:5432/zap
Then one file per app reads it instead of the binding:
// 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.
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:
// 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.
4. Error reporting for the API
@zap-ts/observability ships both ./hono/cloudflare and ./hono/vercel:
import { sentryMiddleware } from "@zap-ts/observability/hono/vercel";
See Observability.
5. Settings and keys
The two scripts of an app differ only in the second half:
// 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:
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.
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.
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_URLandMAIL_PROVIDERare vars. - Sensitive values go up as Cloudflare secrets. A secret is hidden after
you send it. Nobody can read it back.
STRIPE_SECRET_KEYandBETTER_AUTH_SECRETare secrets.
The schema decides which is which. A value is sensitive unless the schema marks
it @sensitive=false.
Before you deploy
Check that every value the app needs is really there:
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 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 — the checklist before launch
- Environment — how settings and keys work
- Queues — jobs that run later, on either host
- Observability — error reporting on either host
- Self-host — running it on your own server instead