Cloudflare Workers, D1, R2, and the Billing Trick
The 73-line wrangler.jsonc at the root of the OpenSEO repository is the architectural blueprint for the whole project. It also contains the single design decision that makes the hosted-versus-self-host split possible: the metered path is the easiest path.
Key Takeaways
- The OpenSEO backend is a single Cloudflare Worker, a single D1 SQLite database, a single R2 bucket, two KV namespaces, two Cloudflare Workflows, and one Durable Object. The whole infrastructure fits in one configuration file.
- The billing decision is the architectural decision. Hosted DataForSEO calls must route through a single client,
createDataforseoClient, which checks credit balance *before* the call and records the actual provider cost *after* the call. Self-hosted calls skip the credit check entirely. Same code, same UI, different cost line. - The onboarding chat agent — a Durable Object backed by SQLite — is the most interesting piece. It scrapes a new user's site, calls DataForSEO, and synthesizes a strategy with one LLM call. The whole flow costs $0.10–$0.25 per signup.
- The trade-off the project is making: leverage Cloudflare's primitives so deeply that the application code reads like infrastructure glue. The cost is portability. The benefit is that the hosted and self-hosted versions of the product are the same binary, gated by environment variables.
---
There is a file in the root of the every-app/open-seo repository called wrangler.jsonc. It is 73 lines long. I want to read it with you line by line, because I think it is the most interesting document in the project — more interesting than the README, more interesting than any of the spec files, more interesting than the marketing copy. The README tells you what the project is. The spec files tell you what decisions the team has made. The wrangler.jsonc file tells you what the project *is built out of*.
Here is the full file, with my annotations in the margins:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "open-seo",
"main": "src/server.ts",
"compatibility_date": "2025-09-02",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"observability": { "enabled": true },
"workflows": [
{ "name": "site-audit-workflow", "binding": "SITE_AUDIT_WORKFLOW", "class_name": "SiteAuditWorkflow" },
{ "name": "rank-check-workflow", "binding": "RANK_CHECK_WORKFLOW", "class_name": "RankCheckWorkflow" }
],
"durable_objects": {
"bindings": [{ "name": "ONBOARDING_CHAT", "class_name": "OnboardingChatAgent" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["OnboardingChatAgent"] }],
"triggers": { "crons": ["*/15 * * * *"] },
"kv_namespaces": [
{ "binding": "KV", "id": "4abd52f3f2c549ac83cc2cb4ceec8620" },
{ "binding": "OAUTH_KV", "id": "bd1759494309474a9b423b029967b0db" }
],
"d1_databases": [{
"binding": "DB", "database_name": "open-seo",
"database_id": "37bee90a-e1aa-404f-b01e-b0d1d479bda1", "migrations_dir": "drizzle"
}],
"r2_buckets": [{ "bucket_name": "open-seo", "binding": "R2" }]
}
That is the entire production deployment, in one file. No Kubernetes. No Terraform. No Pulumi. No Dockerfile-as-infrastructure. The 73 lines are the infra.
What is in the box
Let me name what each binding does, because the names are descriptive and the Cloudflare primitives are doing real work.
- The Worker itself (
main: src/server.ts) is the single entrypoint. Every HTTP request — UI, API, MCP, webhooks — lands here. The project uses TanStack Start, so the Worker serves the React app, the server functions, and the MCP transport from one process. - D1 (
DB) is the relational store. SQLite at the edge, replicated to the user's region. The project uses Drizzle as the ORM and thedrizzle/directory holds the migrations. Auth, projects, keyword research history, rank tracking configs, backlink snapshots, all live in this one database. - R2 (
R2) is the object store. The README and the spec for the onboarding agent both reference R2 as the destination for project context artifacts, scraped markdown, and any large blobs the SQLite store would rather not hold. - KV (
KV) and OAUTH_KV are the two key-value namespaces. KV is for general caching and feature-flag-ish state. OAUTH_KV is the Cloudflare Workers OAuth provider's namespace, holding MCP client registrations, grants, and tokens. The split is intentional: OAuth tokens have a different access pattern (long-lived, sensitive) than the rest of the cache, and they get a separate namespace so they can be inspected, rotated, and audited independently. - The two Workflows are Cloudflare's durable execution primitives.
SITE_AUDIT_WORKFLOWruns a multi-step site audit — crawl, parse, score, persist — and survives Worker restarts.RANK_CHECK_WORKFLOWis the same idea for rank checks: kick off a workflow, it queries the SERP API for every keyword in a tracker config, persists the results, and reports back when it is done. Workflows are how the project gets reliable long-running jobs without standing up a queue. - The Durable Object (
ONBOARDING_CHAT, classOnboardingChatAgent) is the only stateful object in the system that is not a database row. It is the chat agent. It is backed by its own SQLite instance, declared in themigrationsblock as anew_sqlite_classesmigration. One DO instance per project. The agent's conversation history persists in the DO's SQLite. When the chat is quiet, the DO can be evicted, and the next message rehydrates it from disk. - The cron trigger (
*/15 * * * *) is the project's heartbeat. Every fifteen minutes, the Worker wakes up and runs scheduled jobs: rank checks, backlink refreshes, AI visibility probes, site audit re-runs. The trigger is a single line, and it is the entire scheduler.
The toll-booth trick
The architectural decision I want to underline is the one in specs/0002-hosted-dataforseo-metering-with-autumn.md. It is a four-page ADR. The short version is this.
OpenSEO's features need DataForSEO. DataForSEO is a paid service. In hosted mode, OpenSEO pays DataForSEO on behalf of its users and needs to bill them back. The naive design is to build a billing service that wraps every feature. The wrong design is to let feature code call DataForSEO directly. The right design, the one the team chose, is to put the toll booth at the data-fetch boundary.
The toll booth is createDataforseoClient. Every feature that needs DataForSEO creates the client and asks it for data. The client does four things, in order:
1. Resolve the Autumn customer from the caller's organization ID. 2. Preflight credit check. Open a small minimum balance, refuse the call if it cannot be opened. This is the guardrail. It does not predict the cost exactly — DataForSEO does not return the cost until the response — but it guarantees the user is not running the call on a zero-credit account. 3. Run the DataForSEO call through a low-level fetch*Raw helper that returns the parsed data *plus* the provider billing metadata. The raw helper is the transport. The client is the toll booth. 4. Track actual usage in Autumn after the call succeeds, using the *real* cost DataForSEO reported in the response envelope, not an estimate.
In self-hosted mode, the client is a no-op. Same code, same import, but the preflight and tracking branches are skipped. The DataForSEO call goes out the door and the user pays DataForSEO directly.
The genius of the design is the inversion. Instead of asking feature code to "remember to bill the user," the project asks feature code to "ask the client for data, like you would any other API." Billing happens at the boundary, not at the call site. The metered path is the easiest path. The direct DataForSEO import in feature code is, in the spec's words, "treated as a billing bypass."
This is a quiet rebuke of every metering design I have ever seen. Most metered systems ask feature code to do bookkeeping. The bookkeeping is always incomplete, always has gaps, always requires a quarterly audit. OpenSEO removes the audit by making the bookkeeping impossible to skip. If you want DataForSEO data, you go through the client. If you do not want to bill someone, you do not call the client.
The project-scoping decision
A second architectural decision, in specs/0001-project-scoping-for-server-functions.md, deserves a paragraph. The team uses TanStack Start, which means server functions are called from React components over the network. TanStack Start's server functions do not know which route called them. The naive design was to keep the active project in a session and read it from middleware. The team rejected that, because it created drift between the URL, the current request, and the user's other open tabs. The right design, the one they shipped, is for project-scoped server functions to *take* projectId in their input, explicitly, and for global middleware to resolve the project for the current organization when the input contains it.
The result is that the project is not a session variable. It is a request variable. Multi-tab works because each tab passes its own projectId. Authorization is tied to the current request, not to a sticky session. Tests are easier to write because there is no session to mock.
This is a small decision with large consequences. I have seen production outages caused by session-scoped state. The team preempted that whole class of bugs with one ADR.
The onboarding agent as a Durable Object
The single most interesting piece of infrastructure is the onboarding agent, and the single most interesting infrastructure choice is that it lives in a Durable Object, not in a row of the main D1 database. The DO has its own SQLite. The DO is addressable by project ID. The DO is what makes the agent feel "live."
The spec (specs/0005-onboarding-agent.md) describes a four-stage flow: profile, discover, read, signal, then synthesize. The first three stages are mostly deterministic — scrape the site's homepage with Cloudflare Browser Rendering, fetch keyword ideas from DataForSEO Labs, fetch the domain rank overview. The final stage is a single LLM call over the gathered markdown and keyword data, which produces a positioning statement, three to five themes, a starter keyword table, and a "do this next" list. The total DataForSEO cost is $0.04–$0.08. The LLM cost is $0.05–$0.15. The whole flow is $0.10–$0.25 per signup.
The agent streams its progress in real time, with messages like "Reading your homepage… you look like a Notion alternative… 4 keywords ranking, 30 worth targeting…" The user sees the agent think. The thinking is not an LLM loop — it is a narrated pipeline. The LLM is called once, at the end, to write the strategy. Everything before is a deterministic fetch.
The DO design is what makes this possible. The agent is conversational, so it needs a place to keep a conversation. The conversation is per-project, so it is keyed by project ID. A DO is exactly the right primitive for that: one instance per project, message history persisted in the DO's own SQLite, conversation state survives Worker restarts and DO evictions. The team is using the platform the way it is meant to be used.
The portability trade-off
There is a cost to the architecture. OpenSEO is not a Node.js app you can run on AWS. It is a Cloudflare Worker. To self-host, you need a Cloudflare account, even if you use the Docker image (which is a Cloudflare Worker running in a local container via workerd). The migration path off Cloudflare is not free. The R2 bucket becomes an S3 bucket. The D1 database becomes a SQLite file or a Postgres database. The Durable Object becomes a long-lived process or a row in a queue. The Workflows become a Temporal cluster or a set of cron jobs.
The team has made a deliberate bet that this is fine. The bet is that the productivity gain from Cloudflare's primitives — a D1 database that is "just there," a Durable Object that is "just there," a Workflow that is "just there" — is worth more than the optionality of running on a different platform. I think the bet is correct, for the same reason I think the bar-tab decision in Chapter 1 is correct. The closed platform is comfortable. The closed platform is not what an AI-driven workflow needs.
The bet is also reversible. Every Cloudflare primitive in the project has a clear migration path. The wrangler.jsonc is a 73-line file, and every line in it names a primitive that has a mainstream equivalent elsewhere. The bet is that the team can move if they need to. The bet is that they will not need to.
The next chapter is the one I have been building toward. The MCP server is the part of the product that the dashboard cannot replace. It is also the part that turns the architectural decisions above into a workflow an AI agent can actually drive.
---
References:
- OpenSEO
wrangler.jsonc - Spec 0001 — Project scoping for server functions
- Spec 0002 — Hosted DataForSEO metering with Autumn
- Spec 0005 — Onboarding agent
- Cloudflare Durable Objects documentation
- Cloudflare Workflows documentation
---
The toll booth is not at the feature. It is at the data. And the data is the only thing the agent will not lie about.