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
/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- 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
Render
- New Web Service from GitHub, runtime: Docker (uses the repo-root
Dockerfile) - Add a persistent disk, mount at
/app/data - Set env vars in the Render dashboard
DigitalOcean / Lightsail / any VPS
Same pattern as the Hetzner section above: plain VPS, Caddy (or nginx) reverse proxy for TLS, Bun process under systemd for restarts. Swap the Caddyfile's myapp.com for your domain and the deploy steps are identical regardless of which VPS provider you're on.
Coolify (self-hosted PaaS)
- New Resource → Dockerfile deployment, point at the repo
- Add a persistent volume, mount at
/app/data - Set env vars in the Coolify UI
Coolify runs on your own VPS (including Hetzner), so this is Vercel-style deploy UX without giving up the persistent-volume requirement above.
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.
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.