Cloudflare
helipod deploy --target cloudflare provisions a Durable-Object-native host via wrangler.
helipod has two Cloudflare deployment paths. Most projects want the first one; the Containers path is experimental and gives up scheduled work (details below).
| Durable-Object-native (this page's main path) | Containers (alternative) | |
|---|---|---|
| Command | helipod deploy --target cloudflare | hand-wired, no helipod deploy integration |
| Storage | DO-SQLite (per-object) | R2 (object-store substrate) |
| Scheduled functions / crons / triggers | Fire: the DO's own alarm wakes them | Do not fire: container stops ~5s after idle |
| Status | Supported target | Experimental |
The recommended path: Durable-Object-native
helipod deploy --target cloudflare provisions @helipod/runtime-cloudflare's Durable Object
host via wrangler deploy. A Durable Object
is Cloudflare's stateful primitive: a single-instance, globally addressable object with its own
storage and single-threaded execution. One Durable Object is the OCC writer, the DO-SQLite store, every
hibernatable WebSocket, and the wake alarm that fires scheduled functions/crons/triggers even
though the DO idles between requests. It's a single global DO in v1, not a sharded fleet. See
Scaling for the paid-tier multi-shard follow-on.
Install wrangler
npm i -D wranglerAuthenticate locally with wrangler login, or in CI set CLOUDFLARE_API_TOKEN (and typically
CLOUDFLARE_ACCOUNT_ID) as environment variables. Don't run wrangler login in CI.
Write a wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-app",
"main": "worker.ts",
"compatibility_date": "2024-11-27",
// node:crypto (fingerprint/resume hashing) needs nodejs_compat.
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "HELIPOD_DO", "class_name": "HelipodDO" }]
},
"migrations": [
// DO-SQLite needs the SQLite-backed class migration tag.
{ "tag": "v1", "new_sqlite_classes": ["HelipodDO"] }
]
}Your worker source needs only a few lines: export class HelipodDO extends HelipodDurableObject { … } plus export default createWorkerHandler("HELIPOD_DO").
helipod deploy doesn't scaffold this file for you. It reconciles bindings into an existing
wrangler.jsonc, additively, never dropping a field you hand-wrote.
You also need a helipod/ directory with committed _generated/, the same requirement as
helipod serve. helipod deploy refreshes codegen before packaging.
Deploy
helipod deploy --target cloudflare --env productionhelipod deploy --target cloudflare:
- Checks and adds
durable_objects.bindings,migrations, andcompatibility_flagsinwrangler.jsoncif missing (byte-for-byte unchanged, comments included, if nothing was missing). - Shells out to
wrangler deploy, passing--env <name>through when your config'senvironments.<name>setswranglerEnv. - Prints the deployed URL, parsed from wrangler's own output.
--dry-run runs preflight and packaging (validate wrangler, refresh codegen, reconcile
wrangler.jsonc) and skips the actual wrangler deploy, useful for validating a PR. --check
fails if your committed helipod/_generated/ has drifted from a fresh codegen run.
Configure environments in helipod.config.ts:
import { defineConfig } from "@helipod/component";
export default defineConfig({
components: [], // your composed components, if any
deploy: {
targets: {
cloudflare: {
provider: "cloudflare",
environments: {
production: {}, // wrangler deploy
staging: { wranglerEnv: "staging" }, // wrangler deploy --env staging
},
},
},
},
});Set the admin key as a secret
wrangler secret put HELIPOD_ADMIN_KEYHELIPOD_ADMIN_KEY is required. The DO fails fast on an empty admin key. helipod deploy
doesn't manage secrets: wrangler secret put is a one-time, out-of-band step, the same way
CLOUDFLARE_API_TOKEN itself never belongs in wrangler.jsonc.
Optional configuration
If your app uses file storage, turn on an R2 binding:
cloudflare: {
provider: "cloudflare",
r2: true,
r2BucketName: "my-app-storage", // optional, defaults to "helipod-storage"
},helipod deploy --target cloudflare reconciles an r2_buckets binding into wrangler.jsonc.
Create the bucket once, out-of-band:
wrangler r2 bucket create my-app-storageA Durable Object is single-homed and never moves once created. By default it lands near whoever
first reaches it. Set HELIPOD_DO_LOCATION_HINT (one of the 11 Cloudflare region codes, for
example enam) as a Worker environment variable to pin it explicitly. Only the first request
after a fresh deploy honors the hint.
What works
| Reactive queries/mutations, WebSocket sync | Full support. Writer and subscription index are the same in-process object, no RPC hop. |
| Scheduled functions, crons, triggers | Work. The DO's own alarm wakes due timers through idle/hibernation. |
| File storage | Supported via the R2 binding above. |
| Scale | Single global DO in v1, not a sharded fleet. See Scaling for the paid-tier multi-shard router. |
Global tables (D1)
.global() on a table definition puts that table in Cloudflare
D1 (a shared relational database) instead of a Durable
Object's local store, so it's the same data no matter which shard or DO a mutation or query runs
on. It exists for data that must be globally unique or globally consistent: a users table keyed
by email, an orgs table keyed by slug. It's the natural companion to the paid-tier multi-shard
router (each shard-DO has its own local store, exactly wrong for account lookups), and it works on
the single-DO host too whenever a D1 binding is wired.
.global() is mutually exclusive with .shardKey(): a table is either sharded or global, never
both (declaring both throws at schema build time).
import { defineSchema, defineTable, v } from "@helipod/values";
export default defineSchema({
accounts: defineTable({
email: v.string(),
name: v.string(),
})
// { unique: true } on a .global() table is a REAL global-unique constraint
// (a D1 CREATE UNIQUE INDEX), enforced across every shard.
.index("by_email", ["email"], { unique: true })
.global(),
});Reads and writes route to D1 automatically; the function body looks like any other table's.
ctx.db.get(id) and equality index queries (.eq(field, value), optionally .take(n)) are
supported. A mutation that inserts a global row and reads it back in the same handler sees its own
write (a per-mutation overlay provides read-your-own-writes before the write ever hits D1), and a
row written through one shard-DO is readable through another immediately after commit. A violated
unique constraint rejects the mutation with an error naming the table and field.
Subscriptions to a .global() table are live, via the same useQuery as any other table, but the
mechanism is a poller, not commit-time push: a DO with at least one live global subscription checks
a D1 version counter about every 2 seconds and pushes only on change (a DO with none polls
nothing). Expect a global change to surface within roughly that interval; use an
optimistic update if the writing client's own UI needs to
reflect it instantly.
Honest boundaries, all fail-fast rather than silent:
- No co-writes. One mutation writes global tables or sharded/root tables, never both (a D1 write can't enlist in a shard's local transaction, so allowing both would admit a partial commit). Split into two mutations.
- Equality-only queries. Range comparisons,
.order(), filters, and.paginate()on a.global()table are rejected with a clear error. { unique: true }means D1. The same option on a non-global table's index is rejected at schema-load time, since a per-shard store can't enforce it globally.- Row policies aren't supported here yet. An authz read or write policy on a
.global()table is rejected (read policies are not yet supported on .global() tables), never silently skipped. - Cloudflare only. Without a D1 binding, any read or write against a
.global()table fails fast naming the table. You can still declare.global()in a schema you also run locally; you just can't exercise those tables outside Cloudflare.
Wiring: the d1_databases binding is currently manual (unlike the DO/R2 bindings the deploy target
reconciles). Create the database once with wrangler d1 create <name>, add
{ "binding": "DB", "database_name": "...", "database_id": "..." } to wrangler.jsonc, and pass
it through your DO subclass's appConfig as d1: bindingD1Client(env.DB) (from
@helipod/docstore-d1). The D1 tables and indexes for every .global() table are created
automatically on first boot with the binding present; there's no migration step.
CI: GitHub Actions
name: deploy
on:
pull_request:
push: { branches: [main] }
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: npm i -D wrangler
- if: github.event_name == 'pull_request'
run: bunx helipod deploy --check --dry-run --target cloudflare
- if: github.ref == 'refs/heads/main'
run: bunx helipod deploy --target cloudflare --env production
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
HELIPOD_ADMIN_KEY: ${{ secrets.HELIPOD_ADMIN_KEY }}Every target's preflight requires credentials up front and fails fast with an actionable error
in non-interactive mode (CI=true). It never prompts or hangs waiting on stdin.
The alternative path: Cloudflare Containers (experimental)
If you specifically want the same portable helipod serve image (SQLite/Postgres storage
options) you'd run anywhere else, running inside a Cloudflare Container instead of a Durable
Object, that's possible too. It's hand-wired though (no helipod deploy integration), and it has
a correctness gap worth knowing before you pick it.
Scheduled work does not fire
Scheduled functions, crons, triggers, and the file-storage reaper do not fire. Cloudflare stops the container about 5 seconds after the last request, the same mechanism that gives scale-to-zero. A cron set for 03:00 only runs if traffic happens to arrive at 03:00. Use this path only for a request-driven app that doesn't depend on any of those.
It uses Workers (a stateless router), Containers (running the shipped helipod serve image),
and R2 as the object-store substrate. R2 has to be the source of truth because container disk is
ephemeral. You'll need a Workers Paid plan (about $5/mo, Containers and DOs have no free tier), an
R2 bucket, and Docker able to build linux/amd64 (cross-build explicitly on Apple Silicon:
docker build --platform linux/amd64). Your app must be baked into the image, since Containers
don't support bind mounts:
FROM helipod:latest
COPY ./helipod /app/helipodPoint serve at R2 with --object-store (s3+https://<account-id>.r2.cloudflarestorage.com/<bucket>?region=auto&forcePathStyle=true).
Expect warm cold-starts around 4.5s (about 7.3s cold) and writes that are structurally slower than
a local-disk host, since every commit is an R2 CAS round trip.
Choose this path only if scale-to-zero economics matter more than write latency and you don't need scheduled work. Otherwise prefer the Durable-Object-native path above, or Docker self-hosting for a non-Cloudflare target.
Moving data between hosts
The two Cloudflare paths, and non-Cloudflare hosts, store data in different physical topologies. Data doesn't teleport between them. Export a portable dump from one deployment and import it into another:
HELIPOD_ADMIN_KEY=… helipod migrate export --url https://source.example.com --out dump.json
HELIPOD_ADMIN_KEY=… helipod migrate import --url https://target.example.com --in dump.jsonDeploy the matching schema on the target first. Import refuses if table numbers don't match the dump's. Import targets a fresh deployment, not a merge, and is single-shard only.
Related
- Deploy and build:
helipod deploy'sservetarget andhelipod build, the other ways to ship a change. - Scaling: the paid-tier multi-shard Cloudflare router and the Postgres fleet, for when a single node (DO or otherwise) isn't enough.
- Self-hosting with Docker: the non-Cloudflare baseline.
- Vite plugin: local development for a Vite app that talks to this deployment.