Deployment
Deployment
Warpkit is a Bun + TanStack Start app. It runs as a long-lived server process, so any host that supports persistent processes works.
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_PATH=/data/meta.db # path to shared SQLite file
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
Data persistence
Warpkit writes to disk:
DATABASE_PATHpath: shared app SQLite (users, sessions, subscriptions)data/users/: per-user SQLite files
Both paths must be on a persistent volume, not ephemeral storage.
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.
handle /assets/* {
root * /app/dist/client
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
Fly.io
fly launch: auto-detects Bun- Attach a persistent volume and mount it at
/data - Set
DATABASE_PATH=/data/meta.db - Set all other env vars with
fly secrets set
fly secrets set BETTER_AUTH_SECRET=... RESEND_API_KEY=... STRIPE_SECRET_KEY=...
Railway
- Deploy from GitHub
- Add a volume, mount at
/data - 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 \
-e BETTER_AUTH_SECRET=... \
-e STRIPE_SECRET_KEY=... \
-e RESEND_API_KEY=... \
-e ADMIN_EMAILS=you@example.com \
warpkit
Mount a volume at /app/data for persistence (DATABASE_PATH,
USER_DATA_DIR, JOBS_DB_PATH all default under there , see
.env.example). Run bun run db:migrate (and bun run db:seed if needed)
against that volume 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.