helipod
Deploy & Operate

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)
Commandhelipod deploy --target cloudflarehand-wired, no helipod deploy integration
StorageDO-SQLite (per-object)R2 (object-store substrate)
Scheduled functions / crons / triggersFire: the DO's own alarm wakes themDo not fire: container stops ~5s after idle
StatusSupported targetExperimental

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 wrangler

Authenticate 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

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 production

helipod deploy --target cloudflare:

  • Checks and adds durable_objects.bindings, migrations, and compatibility_flags in wrangler.jsonc if missing (byte-for-byte unchanged, comments included, if nothing was missing).
  • Shells out to wrangler deploy, passing --env <name> through when your config's environments.<name> sets wranglerEnv.
  • 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:

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_KEY

HELIPOD_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

What works

Reactive queries/mutations, WebSocket syncFull support. Writer and subscription index are the same in-process object, no RPC hop.
Scheduled functions, crons, triggersWork. The DO's own alarm wakes due timers through idle/hibernation.
File storageSupported via the R2 binding above.
ScaleSingle 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).

helipod/schema.ts
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

.github/workflows/deploy.yml
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/helipod

Point 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.json

Deploy 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.

  • Deploy and build: helipod deploy's serve target and helipod 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.

On this page