Deployment
Deployment
Warpkit is a Bun + TanStack Start app. It runs as a long-lived server process by default, so any host that supports persistent processes works - see Hetzner/Fly/Railway/Docker below. It can also run on Cloudflare Workers (no persistent process) - see that section below for the different deploy model and its own setup steps.
Build
bun run build
Output:
dist/client/, static assets + prerendered HTML filesdist/server/server.js, SSR fetch handler (Bun-compatible)
Start
bun run start
Runs server/start.ts , a thin Bun wrapper that serves prerendered HTML files statically before delegating to the SSR handler. The landing page (/) is served from dist/client/index.html without touching the SSR process.
To test the production build locally (matches CDN behavior for prerendered routes):
make prod # build + start
Environment variables
Set all production values before deploying. At minimum:
DATABASE_URL=postgres://user:password@host:5432/dbname
BETTER_AUTH_SECRET=<32-char-secret>
BETTER_AUTH_URL=https://myapp.com
RESEND_API_KEY=re_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
VITE_STRIPE_PRO_PRICE_ID=price_...
VITE_STRIPE_PRO_YEARLY_PRICE_ID=price_...
ADMIN_EMAILS=you@myapp.com
Run bun run db:migrate (and bun run db:seed if needed) against
DATABASE_URL before first boot - see .env.example for the full env var
list.
Data persistence
This is the Postgres-backed fork of warpkit: users, sessions, subscriptions,
and every feature table (notes, uploads, jobs, notifications, ...) live in
the shared Postgres database at DATABASE_URL, not on local disk - there is
no per-user SQLite file and no separate jobs database file. Postgres itself
must be on persistent storage (a managed instance, or a self-hosted one on a
persistent volume), same as any Postgres deployment.
Bun's own process still writes one thing to local disk:
data/assets-cache/: persistent cache of hashed static assets, populated and pruned automatically on every boot - keeps a tab left open across a deploy from 404ing on its next chunk fetch. Never point a reverse proxy's static-asset root atdist/clientdirectly instead of this directory, and don't delete it between deploys - see the Hetzner+Caddy section below.
That directory must be on a persistent volume on any target that keeps a long-lived Bun process (Hetzner, Fly, Railway, Docker below) - the assets still resolve correctly without it, just without warm-cache benefits across deploys. Not applicable to the Cloudflare Workers target (see below), which serves static assets via Workers' own native binding instead.
Cloudflare Workers
Serverless target, no persistent process. Coexists with self-hosted -
server/worker.ts (Workers entry point) and server/start.ts (Bun entry
point) both wrap the same dist/server/server.js SSR handler built by the
one shared bun run build; neither depends on the other.
One-time setup:
bun add -D wrangler(already a devDependency in this repo)wrangler login- Provision Hyperdrive, pointed at your Postgres connection string (any
host - Supabase, Neon, RDS, self-hosted, etc; Hyperdrive proxies
standard Postgres wire protocol, not an HTTP-only driver):
Copy the printedwrangler hyperdrive create warpkit-postgres --connection-string="postgres://user:password@host:5432/dbname"idintowrangler.jsonc'shyperdrive[0].id. - Run migrations against that same Postgres instance from wherever you
normally would (
DATABASE_URL=<connection-string> bun run db:migrate) - Hyperdrive proxies connections for the deployed Worker, it doesn't run migrations itself. If your Postgres host's direct-connection endpoint is IPv6-only (e.g. Supabase's default host) and your local machine has no IPv6 egress, use that provider's IPv4-compatible connection pooler endpoint for the one-off migration run instead - Hyperdrive itself reaches either fine from Cloudflare's edge. wrangler secret put <NAME>for every real secret env var (BETTER_AUTH_SECRET,STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,RESEND_API_KEY,ADMIN_EMAILS, etc) - these do not come from.env/.env.examplein this target;wrangler devreads.envlocally for convenience, but a real deploy needs each one set as a Workers secret.
Build + deploy:
bun run build
wrangler deploy
wrangler deploy bundles server/worker.ts (which dynamic-imports the
already-built dist/server/server.js) plus Workers' native Static Assets
binding serving dist/client/ directly - no reverse proxy, no Caddy/nginx
config, unlike the persistent-process targets above.
Local dev against real Cloudflare infrastructure (Hyperdrive has no local emulation without a real reachable Postgres):
wrangler dev --remote
Cron Trigger (background jobs): wrangler.jsonc's triggers.crons
("* * * * *", Workers' finest granularity - coarser than self-hosted's 5s
poll loop by necessity) fires server/worker.ts's scheduled() export,
which calls runTick() from src/features/jobs/tick.server.ts - the exact
same job-draining logic self-hosted's poll loop uses. Reconciler cadence
(reconcilePendingDeletions, reconcileBillingSubscriptions) is not driven
directly by the cron - both run through src/features/jobs/scheduler.server.ts's
SCHEDULES table (daily entries), evaluated automatically by runTick
itself. Test a firing locally with wrangler dev --remote --test-scheduled,
then curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*" (the
/__scheduled test endpoint only exists when --test-scheduled is passed).
nodejs_compat is required (compatibility_flags in wrangler.jsonc)
pg(the Postgres driverdrizzle-orm/node-postgresuses) needs Node.js API shims Workers doesn't provide by default.
Known limitations, accepted as-is:
- The in-memory per-type job rate limiter
(
src/features/jobs/rate-limiter.ts) resets every Cron Trigger invocation, since each firing is a fresh isolate - same gap self-hosted's own code comments already document for any serverless/Cron target, not new to Workers specifically. - A prerendered marketing/auth path with no session cookie is served
straight from the Static Assets binding; the same path with a session
cookie is forced through real SSR (
server/worker.ts's own cookie check, matchingserver/start.ts's equivalent logic) - this is deliberate, not a gap, but worth knowing if you add a new prerendered route: it needs adding to bothsrc/lib/prerendered-paths.ts'sSTATIC_HTML_PATHSandwrangler.jsonc'sassets.run_worker_firstlist, or the cookie check never runs for it.
Hetzner VPS + Caddy (recommended for self-hosting)
Run Caddy as a reverse proxy in front of Bun. Caddy handles TLS, serves static assets directly from disk, and forwards everything else to the Bun process.
Caddyfile (/etc/caddy/Caddyfile):
myapp.com {
# Serve immutable hashed assets directly from disk , bypasses Bun entirely.
# Caddy decompresses brotli/gzip automatically if the client supports it.
# Root points at the persistent assets cache (populated and pruned by
# Bun on every boot, see server/assets-cache.ts), not dist/client
# directly - assets persist across deploys here so a tab left open
# during a deploy doesn't 404 on its next chunk fetch. Do not point
# this back at dist/client or delete this directory between deploys.
handle /assets/* {
root * /app/data/assets-cache
file_server {
precompressed br gzip
}
header Cache-Control "public, max-age=31536000, immutable"
}
# Everything else proxies to Bun (SSR + API routes).
handle {
reverse_proxy localhost:3000
}
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
-Server
}
encode gzip
log {
output file /var/log/caddy/myapp.log
}
}
Deploy steps:
- Build on CI or locally, copy
dist/to the server - Start the Bun process:
PORT=3000 bun server/start.ts - Reload Caddy:
caddy reload
For zero-downtime restarts, run Bun under systemd and use systemctl restart myapp after deploying.
systemd unit (/etc/systemd/system/myapp.service):
[Unit]
Description=warpkit app
After=network.target
[Service]
WorkingDirectory=/app
ExecStart=/usr/local/bin/bun server/start.ts
Restart=always
RestartSec=5
EnvironmentFile=/app/.env
User=www-data
[Install]
WantedBy=multi-user.target
/app/.env should be chown root:root / chmod 600 , the unit runs as
www-data, which only needs to read it via EnvironmentFile, not own it,
and /app itself is the app's working directory (writable by the deploy
process), not a place secrets should be group/world-readable by default.
Fly.io
fly launch: auto-detects Bun- Provision Postgres (Fly Postgres, or any external managed instance) and
set
DATABASE_URLto it - Attach a persistent volume mounted at
/datafordata/assets-cache/only (see Data persistence above) - not required for correctness, only for cross-deploy asset cache warmth - Set all other env vars with
fly secrets set
fly secrets set BETTER_AUTH_SECRET=... RESEND_API_KEY=... STRIPE_SECRET_KEY=... DATABASE_URL=...
Railway
- Deploy from GitHub
- Add a Railway Postgres plugin (or point
DATABASE_URLat any external Postgres) and runbun run db:migrateagainst it - Set env vars in the Railway dashboard
Docker
See the repo-root Dockerfile , multi-stage (build stage produces dist/,
runtime stage installs production-only deps), runs as a non-root user, and
has a HEALTHCHECK against GET / (server/start.ts serves the prerendered
landing page before touching SSR, so an anonymous request there is a real
liveness signal).
docker build -t warpkit .
docker run -d -p 3000:3000 \
-v $(pwd)/data:/app/data \
--env-file /path/to/production.env \
warpkit
Use --env-file, not -e KEY=value on the command line , CLI-supplied env
vars land in shell history, are readable via docker inspect by anyone in
the docker group, and are exposed at /proc/<pid>/environ. production.env
should be root-owned, chmod 600, and never committed (same rule as .env).
It needs at least BETTER_AUTH_SECRET, STRIPE_SECRET_KEY,
RESEND_API_KEY, and ADMIN_EMAILS , see .env.example for the full list.
Point DATABASE_URL at a real Postgres instance (managed or self-hosted -
not something Docker provisions for you). Optionally mount a volume at
/app/data for the static-assets cache only (see Data persistence above).
Run bun run db:migrate (and bun run db:seed if needed) against
DATABASE_URL before first boot, same as any non-Docker deploy.
Real secrets are only needed at docker run time, not docker build time ,
the build stage's vite build triggers prerendering, which boots a real
server instance and would otherwise require production secrets just to
bundle static assets. The Dockerfile sidesteps this with a build-only
NODE_ENV=test + placeholder BETTER_AUTH_SECRET, mirroring what make ci
already does via a local (gitignored, never-baked-into-the-image) .env
file.
Stripe webhooks
After deploying, register your webhook URL in the Stripe dashboard:
https://myapp.com/api/v1/stripe-webhook
Events to enable: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed, invoice.paid, charge.refunded, charge.dispute.created. Under-registering charge.refunded or charge.dispute.created silently breaks refund/dispute access revocation , see docs/warpkit/features/billing.md for the full event table.
Copy the webhook signing secret to STRIPE_WEBHOOK_SECRET.