Production
Harden ZBSearch Edge for production - authentication, external rebuilds, GitHub Actions, and operational guidance. Cloudflare deploy is in beta.
Beta
Cloudflare deploy is in beta. Treat production rollouts as early adopters - monitor closely, pin versions, and keep a rollback path.
Running ZBSearch Edge in production means protecting your API, offloading rebuilds from the Worker, and monitoring index health. This page covers the recommended production setup.
Cloudflare plan requirements
Workers Paid plan required
ZBSearch Edge requires the Workers Paid plan ($5/mo). The Free plan caps CPU at 10ms per invocation, which search workloads exceed - in load tests 97.8% of searches on a 10k-document index failed with error 1102. Paid also unlocks [limits] cpu_ms = 300000 (extended CPU time for in-Worker rebuilds).
The IndexCoordinator Durable Object (WAL batching and rebuild locking) uses a SQLite-backed class, which is available on both plans - but without the Paid plan's CPU budget the API is not usable for search.
Authentication
Always set API keys before exposing the Worker publicly — a full-access write key, and a read-only key you can safely embed in public clients:
wrangler secret put WRITE_API_KEY --config deploy/cloudflare/wrangler.toml
wrangler secret put READ_API_KEY --config deploy/cloudflare/wrangler.tomlClients must send:
Authorization: Bearer YOUR_SECRET_KEYThe read-only key covers searches and index/metadata reads only; it gets 401 on any write route. The legacy single API_KEY secret still works as a full-access key.
Public endpoints (no key required):
GET /healthGET /v1/infoOPTIONS *
Rotate keys by setting a new secret and updating clients. There is no built-in multi-key or scoped token support in v1 - use Cloudflare Access or an API gateway in front of the Worker if you need finer control.
External rebuilds (recommended)
In-Worker rebuilds work for small indexes but share the Worker's CPU and memory limits. For production:
- Set
BUILDER_WEBHOOK_URLon the Worker - Run rebuilds via GitHub Actions, a cron job, or any CI that can call the rebuild CLI
When the webhook is configured, the Worker's cron does not rebuild inline - it only notifies your external runner.
wrangler secret put BUILDER_WEBHOOK_URL --config deploy/cloudflare/wrangler.tomlThe Worker sends:
POST /your-webhook-url
Content-Type: application/json
{"indexId":"products","source":"scheduler"}Your runner then executes:
source deploy/cloudflare/.env
pnpm --filter @zbsearch/edge-index-builder build
node packages/edge-index-builder/dist/cli.js rebuild products
# or
node packages/edge-index-builder/dist/cli.js rebuild --allFor initial or full catalog reloads, use import instead of per-document API writes - see Indexing & rebuilds.
GitHub Actions setup
The monorepo includes a reference workflow at deploy/github-actions/zbsearch-rebuild.yml.
1. Copy the workflow
Copy it to .github/workflows/zbsearch-rebuild.yml in your fork or deployment repo.
2. Add repository secrets
In GitHub -> Settings -> Secrets and variables -> Actions, add:
| Secret | Value |
|---|---|
R2_BUCKET | Your R2 bucket name |
R2_ACCESS_KEY_ID | R2 API token access key |
R2_SECRET_ACCESS_KEY | R2 API token secret |
R2_ENDPOINT | https://<account_id>.r2.cloudflarestorage.com |
3. Trigger options
The reference workflow supports three triggers:
| Trigger | When it runs |
|---|---|
schedule: '*/15 * * * *' | Every 15 minutes |
workflow_dispatch | Manual run from GitHub UI |
repository_dispatch type zbsearch-rebuild | Webhook from Worker |
To connect the Worker webhook to GitHub dispatch, point BUILDER_WEBHOOK_URL at the GitHub API:
https://api.github.com/repos/OWNER/REPO/dispatchesWith headers configured in your webhook proxy, or use a small relay (e.g. Cloudflare Worker, AWS Lambda) that translates the Worker's POST into a GitHub repository_dispatch with a personal access token.
GitHub's dispatch API requires a Authorization: token GITHUB_PAT header and a body { "event_type": "zbsearch-rebuild" }. The Worker's built-in webhook sends a simple JSON payload - a thin relay is often the simplest production pattern.
4. Verify CI rebuild
After a workflow run, check index status:
curl "$WORKER_URL/v1/indexes/products/status" \
-H "authorization: Bearer $API_KEY"Confirm pendingOps is 0 and lastRebuildAt is recent.
Environment reference
Worker secrets (via wrangler secret put)
| Secret | Required | Description |
|---|---|---|
WRITE_API_KEY | Recommended | Full-access Bearer token (reads + writes) |
READ_API_KEY | Recommended | Read-only Bearer token (searches, status) — safe for public clients |
BUILDER_WEBHOOK_URL | Production | URL called when rebuild threshold is hit |
Worker vars (wrangler.toml)
| Var | Default | Description |
|---|---|---|
REBUILD_THRESHOLD_OPS | 500 | Pending ops before cron triggers rebuild/webhook |
SNAPSHOT_CACHE_MAX_ENTRIES | 4 | Max deserialized snapshots kept in the per-isolate LRU cache |
SNAPSHOT_CACHE_MAX_BYTES | 67108864 (~64 MB) | Max total bytes held by the per-isolate snapshot cache |
Worker bindings (wrangler.toml)
| Binding | Type | Description |
|---|---|---|
BUCKET | R2 bucket | Snapshots, WAL, and index metadata |
INDEX_COORDINATOR | Durable Object (IndexCoordinator) | One DO per index: serializes WAL appends, batches ops into segments, holds the rebuild lock. SQLite-backed class (new_sqlite_classes migration). |
SEARCH_SHARD | Durable Object (ShardSearch) | One DO per physical shard: group searches fan out here so every shard search runs (and keeps its snapshot cache warm) in its own isolate. This is what removes the 128MB single-isolate ceiling on total index size. Optional - without the binding, group searches fan out in-process. |
Caching behavior
Search latency is dominated by which cache layer serves the snapshot:
| Layer | Scope | What it stores |
|---|---|---|
| Per-isolate LRU | Single Worker isolate | Fully deserialized ZBSearch database, keyed by version |
| Workers Cache API | Colo | Raw snapshot bytes (caches.default) |
| R2 | Global | Source of truth |
Hot indexes on warm isolates skip both deserialization and network I/O entirely. Cold isolates in a warm colo pay one Cache API read plus deserialization; a new colo pays one R2 read. Expect the isolate cache to absorb the large majority of search traffic on any index queried more than occasionally, and the Cache API to cover most of the rest - R2 reads should be rare outside of fresh colos and just-rebuilt versions. Rebuilds change the version key, so post-rebuild traffic re-warms once per colo rather than serving stale data.
Rebuild CLI env
| Variable | Aliases | Description |
|---|---|---|
R2_BUCKET | S3_BUCKET | Bucket name |
R2_ACCESS_KEY_ID | S3_ACCESS_KEY_ID | Access key |
R2_SECRET_ACCESS_KEY | S3_SECRET_ACCESS_KEY | Secret key |
R2_ENDPOINT | S3_ENDPOINT | S3-compatible endpoint |
R2_REGION | AWS_REGION | Region (default auto) |
Build the CLI before first use (or after upgrading):
pnpm --filter @zbsearch/edge-index-builder buildMonitoring
Health checks
Point uptime monitoring at GET /health. No auth required.
Index health
Poll GET /v1/indexes/:id/status for:
pendingOps- growing unbounded means rebuilds are failing or too infrequentstatus-buildinglonger than your rebuild duration indicates a failed rebuild. With the coordinator binding, the abandoned lock becomes stealable after 15 minutes, so the next rebuild attempt recovers on its own.lastRebuildAt- stale timestamp means writes are not being merged
Cloudflare observability
wrangler.toml enables observability by default:
[observability]
enabled = trueUse the Cloudflare dashboard for Worker invocations, errors, and CPU time. Watch for CPU limit errors during in-Worker rebuilds - migrate to external rebuild if you see them.
Security checklist
-
WRITE_API_KEYandREAD_API_KEYset and enforced on all/v1/*routes; only the read key reaches public clients - R2 API tokens scoped to the minimum bucket permissions
- R2 credentials stored as GitHub secrets, not in the repo
- Custom domain with Cloudflare SSL if exposing publicly
- Consider Cloudflare Access or WAF rules for additional protection
- External rebuild runner in a trusted environment (CI, not public HTTP)
Scaling guidance
Measured locally with the load-testing harness (~1.3 KB/doc snapshots, single-process wrangler dev - re-run against your deployment for authoritative numbers):
| Index size | Rebuild location | Search |
|---|---|---|
| < 10k docs, < 15 MB snapshot | In-Worker cron OK | ~470 req/s sustained (deployed, 0.00% errors); med ~170 ms incl. network RTT |
10k–100k docs, snapshot fits SNAPSHOT_CACHE_MAX_BYTES | External CLI / CI recommended | Warm cache serves med ~40 ms; keep pendingOps near zero - dirty searches re-index everything per request |
| Snapshot > cache budget at any size | External CLI mandatory | Do not operate here - every search re-fetches and re-decodes the full snapshot (measured: ~8.5 s avg, ~100% errors) |
| 100k+ docs / total snapshots > ~100 MB | External CLI; shard the index (--shards N) | With the SEARCH_SHARD binding, each shard search runs in its own Durable Object isolate - total index size is no longer capped by one isolate. Measured: 100k docs / 8 shards, 0.00% errors warm, med ~330 ms |
v1 stores full msgpack snapshots per shard. A group search that fans out in-process must hold every shard snapshot in one isolate (8× 16.5 MB = 132 MB > 128 MB - measured: 15-22 s latencies and OOM 503s at 100k docs). The ShardSearch Durable Object gives each shard a private isolate with a warm cache, which is the supported architecture past ~50k docs total. As a rule of thumb, at ~1.3 KB/document a shard of 25–50k documents keeps each snapshot comfortably under the default 64 MB SNAPSHOT_CACHE_MAX_BYTES, which also bounds the re-indexing cost of dirty searches (WAL overlay) per shard. Very large indexes will hit Worker memory limits during in-Worker rebuild - use the external builder and watch indexSizeBytes in /status.
One more rebuild caveat, learned the hard way in production testing: in-Worker rebuilds replay a shard's whole WAL backlog in a single invocation. Rolling rebuilds at the default 500-op threshold sail through 100k+ docs, but a large one-shot backlog (bulk API ingest faster than rebuilds drain, roughly 10k+ pending ops per shard) does not complete - the status wedges at building and retries. For bulk loads, use the external builder (zbsearch-edge-builder import) instead of the write API.
Cost considerations
You pay Cloudflare for:
- Workers - requests and CPU time (rebuilds in-Worker consume CPU ms)
- R2 - storage and Class A/B operations (WAL appends, snapshot reads)
Search-heavy workloads benefit from the per-isolate snapshot cache and Workers Cache reducing R2 reads and deserialization cost. Write-heavy workloads are batched into WAL segments (roughly 100 ops / 256 KB per R2 object when the coordinator is bound) and trigger periodic full snapshot rewrites.
Disaster recovery
R2 objects are the source of truth:
indexes/*/meta.json- index registry stateindexes/*/*/snapshot.msgpack- searchable datawal/*- unmerged writes (append-only)buffer/*- legacy unmerged writes (read during migration)
To recover: redeploy the Worker pointing at the same bucket. Unmerged WAL entries will be included in the next rebuild. Keep R2 versioning or lifecycle rules if you need point-in-time recovery.
Upgrading
When updating from a new zbsearch release:
git pull
pnpm install
pnpm --filter @zbsearch/edge-core build
wrangler deploy --config deploy/cloudflare/wrangler.tomlRebuild indexes after engine upgrades if the snapshot format changes (check release notes). No automatic migration runs in v1.
Indexing & rebuilds
How ZBSearch Edge appends WAL writes, builds immutable snapshots in R2, and makes documents searchable. Cloudflare deploy is in beta.
Cost calculator
Estimate the monthly Cloudflare bill for a ZBSearch Edge deployment - Workers requests and CPU, R2 storage and operations, and Durable Objects, from measured baselines.