Pulumi for Cloudflare Workers AI Infrastructure
10 min read · updated August 11, 2026
Two things make this harder than it looks: the Pulumi Cloudflare provider renamed most of the Workers properties in its version 5 rewrite, and it still has no resource for Vectorize. Both have clean answers, but neither is in the example you will find first.
Why older examples do not run
The Cloudflare provider was regenerated from Cloudflare’s OpenAPI description for version 5, and the Workers resources changed shape. Older material — including some still-linked tutorials — constructs a script like this:
// Older shape. Property names no longer match.
const worker = new cloudflare.WorkersScript("hello-world-worker", {
accountId: accountId,
name: "hello-world-worker",
content: content,
module: true,
});The current registry documentation for cloudflare.WorkersScript lists accountId, scriptName, content, mainModule, bindings, compatibilityDate, compatibilityFlags and observability. So name became scriptName, and the boolean module was replaced by mainModule, which names the entry file rather than asserting a format.
If a Pulumi program errors on an unrecognised property against Cloudflare, this is almost always why. Check the provider major version in your lockfile before debugging anything else.
package.json and read the release notes on a major bump — a regenerated provider can rename properties across an entire service area at once.The Worker resource
import * as cloudflare from "@pulumi/cloudflare";
import * as fs from "fs";
const accountId = new pulumi.Config("cloudflare").require("accountId");
const worker = new cloudflare.WorkersScript("rag-worker", {
accountId,
scriptName: "rag-worker",
content: fs.readFileSync("./dist/index.js", "utf8"),
mainModule: "index.js",
compatibilityDate: "2026-06-01",
compatibilityFlags: ["nodejs_compat"],
observability: {
enabled: true,
headSamplingRate: 1,
},
});compatibilityDate is the argument to think about rather than copy. It pins the runtime semantics your Worker was written against, and moving it forward can change behaviour — that is its entire purpose. Setting it to today’s date at every deploy defeats it. Set it once, deliberately, and move it when you have a reason.
content takes the bundled script as a string, which means Pulumi is not your bundler. Build with esbuild or wrangler first and read the output; a program that passes unbundled TypeScript will deploy something that fails at runtime rather than at deploy time.
Bindings are an array, not a block per type
In wrangler configuration each binding kind has its own section — a [ai] table, a [[vectorize]] array, and so on. The Pulumi resource flattens all of them into a single bindings array where each entry carries a type discriminator and a name, plus whatever fields that type needs.
const worker = new cloudflare.WorkersScript("rag-worker", {
accountId,
scriptName: "rag-worker",
content: bundled,
mainModule: "index.js",
compatibilityDate: "2026-06-01",
bindings: [
{ type: "ai", name: "AI" },
{ type: "vectorize", name: "VECTORIZE_INDEX", indexName: "docs-index" },
{ type: "secret_text", name: "UPSTREAM_KEY", text: upstreamKey },
],
});Two details worth holding on to. The name is the property that appears on env inside the Worker, so { type: "ai", name: "AI" } is what makes env.AI.run(...) exist. And the Vectorize binding references the index by name, not by an ID or a resource reference — which means Pulumi cannot see that the index exists, and a typo produces a Worker that deploys successfully and throws on first use.
The full binding list Cloudflare documents is long — AI, Analytics Engine, Assets, Browser Rendering, D1, Durable Objects, Hyperdrive, Images, KV, mTLS, Queues, R2, Rate Limiting, Secrets Store, service bindings, Vectorize, Workflows and more. Only some have first-class Pulumi resources for the underlying thing, and the binding array will accept a name for any of them regardless.
The index with no resource
This is the part the row title implies is easy. It is not: Cloudflare states in its own Pulumi tutorial that you must define a dynamic provider for Vectorize because the Cloudflare Pulumi provider does not support that resource, and that updates are left unimplemented because a Vectorize index does not allow them once created.
A dynamic provider is the right answer rather than a workaround: it gives you create and delete against the Cloudflare REST API, so the index participates in pulumi up and pulumi destroy like any other resource.
import * as pulumi from "@pulumi/pulumi";
const vectorizeProvider: pulumi.dynamic.ResourceProvider = {
async create(inputs) {
const res = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${inputs.accountId}/vectorize/v2/indexes`,
{
method: "POST",
headers: {
Authorization: `Bearer ${inputs.apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: inputs.name,
config: { dimensions: inputs.dimensions, metric: inputs.metric },
}),
},
);
const body = await res.json();
if (!body.success) throw new Error(JSON.stringify(body.errors));
return { id: inputs.name, outs: { ...inputs } };
},
async delete(id, props) {
await fetch(
`https://api.cloudflare.com/client/v4/accounts/${props.accountId}/vectorize/v2/indexes/${id}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${props.apiToken}` } },
);
},
};
class VectorizeIndex extends pulumi.dynamic.Resource {
constructor(name: string, args: any, opts?: pulumi.CustomResourceOptions) {
super(vectorizeProvider, name, args, opts);
}
}Because there is no update, mark the immutable inputs so Pulumi replaces rather than tries to patch — pass { replaceOnChanges: ["dimensions", "metric"] } in the resource options. Otherwise a dimension change is a silent no-op and your index quietly no longer matches your embedding model.
The two values you must get right at creation are exactly those. Cloudflare documents the valid distance metrics as cosine, euclidean and dot-product, and its Vectorize limits page states a maximum of 1536 dimensions per vector at 32 bits of precision, with a maximum of 20,000,000 vectors per index. The dimension count must equal the output width of whatever embedding model you use; a mismatch is not detected until you insert.
Drift, replacement and the size ceiling
The first surprise on a team is that somebody runs wrangler deploy to ship a quick fix, and the next pulumi up puts the old code back. That is not a bug. The script body is an input to the resource, so Pulumi treats the deployed content as drift and restores what the program says. A pulumi refresh beforehand will show it clearly.
Resist the reflex to paper over this with ignoreChanges: ["content"]. That leaves Pulumi managing a Worker whose code it no longer knows, so the next genuine change deploys against an unknown baseline. Pick one deploy path per Worker and make it the only one — either Pulumi owns the script, or wrangler does and Pulumi owns only the bindings and the surrounding resources.
Know which changes replace rather than update. scriptName is the Worker’s identity, so renaming it creates a new Worker and deletes the old one — and any route or custom domain pointing at the old name stops resolving during the gap. Editing content, bindings or compatibilityFlags is an update in place, which is why a binding change is cheap and a rename is not.
Two documented limits will stop you before your architecture does. Cloudflare gives the Worker size after compression as 3 MB on the Free plan and 10 MB on the Paid plan. That sounds generous until a bundled SDK, a tokenizer or an embedded prompt library goes in, at which point the deploy fails rather than degrading — move large static data into KV, R2 or an asset binding rather than into the bundle.
The second is subtler. Cloudflare documents that a Worker must parse and execute its global scope — the top-level code outside the handlers — within one second. Work you would normally hoist for efficiency, such as reading a large JSON blob or building an in-memory index at module load, is exactly what breaks this. Move it inside the handler and cache it in a module-level variable on first use instead.
One last thing about the secret_text binding in the earlier example. Its value is a resource input, so it is written into the stack’s checkpoint like any other. Sourcing it from cfg.requireSecret(...) rather than a plain string keeps it encrypted through the state file as well as in the stack YAML — the secretness propagates. Reading the key from a plain environment variable and passing it in loses that property silently, which is the Cloudflare version of the problem keeping model API keys out of Terraform state covers in detail.
Where Pulumi stops and wrangler starts
Cloudflare’s own guidance is a hybrid, and it is worth adopting rather than fighting. Its tutorial drives D1 migrations and some deploys through wrangler invoked from a command resource, triggered on a hash of the migrations directory, while Pulumi owns the resources that have provider coverage.
The line to draw is: Pulumi owns anything with lifecycle and identity — the Worker, the index, the queue, the bucket. Wrangler owns anything that is a one-way operation with no state to reconcile, such as applying a migration. Trying to make Pulumi model a migration is the same mistake as trying to make wrangler model your account topology.
One thing to keep out of both: the API token. Put it in stack configuration as a secret with pulumi config set --secret cloudflare:apiToken so it is encrypted in Pulumi.<stack>.yaml, and read it with requireSecret. That habit is what makes separate dev and prod stacks actually separate rather than notionally separate.