Deploying Strut to Cloudflare
August 4, 2026 · View on GitHub
This deploys the web app (SSR + the same-origin /api/rindle/* API and image-upload routes) to
Cloudflare Workers, with image uploads stored in Cloudflare R2 via a native bucket binding.
Local pnpm dev and the default pnpm build are unchanged — they still run on Node against a local
rindled daemon. The Cloudflare path is opt-in (CF=1, wired through pnpm build:cf / pnpm deploy).
The one hard constraint: the daemon can't run on Workers
Strut's data layer is the rindled daemon — it owns the SQLite database (rindle.db) and the
live-query WebSocket. Workers are stateless and have no persistent local disk or long-lived
listening sockets, so the daemon must be hosted somewhere else. The Worker (and the browser) talk
to it over the network:
┌─────────────────────── Cloudflare Workers ───────────────────────┐
browser ─────▶ │ Strut SSR app + /api/rindle/* (server routes) + R2 binding │
│ └───────────────┬──────────────────────────────────────────────────┘
│ │ https (RINDLE_DAEMON_URL, Bearer RINDLE_DAEMON_TOKEN)
│ wss (RINDLE_DAEMON_WS) ▼
└────────────────▶ ┌──────────────────────────────┐
│ Rindle fleet (host it!) │ SQLite + live-query WebSocket
│ one ingress: control + ws │ needs a persistent volume
└──────────────────────────────┘
Hosting the daemon
Run the fleet on any host that gives you a persistent volume and lets you terminate TLS — a small VM/VPS, Fly.io, Railway, Render, a container platform, etc. Requirements:
-
Persistent disk for the databases (
master.db+follower-0.dband their sidecar files). -
Bind to a reachable interface, not just loopback. The rendered configs under
.rindle/bind127.0.0.1; change that and put the fleet behind a TLS-terminating reverse proxy so onlyhttps/wssis exposed publicly. -
Set an auth token so the control plane isn't open to the world (the Worker sends
Authorization: Bearer <RINDLE_DAEMON_TOKEN>; see the rindle daemon docs for enabling token auth). -
Expose two public endpoints:
- the control plane as
https://…→ the Worker'sRINDLE_DAEMON_URL(server env) - the live-query WebSocket as
wss://…→RINDLE_DAEMON_WS(server env), which the Worker hands to the browser at runtime via/api/rindle/config— so no client rebuild per host
Since Rindle 0.9 both are the SAME ingress port (
:22050locally); the pre-0.9 split:7600/:7601daemon no longer exists. - the control plane as
If you don't want to operate a daemon yet, deploy the Worker anyway — it serves and builds fine — but reads/writes will fail until
RINDLE_DAEMON_URLpoints at a running fleet.
Managed daemon: Rindle Cloud (headwaters)
The official Strut deployment doesn't self-host rindled — it runs a managed app on Rindle Cloud,
and wrangler.jsonc's RINDLE_DAEMON_URL/RINDLE_DAEMON_WS already point at it. The repo is bound to
that app in .rindle/cloud.json (committed — it holds an app id + public names, no secrets, the
wrangler.toml analogue). rindle.ncl describes the fleet's shape. With that binding in place:
rindle login # once — device flow to https://cloud.rindle.sh
pnpm rindle:deploy # ensure/re-attach the managed app (won't provision a duplicate)
pnpm rindle:migrate:remote # push migrations/ (Rindle schema) to the managed daemon
Forking Strut? Before your first
rindle deploy,rm .rindle/cloud.jsonso you provision your own app instead of re-attaching to the official one (which you can't access — deploy will error until you remove it). Then pointRINDLE_DAEMON_URL/RINDLE_DAEMON_WSinwrangler.jsoncat your app, exactly as you override the other deployment identifiers here. Or ignore all of this and self-hostrindledper the section above — Rindle Cloud is optional.
Image storage: three backends, auto-selected
server/upload.ts picks a storage backend at runtime, in priority order:
- Native R2 binding — used when running on Workers (
env.STRUT_UPLOADS). No credentials needed. This is the production path on Cloudflare. - R2 over the S3 API — used on a non-Workers host when the
R2_*credential env vars are set. - Local disk (
.uploads/) — the zero-config dev fallback.
The binding is read through server/cf-env.ts, which imports env from cloudflare:workers only at
runtime and returns null off-Workers — so the same code runs in all three environments.
Public URL vs. Worker-served: after storing an object, the returned image URL is either
${R2_PUBLIC_BASE_URL}/<key>whenR2_PUBLIC_BASE_URLis set (a public r2.dev domain or a custom domain — cheapest + CDN-cached), or/api/rindle/uploads/<key>when it's empty — the image is streamed back through the Worker from the bucket, so a private bucket works with no extra setup (at the cost of a Worker request per load).
Runnable artifacts: sandbox origin
Runnable artifact blocks store the author's code as a built HTML doc in the same STRUT_UPLOADS
bucket (under an artifacts/ prefix) and serve it at GET /a/<hash>.html with text/html + a strict
CSP + nosniff (server/artifact.ts). It runs in a <iframe sandbox="allow-scripts"> — never with
allow-same-origin — so the code executes in a unique opaque origin and cannot read the app's
cookies/storage/DOM even if served same-origin.
For defense-in-depth, serve artifacts from a separate origin than the app:
- Add a second custom domain (e.g.
sandbox.strut.io) to this same Worker — append it to theroutesarray inwrangler.jsonc({ "pattern": "sandbox.strut.io", "custom_domain": true }), or add it in the dashboard (Workers & Pages → strut → Domains & Routes). The zone must be active. - Set
ARTIFACT_ORIGINto that origin (e.g.https://sandbox.strut.io). ArtifactsrcURLs then point there;/a/<key>answers on that host from the same Worker.
If ARTIFACT_ORIGIN is empty (the default, and dev), artifacts are served same-origin at /a/<key> —
still fully sandboxed (opaque origin), just without the second boundary.
One-time setup
1. Create the R2 bucket
npx wrangler r2 bucket create strut-uploads
The binding is already declared in wrangler.jsonc:
"r2_buckets": [{ "binding": "STRUT_UPLOADS", "bucket_name": "strut-uploads" }]
(Optional) For direct CDN URLs, enable public access / attach a custom domain to the bucket and set
R2_PUBLIC_BASE_URL (see below). Otherwise leave it empty and images are served through the Worker.
2. Set config in wrangler.jsonc (vars)
Edit the vars block:
"vars": {
"RINDLE_DAEMON_URL": "https://your-daemon-host.example.com", // daemon control plane (server-side)
"RINDLE_DAEMON_WS": "wss://your-daemon-host.example.com", // daemon WebSocket (served to browser)
"R2_PUBLIC_BASE_URL": "" // "" = serve via Worker; or https://pub-xxxx.r2.dev
}
Both are runtime config — the browser fetches RINDLE_DAEMON_WS from /api/rindle/config when it
boots, so pointing at a different daemon is just a vars edit + redeploy, no client rebuild. (A
build-time VITE_RINDLE_WS in .env still works as a local-dev override; runtime config wins when set.)
3. Set secrets (not stored in the repo)
npx wrangler secret put RINDLE_DAEMON_TOKEN # matches the token your daemon requires
With nodejs_compat, both vars and secrets are surfaced on process.env at runtime, so
server/rindle-api.ts keeps reading process.env.RINDLE_DAEMON_URL / RINDLE_DAEMON_TOKEN unchanged.
Deploy
pnpm cf-typegen # (optional) regenerate worker-configuration.d.ts for binding autocomplete
pnpm preview:cf # (optional) build + preview the Worker locally in workerd before deploying
pnpm deploy # CF=1 vite build && wrangler deploy -c dist/server/wrangler.json
CF=1 vite build runs the app through @cloudflare/vite-plugin, producing the Worker at
dist/server/index.js, static assets at dist/client/, and the deployable config at
dist/server/wrangler.json (this is why deploy passes -c dist/server/wrangler.json — the root
wrangler.jsonc is the plugin's source config, not a directly-deployable one).
Scripts
| Script | What it does |
|---|---|
pnpm dev | Local Node dev (daemon + Vite). Unchanged — no Cloudflare. |
pnpm build | Default Node build. Unchanged. |
pnpm build:cf | CF=1 vite build — build the Cloudflare Worker bundle. |
pnpm preview:cf | Build, then preview the Worker locally in workerd (vite preview). |
pnpm deploy | Build the Worker and wrangler deploy the generated config. |
pnpm cf-typegen | wrangler types — regenerate binding types (gitignored). |
Notes & gotchas
worker-configuration.d.tsis gitignored and excluded fromtsconfig.json. Its workerd runtime globals shadow the DOM lib and break browser-side type-checking (e.g.Response.json()), so the two server files that need R2 types use a small local interface (R2BucketLikeinserver/cf-env.ts) instead. Runpnpm cf-typegenif you want full binding autocomplete while editing Worker code.compatibility_dateinwrangler.jsoncis pinned to2026-07-01to match the installedworkerd; bump it as you updatewrangler/workerd.- The AWS S3 SDK is imported lazily and only on the S3-fallback path; it bundles for Workers but is never invoked there (the native binding wins), so no R2 credentials are needed on Cloudflare.