Multitenancy
In a multi-tenant application, each tenant must only ever see their own data. AI Search supports two ways to isolate search per tenant: give each tenant its own instance, or share one instance and filter by tenant at query time.
| Approach | How it isolates | Choose it when |
|---|---|---|
| Instance per tenant (recommended) | Each tenant gets a separate instance with its own storage and index | You need strong isolation, or you create and delete tenants at runtime |
| Shared instance with filtering | One instance holds every tenant; a metadata filter scopes each query | You have many small tenants and want the simplest setup |
Both approaches use a Cloudflare Worker. Create the project first, then follow the option you chose.
- Sign up for a Cloudflare account ↗.
- Install
Node.js↗.
Node.js version manager
Use a Node version manager like Volta ↗ or nvm ↗ to avoid permission issues and change Node.js versions. Wrangler, discussed later in this guide, requires a Node version of 16.17.0 or later.
Create a new Worker project using the create-cloudflare CLI (C3). C3 ↗ is a command-line tool designed to help you set up and deploy new applications to Cloudflare.
Create a new project named tenant-search by running:
npm create cloudflare@latest -- tenant-search yarn create cloudflare tenant-search pnpm create cloudflare@latest tenant-search For setup, select the following options:
- For What would you like to start with?, choose
Hello World example. - For Which template would you like to use?, choose
Worker only. - For Which language do you want to use?, choose
TypeScript. - For Do you want to use git for version control?, choose
Yes. - For Do you want to deploy your application?, choose
No(we will be making some changes before deploying).
Go to your application directory:
cd tenant-searchThis is the recommended approach. Each tenant gets a separate instance with its own storage and search index, so one tenant can never retrieve another tenant's documents.
Create an isolated AI Search instance for each tenant at runtime using the namespace binding.
Add the namespace binding to your Wrangler configuration file:
{ "$schema": "./node_modules/wrangler/config-schema.json", "ai_search_namespaces": [ { "binding": "TENANTS", "namespace": "default", "remote": true } ]}[[ai_search_namespaces]]binding = "TENANTS"namespace = "default"remote = trueThe remote option lets wrangler dev proxy requests to your deployed instances, since AI Search does not run locally.
Each tenant's instance holds documents that you upload directly to it, with no external data source.
Update src/index.ts. This Worker identifies the tenant from a request header, then creates, populates, searches, and deletes that tenant's instance.
export default { async fetch(request, env) { const url = new URL(request.url);
// Identify the tenant from the request header. const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) { return new Response("Missing x-tenant-id header", { status: 400 }); }
// Create a new instance for the tenant. if (url.pathname === "/onboard" && request.method === "POST") { const instance = await env.TENANTS.create({ id: `tenant-${tenantId}`, }); return Response.json({ success: true, instance: await instance.info() }); }
// Upload a document to the tenant's instance. if (url.pathname === "/upload" && request.method === "POST") { const formData = await request.formData(); const file = formData.get("file");
const item = await env.TENANTS.get(`tenant-${tenantId}`).items.upload( file.name, await file.arrayBuffer(), ); return Response.json({ success: true, item }); }
// Search the tenant's instance. Search is isolated to their instance. if (url.pathname === "/search") { const query = url.searchParams.get("q") || "";
const results = await env.TENANTS.get(`tenant-${tenantId}`).search({ messages: [{ role: "user", content: query }], }); return Response.json(results); }
// Delete the tenant's instance and all its data. if (url.pathname === "/offboard" && request.method === "DELETE") { await env.TENANTS.delete(`tenant-${tenantId}`); return Response.json({ success: true }); }
return new Response("Not found", { status: 404 }); },};export type Env = { TENANTS: AiSearchNamespace;};
export default { async fetch(request, env): Promise<Response> { const url = new URL(request.url);
// Identify the tenant from the request header. const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) { return new Response("Missing x-tenant-id header", { status: 400 }); }
// Create a new instance for the tenant. if (url.pathname === "/onboard" && request.method === "POST") { const instance = await env.TENANTS.create({ id: `tenant-${tenantId}`, }); return Response.json({ success: true, instance: await instance.info() }); }
// Upload a document to the tenant's instance. if (url.pathname === "/upload" && request.method === "POST") { const formData = await request.formData(); const file = formData.get("file") as File;
const item = await env.TENANTS.get(`tenant-${tenantId}`).items.upload( file.name, await file.arrayBuffer(), ); return Response.json({ success: true, item }); }
// Search the tenant's instance. Search is isolated to their instance. if (url.pathname === "/search") { const query = url.searchParams.get("q") || "";
const results = await env.TENANTS.get(`tenant-${tenantId}`).search({ messages: [{ role: "user", content: query }], }); return Response.json(results); }
// Delete the tenant's instance and all its data. if (url.pathname === "/offboard" && request.method === "DELETE") { await env.TENANTS.delete(`tenant-${tenantId}`); return Response.json({ success: true }); }
return new Response("Not found", { status: 404 }); },} satisfies ExportedHandler<Env>;Start a local development server:
npx wrangler devThen onboard a tenant, upload a document to their instance, search it, and offboard. The x-tenant-id header scopes every request to that tenant's instance:
# Create an isolated instance for tenant "acme"curl -X POST http://localhost:8787/onboard -H "x-tenant-id: acme"
# Upload a document to acme's instancecurl -X POST http://localhost:8787/upload -H "x-tenant-id: acme" -F "file=@./handbook.pdf"
# Search acme's instancecurl "http://localhost:8787/search?q=vacation+policy" -H "x-tenant-id: acme"
# Delete acme's instance and all of its datacurl -X DELETE http://localhost:8787/offboard -H "x-tenant-id: acme"AI Search indexes uploads asynchronously, so wait a moment after uploading before you search.
If your tenants' data already lives in R2, back each tenant's instance with R2 instead of uploading documents to built-in storage. Change the /onboard route from the built-in storage example to create an R2-backed instance. How you configure it depends on how the data is organized.
A bucket per tenant: if each tenant's data is already in its own bucket, point the instance at that whole bucket:
const instance = await env.TENANTS.create({ id: `tenant-${tenantId}`, type: "r2", source: `tenant-${tenantId}-bucket`,});A shared bucket: if every tenant's data lives in one bucket, organized by folder:
Directorymy-bucket
Directorycustomers/
Directoryacme/
- …
Directoryglobex/
- …
Scope each instance to that tenant's folder with path filtering, so it only ever indexes and searches that tenant's objects:
const instance = await env.TENANTS.create({ id: `tenant-${tenantId}`, type: "r2", source: "my-bucket", source_params: { include_items: [`/customers/${tenantId}/**`], },});With either layout, AI Search indexes each tenant's objects on the next sync. Add documents by writing them to R2 rather than uploading through the Worker, and keep the search and offboard routes from the built-in storage example: each instance only ever returns its own tenant's results, and deleting an instance removes its search index while leaving the R2 objects untouched.
To try it, run npx wrangler dev and use the same onboard, search, and offboard requests as built-in storage. Skip the upload step, since AI Search indexes each tenant's objects directly from R2.
Use a single AI Search instance and organize content by tenant using folder paths. This approach works with both R2 buckets and built-in storage. Apply metadata filters at query time so each tenant only retrieves their own documents.
This option searches an existing instance, so create one named shared-instance and add your content first. Refer to Get started.
Add the instance binding to your Wrangler configuration file:
{ "$schema": "./node_modules/wrangler/config-schema.json", "ai_search": [ { "binding": "SHARED_INSTANCE", "instance_name": "shared-instance", "remote": true } ]}[[ai_search]]binding = "SHARED_INSTANCE"instance_name = "shared-instance"remote = trueOrganize your content by tenant using unique folder paths:
Directorycustomer-a
Directorylogs/
- …
Directorycontracts/
- …
Directorycustomer-b
Directorycontracts/
- …
Update src/index.ts to filter by the tenant's folder at query time:
export default { async fetch(request, env) { const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) { return new Response("Missing x-tenant-id header", { status: 400 }); }
// Filter results to only return documents from this tenant's folder. const results = await env.SHARED_INSTANCE.search({ messages: [{ role: "user", content: "When did I sign my agreement?" }], ai_search_options: { retrieval: { filters: { folder: { $gte: `${tenantId}/`, $lt: `${tenantId}0` }, }, }, }, });
return Response.json(results); },};export type Env = { SHARED_INSTANCE: AiSearchInstance;};
export default { async fetch(request, env): Promise<Response> { const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) { return new Response("Missing x-tenant-id header", { status: 400 }); }
// Filter results to only return documents from this tenant's folder. const results = await env.SHARED_INSTANCE.search({ messages: [{ role: "user", content: "When did I sign my agreement?" }], ai_search_options: { retrieval: { filters: { folder: { $gte: `${tenantId}/`, $lt: `${tenantId}0` }, }, }, }, });
return Response.json(results); },} satisfies ExportedHandler<Env>;This example uses a "starts with" filter to match all files under the tenant's folder, including subfolders.
Start a local development server:
npx wrangler devSend a request as each tenant. The Worker scopes the search to that tenant's folder, so the results never overlap:
curl http://localhost:8787/ -H "x-tenant-id: customer-a"curl http://localhost:8787/ -H "x-tenant-id: customer-b"Log in with your Cloudflare account, then deploy your Worker to make it accessible on the Internet:
npx wrangler loginnpx wrangler deploy