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 files
  • dist/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 at dist/client directly 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:

  1. bun add -D wrangler (already a devDependency in this repo)
  2. wrangler login
  3. 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):
    wrangler hyperdrive create warpkit-postgres --connection-string="postgres://user:password@host:5432/dbname"
    
    Copy the printed id into wrangler.jsonc's hyperdrive[0].id.
  4. 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.
  5. 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.example in this target; wrangler dev reads .env locally 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 driver drizzle-orm/node-postgres uses) 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, matching server/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 both src/lib/prerendered-paths.ts's STATIC_HTML_PATHS and wrangler.jsonc's assets.run_worker_first list, or the cookie check never runs for it.

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:

  1. Build on CI or locally, copy dist/ to the server
  2. Start the Bun process: PORT=3000 bun server/start.ts
  3. 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

  1. fly launch: auto-detects Bun
  2. Provision Postgres (Fly Postgres, or any external managed instance) and set DATABASE_URL to it
  3. Attach a persistent volume mounted at /data for data/assets-cache/ only (see Data persistence above) - not required for correctness, only for cross-deploy asset cache warmth
  4. Set all other env vars with fly secrets set
fly secrets set BETTER_AUTH_SECRET=... RESEND_API_KEY=... STRIPE_SECRET_KEY=... DATABASE_URL=...

Railway

  1. Deploy from GitHub
  2. Add a Railway Postgres plugin (or point DATABASE_URL at any external Postgres) and run bun run db:migrate against it
  3. 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.