A deep dive into OpenSEO
By Flavio
Updated
How OpenSEO works: DataForSEO data, Cloudflare Workers, its audit crawler, MCP tools, the SAM agent, storage, and how to self-host it.
OpenSEO brings keyword research, competitor analysis, backlinks, site audits, and search visibility into one application. You can use its dashboard, connect an AI agent through MCP, or run the open-source application yourself.
What I find useful is that these workflows share one project. Research lives beside the site it belongs to, and an agent can retrieve data, remember the business context, and turn the findings into a report you read later.
The open-source repository is also a substantial application to learn from: a React interface with server functions, a relational database, object storage, durable background workflows, and stateful agents. This post goes through how those pieces fit together and what you take on when you self-host it. It’s based on the source and the docs: I haven’t deployed it or run an SEO campaign with it.
Open source application, external data
The application is open source, but its market data comes largely from DataForSEO, a separate commercial service. When you self-host, you bring DataForSEO credentials and pay that provider for each request. Cloning the repository doesn’t give you a keyword database or a backlink index. OpenSEO adds the workflow around the data: project defaults, validation, saved research, tracking history, reports, agent tools, and the dashboard.
Other data follows other paths. Search Console uses Google’s API for a property you authorize, the built-in agent uses OpenRouter, and a rendered audit can use Cloudflare Browser Run, with Context as an optional fallback. A normal HTML crawl fetches pages from the site being audited, with no LLM involved.
| Capability | Main source or execution path |
|---|---|
| Keyword and competitor research | DataForSEO APIs |
| Backlink research | DataForSEO’s backlink data |
| Rank observations | DataForSEO search-result collection |
| Search Console performance | Google’s API for an authorized property |
| Technical HTML checks | OpenSEO’s crawler and analyzers |
| JavaScript-rendered pages | Browser Run or Context, depending on deployment |
| Lighthouse checks | DataForSEO integration |
| In-app agent reasoning | OpenRouter model access |
| AI visibility collection | DataForSEO collection methods that differ by engine |
The hosted application adds its own subscription and usage accounting. The pricing page lists a $10 monthly base plan with $10 of included usage, plus additional usage purchases. Self-hosting removes that subscription, while provider charges and your infrastructure costs remain.
The architecture from the outside
OpenSEO has several entry points into the same product. A person opens the dashboard, an external agent connects to /mcp, the built-in SAM agent uses its own chat connection, and scheduled work enters through Cloudflare’s scheduled handler. They all reach the same server-side features, with different authentication and different tools.
The main application uses TanStack Start, React, and TanStack Router, with TanStack Query for client-side server state, Tailwind for styling, and TypeScript throughout. Server functions validate input and call a feature service, so provider credentials never reach the browser.
src/server.ts dispatches application requests, MCP traffic, agent connections, and scheduled work. Below it, src/server/features/ has separate areas for keywords, domains, backlinks, audits, projects, reports, integrations, and agents. Services implement product operations, repositories handle stored data, and shared provider clients talk to DataForSEO, which is how a keyword page and an MCP tool end up calling the same code. The site audit engine runs in a separate auxiliary Worker, which we’ll get to.
If you want to follow along in the repository, these are good starting points:
| Area | Source entry point | What to follow |
|---|---|---|
| Request dispatch | src/server.ts |
App, MCP, chat, and scheduled entry points |
| Research operations | src/server/features |
Product services and repositories |
| External SEO data | src/server/lib/dataforseo |
Provider methods, envelopes, pricing, and retries |
| Durable execution | src/server/workflows |
Audit, ranking, and visibility orchestration |
| Audit state | AuditScratchpad.ts |
Frontier ownership and temporary SQLite state |
| MCP | src/server/mcp |
Authentication, schemas, transport, and tool wrappers |
| In-app agent | src/server/features/sam |
Chat sessions, memory, tools, and model execution |
| SQL backend | src/db |
Schemas, provider selection, transactions, migrations |
| Infrastructure | deploy/alchemy |
Resource provisioning and deployment modes |
Why Cloudflare appears throughout the code
The application is built around Cloudflare bindings. A binding gives a Worker access to a configured resource, such as a database or bucket, through its environment instead of a network address and password.
OpenSEO uses D1 for the default relational database, R2 for object storage, KV for selected lightweight state, Cloudflare Workflows for durable background jobs, and SQLite-backed Durable Objects for specific stateful jobs. A generic Node server plus a Postgres URL can’t run it.
Docker runs the Cloudflare development and preview runtime locally, with local implementations of those bindings. If those concepts are new, my free Cloudflare Workers course explains the execution model and bindings.
Follow a keyword request
Say we want to know which searches relate to a waiting-list tool. The project supplies a domain, location, and language, and you pick a seed or another research operation with result limits and filters.
The server checks the user’s access to the project first, then the feature service turns the request into provider operations. Related keywords, domain keywords, search volume, and live search results are separate requests, so one page can trigger several calls. The provider client adds authentication on the server and reads both the HTTP status and DataForSEO’s own result envelope, because a 200 response can still carry a task-level failure.
The results are mapped into rows the interface understands. Saving a keyword is a separate operation that adds it to the project’s saved list, so closing a tab doesn’t lose it. An agent works the same way: it retrieves results, decides what matters, and explicitly saves the ones it picked.
The keyword service also does more than forward a seed to one endpoint. It normalizes and deduplicates seeds and picks a provider path for the market. Automatic mode combines suggestions and ideas, removes duplicates, and falls back to related keywords when the first result is thin. Countries outside one provider dataset’s coverage use a Google Ads path instead. Local research starts with national ideas and replaces selected metrics with local Google Ads metrics, without overwriting the national records. That’s why the cache key includes market, language, limit, mode, and local settings, not only the keyword.
Domain and backlink features have their own scope rules. Some upstream summary or history endpoints can’t represent a subfolder, so the application labels or omits that data instead of pretending a filter was applied.
Location and language are part of every question. The same search returns different results in different countries, and device and filters change both the data and the cost of collecting it. OpenSEO stores market defaults on the project and records the market next to saved or tracked data. Keep those settings consistent within an investigation. I’d rather track a few searches in a market I can serve than collect a huge international list with no clear use.
Caching and freshness
A client cache keeps the dashboard from reloading, a provider-result cache avoids repeating paid work, and saved project data is the durable record.
r2-cache.ts stores provider responses in R2 under deterministic keys built from the operation’s parameters, with an expiration timestamp in object metadata. An expired object counts as a miss on read. That’s a soft TTL: the app stops reusing the object, but it can still sit in storage. Research paths can also validate cached data against a schema, which catches an old response with the wrong shape after a deployment.
Each operation has its own caching policy. A cached keyword response is fine for planning, while a scheduled rank observation needs a fresh measurement, so check when a number was collected before you act on it.
Where the data lives
OpenSEO uses several storage systems because its data has different shapes and lifetimes.
| Storage | Responsibility |
|---|---|
| D1, or the optional Postgres backend | Users and organizations, projects, saved research, tracking configuration and results, integrations, report documents |
| R2 | Cached provider payloads, larger audit artifacts, generated exports |
| KV | Selected progress and transient state; a separate namespace holds hosted MCP OAuth data |
| SAM Durable Object SQLite | Persistent chat messages and agent state for a session |
| Audit scratchpad Durable Object SQLite | Temporary crawl frontier, seen URLs, link edges, and slim page state |
The split follows access patterns. A project listing needs indexed rows and relationships, a cached response is fetched as one object by key, and a crawl frontier needs tightly coordinated updates while a job runs. Notice that reports live in the relational database, not in R2.
The central relationship is organization → project → feature data. Saved keywords, audit runs, and tracking history belong to a project, and crawled pages belong to an audit. Repository methods scope child lookups by project as well as by ID, so someone authorized for one project can’t fetch another project’s report by guessing its ID. Archiving a project hides it and keeps its history.
D1 and Postgres
D1 is the default database, and there’s also a Postgres path through Cloudflare Hyperdrive. Drizzle is the query layer, with separate schemas and migrations for SQLite and Postgres, and schema-parity tests keep them aligned.
The two behave differently. runBatch uses D1’s batch operation on one backend and a transaction on the other, and with Postgres every statement has to be built from the transaction handle, or it silently runs outside the transaction. Postgres clients are scoped to one invocation through request-local context, so a Worker never reuses a socket from an unrelated earlier request. Hyperdrive is required, and a database URL alone won’t do. My Cloudflare Hyperdrive tutorial covers that binding and the pooling behind it.
Switching to Postgres changes the database and nothing else. Workflows, agent storage, object storage, and the audit engine stay as they are.
Domain and backlink research
You can authorize Search Console for your own site, but not for a competitor’s, so market-research providers estimate competitor visibility from the data they collect. OpenSEO shows ranking keywords and pages, filters, and referring domains, individual backlinks, history, and quality indicators, all with server-side pagination so a giant dataset never lands in the browser.
A large backlink count tells me little on its own. I would look at the actual source pages, whether the links are relevant, and whether the opportunity makes sense for my audience. If you compare providers, use the same domain, market, and date, since their coverage and collection methods differ.
Rank tracking is a durable job
A tracking configuration says what to check and in which market. A scheduled handler finds due work, and a workflow submits and collects the observations. Cloudflare’s cron wakes the dispatcher periodically, and each tick picks up only the configurations that are due.
DataForSEO has its own asynchronous task system: OpenSEO submits a task, keeps the task ID, and retrieves the result later. So Cloudflare Workflows keeps the application’s progress through the job, and DataForSEO’s task queue does the collection. Cloudflare Queues isn’t involved, even though you’ll see variables named queued. Because of the workflow, waiting on a provider task never holds a browser request open.
For jobs dispatched twice or provider tasks that complete after the app lost its connection, OpenSEO has run guards, stored task state, and reconciliation paths, so the next run doesn’t pay for a replacement task it doesn’t need.
The hosted service currently pauses scheduled rank tracking with a hosted-only flag. Manual checks still work, and self-hosted schedules bypass the pause, so check the hosted status before counting on it for unattended tracking.
Claiming a schedule
The scheduler reads due configurations in order and caps how much work it admits per tick, counting keyword and device combinations, since desktop and mobile are separate tasks. A compare-and-swap update claims each slot: the write succeeds only if the stored state still matches what the dispatcher read, so two concurrent ticks, or a tick and a configuration edit, can’t both claim it. A failed workflow launch doesn’t send the configuration into an unlimited retry loop.
The workflow writes observations incrementally, so a failed later batch leaves earlier results saved. Scheduled checks use queued provider tasks and manual checks use live collection, with fallbacks for missing or failed tasks. Be aware that a queued task followed by a live fallback can be billed twice. When a fallback fails, the code keeps the previous observation instead of recording the keyword as not found.
The site audit engine
An audit starts with a URL and a bounded scope, discovers pages, fetches and analyzes them, and builds results across the crawl. In a durable application, the hard part of that loop is deciding which state survives between iterations. I built a smaller version in a tiny site-audit crawler with Workers and Queues.
OpenSEO runs the audit engine in an auxiliary Worker. The main application starts its SiteAuditWorkflow through a cross-script binding and later reads status or sends control operations. The split is about memory: in-flight HTML and large Lighthouse payloads could interfere with the main application, so they stay in another Worker’s heap, while storage is shared.
The frontier and the scratchpad
The crawler keeps a frontier, the URLs it knows about but hasn’t finished. Discovery uses the starting page, sitemap information, and links found while crawling. URL policy and normalization keep fragments, tracking parameters, alternative spellings, and external links from making the crawl much larger than expected.
Each audit has a scratchpad in a SQLite-backed Durable Object with the frontier, seen-set, link edges, and a slim mirror of page data. One object owns the audit’s state, so Workers can advance the crawl without passing the whole graph through every workflow step. The scratchpad is destroyed when the audit finalizes, and a cleanup alarm removes abandoned ones, so permanent results have to be written elsewhere first.
Checkpoints and backpressure
The workflow processes bounded chunks of leased URLs. Each chunk has a stable identity, so a retry recovers the same lease instead of claiming different pages, and scratchpad writes are idempotent. Checkpoints return small counters and status, never the frontier or downloaded documents.
When page persistence falls behind fetching, the crawler stops launching fetches instead of piling HTML up in memory. Its adaptive fetch window is conservative. A 429 pauses new fetches using the site’s retry guidance or a fallback delay, and that throttle state carries across chunks.
The link graph has a storage ceiling, and rendered-page analysis truncates very large HTML. So I would read an orphan-page report as a description of the graph the crawl saw. After a page cap, blocked responses, or truncation, a page listed as orphaned may still have inbound links.
Fetching and checking pages
A fetch can return a redirect, a CDN challenge, a rate-limit error page, or an image. OpenSEO classifies the response before analyzing it, so a blocked request doesn’t show up as a page with no heading and no text. On a site you control, start with a small page budget and look at those classifications. A report full of access failures needs a crawler-access fix before any content rewrite.
Some checks need one response: title, description, headings, canonical URL, indexability directives, structured data, and social metadata. Duplicate titles, link relationships, and canonical conflicts need the whole crawl, which is what the link graph is for. Severity still needs your judgment, since a missing description on a utility page and an accidental noindex on a product page are very different problems.
Rendering and Lighthouse
A client-rendered site may only show its content after JavaScript runs, so OpenSEO has a rendered audit path. On Cloudflare, Browser Run renders first, with Context as a fallback. Docker doesn’t have the browser binding, so its documented rendering path uses Context credentials. The self-hosting operations guide requires a paid Workers plan for the Browser Run path, even if the rest of the app fits in free allowances.
Lighthouse runs through DataForSEO’s Lighthouse service, and its score is a lab result under that test’s conditions. For field performance I would keep real-user measurements next to it.
Recovering a stuck audit
Runtime limits, provider failures, and deployments can leave a run without its final write. The main application’s scheduled handler runs a stale-audit reconciler that finds audits that stopped making progress and updates their status, and the scratchpad’s cleanup alarm removes their temporary state.
MCP is another application interface
OpenSEO exposes tools through MCP, so an external agent can work with the same projects and data. I walked through that keyword workflow in Research keywords through OpenSEO MCP.
A useful request might be:
Use the project for my waiting-list tool. Read its context first.
Find a small set of relevant search opportunities in the project's market.
Reuse existing research where possible. Tell me which fresh requests would
cost money before expanding the investigation. Save the selected keywords
and a report containing the evidence and the next actions.
The agent turns that into tool calls, each with an input schema and server-side behavior. If a tool returns the wrong data, check its parameters. If the data is right but the recommendation is poor, look at the agent’s instructions.
Identity on every tool call
Hosted MCP connections can use the application’s OAuth system, and the server also accepts API keys. The credential identifies a user, and every call checks that user’s current access to the requested project. Users can belong to several organizations, so the server takes the organization from the authorized project, which keeps both data and billing in the right place. The guard covers child records too: a tool can’t accept a report ID or tracking configuration ID without checking it belongs to that project. An API key accepted for MCP also can’t act as a browser session for account administration.
On a Cloudflare Access deployment, Access protects the application and the server validates its assertions. MCP clients need Managed OAuth configured with their callback addresses, since a working browser login doesn’t help them connect.
The local Docker mode has no application authentication, and that includes its MCP endpoint.
My guide to MCP explains the protocol. OpenSEO is a good larger example because its tools reach paid operations, persistent data, and several organizations.
The MCP transport is stateless between requests, and durable state lives in projects and repositories. The external agent keeps its own conversation. As the diagram above shows, a research tool never calls a report-writing tool on its own: the agent reads the result, writes a document, and makes a separate save request.
Spending boundaries
An agent can generate a lot of plausible research calls, and each one can cost money. A prompt like “research every competitor and every keyword” has no budget. I would start with one project, one market, and a few bounded questions, and ask the agent to reuse saved data before fetching more. Server-side metering still matters, because it runs however the model phrases its request.
Project context
A project can hold business context: what the product does, who it serves, competitors, important pages, and bounded research-log summaries. Without it, every new agent session starts by guessing the same things, and with it an agent can avoid buying the same research twice. Updates are validated and related writes grouped atomically, so the next agent never sees a half-applied edit.
It’s plain application state, so when the product changes you update it yourself. I would keep it short enough to review. For Waiting Lists, I would record the self-hosted workflow, double opt-in behavior, and intended users, and leave out integrations or a managed service it doesn’t have, so the agent’s topic ideas stay within what the product does.
SAM, the built-in agent
SAM is OpenSEO’s in-app assistant. It shares feature services with the rest of the application but runs on a different surface from an external MCP client.
Each chat session has a SQLite-backed Durable Object, tied to the user and project by a relational session record. The Worker authorizes both the live connection and message-history requests before routing them to the object, so knowing a session ID isn’t enough to read it. Model access goes through OpenRouter, so self-hosting needs that key for SAM to work. SAM’s server-side tool wrappers supply the authorized project, so the model can’t switch projects by changing a tool argument.
OpenSEO bundles public skills into SAM, adapted to an environment with no filesystem or shell. SAM can explain a problem, but changing a website’s code is a job for an external coding agent. SAM also has no report tools, so its adapted skills return findings in chat while external MCP clients can save reports.
SAM compacts and clips context as it grows, because the Durable Object’s memory can run out before the model’s context window does. Messages persist, but an interrupted inference isn’t replayed automatically, since that could repeat paid tool calls. You ask it to continue.
Reports are sandboxed HTML
External agent workflows can save a self-contained HTML report into a project, which is easier to read than a transcript and outlives the session. The report service validates structure and byte limits and enforces storage guardrails. An update replaces the content, with no versioning, and listings fetch metadata separately from the HTML.
HTML built from crawled pages and model output is untrusted, so OpenSEO serves it in a restrictive sandbox with a Content Security Policy on the report response itself. Scripts, network connections, and form submission are blocked, and the report never gets the application’s origin, even when opened outside the embedded viewer. Printing uses one narrowly allowed script supplied by the application.
Hosted reports can have public share links, which the code disables on self-hosted installations. Treat a public link as a disclosure decision, since a nicely formatted report can contain commercially sensitive research.
Search Console and Analytics
Search Console gives OpenSEO clicks, impressions, positions, and the query and page dimensions Google’s API supports for a property you authorize. This is first-party evidence, still shaped by Google’s aggregation, quotas, and omitted data.
Self-hosting the Google integration needs a Google OAuth client, an exact callback URL, and a secret that encrypts stored tokens. Back up that secret with the database, because restored tokens are useless without it.
Signing in with Google and granting access to Search Console or GA4 are separate grants. A data grant belongs to the user and Google account that connected it, and a project mapping names the selected property and the grant used to read it. Other project members read through that connection, so if the connector’s grant is removed or their Google permissions change, reads break for everyone.
The repository also has GA4 integration code. GA4 measures activity on your site, while Search Console describes how it appears in Google Search.
AI visibility
AI visibility asks whether a brand appears in answers to selected questions and whether those answers cite its site. It’s useful when recorded with its conditions, and misleading as one universal number for how often “AI recommends you”.
A project defines prompts and markets, collects answers from supported engines, and keeps mentions and citations over time. ChatGPT, Gemini, and Google AI Overviews go through DataForSEO’s scraper or search-result collection. Claude and Perplexity use its model-response APIs with web search, so those answers come from an API rather than a signed-in app session.
Any collected answer can differ from what someone else sees with a different location, account history, time, or wording.
A mention means the brand name appears in the answer, matched by text. A citation means the answer links to an owned domain, matched by domain. They can disagree, a generic brand word can be ambiguous, and a competitor’s page about your product doesn’t count as your citation. I would read the underlying answers before acting on a trend.
Runs also need comparable inputs. If one run checks ten prompts and the next checks fifty, the aggregate moves because the sample changed. OpenSEO compares matching prompt, engine, market, and brand cells and applies answer-coverage thresholds, so failed collection doesn’t count as brand absence. The prompt list is still yours, so the result describes your questions. I would begin with a few customer questions that matter to the product, keep the conditions stable, and review the cited pages.
Paid requests and failure handling
Retrying a failed read from your own database is cheap. Retrying an external research task can create billable work twice, even when the first response never arrived.
OpenSEO’s provider client separates HTTP failures from task-envelope failures, bounds request time, and doesn’t blindly replay timeouts. Hosted requests add credit accounting through Autumn, which estimates or reserves usage and later reconciles provider-reported costs. Self-hosted requests skip that layer, and DataForSEO bills your account directly.
No transaction spans the database, the provider’s task system, a workflow, and credit accounting, so recovery relies on identifiers, recorded state, and reconciliation. “The request failed” can mean the provider rejected it, or the work completed and the response was lost, and those have different retry and billing consequences.
On a self-hosted installation I would set provider spending controls and start with small limits, because schedules and agents both multiply requests quickly.
Run it locally with Docker
Docker is the documented way to try the self-hosted application on one machine. On macOS, use Docker Desktop or another Docker environment with Compose support.
Clone the repository and copy its environment template:
git clone https://github.com/every-app/open-seo.git
cd open-seo
cp .env.example .env
In .env, set DATAFORSEO_API_KEY to the Base64 credentials from DataForSEO’s API access page. Base64 only encodes your API login and password, so treat the value as a secret.
You can also opt out of the anonymous self-hosting telemetry:
OPENSEO_TELEMETRY_DISABLED=1
Start the published image:
docker compose up -d
docker compose logs -f open-seo
The Compose file defaults to ghcr.io/every-app/open-seo:latest and maps the app to 127.0.0.1:3001. Open localhost:3001. The first start takes a while, because it validates configuration, applies migrations, and builds the application. Later restarts reuse an unchanged build.
Check its health after startup:
curl --fail http://localhost:3001/api/health
That checks configuration and the database. Provider calls, Google connections, and model integrations need their own test.
The Compose configuration forces AUTH_MODE=local_noauth, which injects a local administrator identity with no login. That’s why the port is bound to loopback. Don’t expose it publicly, and if you add a reverse proxy or tunnel, put real authentication in front of it. ALLOWED_HOST only gets a hostname past Vite’s host checks. For shared internet-facing use, deploy to Cloudflare instead.
The named open_seo_data volume mounts /app/.wrangler, which holds all the local state. Removing a container keeps the volume, but a Compose command that removes volumes deletes your projects for good.
The container also runs a local scheduler that calls the scheduled handler, because the preview runtime doesn’t emit cron events. Schedules only run while the container does, so a laptop that sleeps overnight won’t track anything.
For an update, back up first, then pull and recreate the service:
docker compose pull
docker compose up -d
Pin an image tag you’ve checked when you need a repeatable deployment, and keep in mind an older image may not read a database after a newer migration. The repository’s Docker guide covers optional variables and operational commands. My free Docker course covers volumes, networking, and Compose.
Deploy it to Cloudflare
The supported Cloudflare path provisions everything with Alchemy, the infrastructure code in the repository. It creates the storage resources and Workers, applies migrations, and sets up a Cloudflare Access application.
You need a Cloudflare account with R2 enabled (which can require a payment method even within free allowances), a recent Node, pnpm, and DataForSEO credentials.
From your clone, install dependencies:
corepack enable
pnpm install
Then log in and bootstrap:
pnpm alchemy login
pnpm alchemy cloudflare bootstrap
The login needs the Access permissions described in the self-hosting guide. Bootstrap creates the infrastructure tool’s state store in your account.
Copy the dedicated template:
cp deploy/.env.selfhost.example .env.selfhost
Set DATAFORSEO_API_KEY and ACCESS_ALLOWED_EMAILS there. The email list decides who gets through the Access gate. Keep this file private.
Deploy with:
pnpm deploy:selfhost --yes
You can pass an existing Access configuration through TEAM_DOMAIN and POLICY_AUD instead. Decide whether the infrastructure code or your own Access administration owns that policy, or a later deployment can overwrite changes you made in the dashboard.
Open the printed Worker URL, authenticate through Access, and check the dashboard and one small provider request. Everyone allowed through Access shares one workspace and sees all its projects. That suits a small trusted team, and it’s a poor fit for an agency that needs isolated client portals.
For external MCP clients, configure Access Managed OAuth with the right redirect addresses and connect them to your deployment’s /mcp endpoint, following the operations guide.
Don’t replace this with a generic one-command Worker upload. The app and the audit Worker depend on cross-script bindings, storage, workflow registrations, and Access settings that have to stay consistent.
Add integrations gradually
I would verify the base app before adding optional integrations, so each failure has fewer possible causes. Start with one small research request and confirm the market and the provider activity. Then connect Search Console, with the OAuth callback set exactly and the encryption secret saved, and add OpenRouter if you want SAM. Try a bounded HTML audit before browser rendering, and enable schedules last, for a few observations, checking that they finish after the browser closes.
Backups and recovery
For Docker, back up the runtime volume and your private configuration, with the service stopped so you don’t copy database files mid-write.
For Cloudflare, plan separately for relational data, important R2 objects, relevant KV state, and durable chat state, since a database export covers only the first. The audit scratchpad is disposable. Keep the token-encryption key, because without it stored OAuth tokens are unusable, and keep a deployment revision with each backup, because the SQLite and Postgres migration histories are separate.
I would test a restore in a separate environment with schedules disabled, or it could start paying for duplicate observations. And be careful with the infrastructure destroy command, which can delete the stage’s stored data along with its Workers.
What will limit it as usage grows?
A bigger SQL database fixes only some of the bottlenecks. Every fresh research operation has provider latency and cost, and crawl cost grows with pages, link edges, HTML size, rendering, and Lighthouse runs. Cached objects and saved reports need retention rules. Watch the main Worker, the audit Worker, workflow state, and provider errors separately, because the dashboard can be fast while collection is failing. I would measure a deployment with real limits before promising it could serve an agency’s whole portfolio unattended.
OpenSEO versus the alternatives
| Option | Why I would consider it | Main trade-off |
|---|---|---|
| OpenSEO self-hosted | Custom workflows, owned application state, inspectable code, agent tools | Operating the app and paying providers directly |
| OpenSEO hosted | The same product direction without maintaining its infrastructure | Subscription, usage accounting, service availability, less deployment control |
| Ahrefs | Established competitor and backlink research workflows backed by its data collection | A different dataset, product model, and subscription to evaluate |
| Semrush | A broad marketing and SEO suite with many integrated workflows | Scope and cost can exceed what a small project needs |
| Screaming Frog SEO Spider | Detailed technical crawling from a desktop tool | It doesn’t replace the whole hosted research and agent workspace |
| Search Console | Direct evidence about your own Google Search appearance | Authorized properties rather than competitor-market research |
| Direct DataForSEO integration | Full control of the data requests and a narrow custom tool | You build storage, interface, access control, reporting, and recovery |
Ahrefs Site Explorer has its own data collection, so compare specific queries before treating it as a replacement. Semrush covers far more marketing workflows, which is worth paying for only if you use them. Screaming Frog runs on macOS, Windows, and Linux and fits when the main task is a detailed technical crawl.
Search Console is where I would begin for an existing site, adding market data when I have a question it can’t answer. Going straight to DataForSEO works for a narrow internal tool, like one recurring report. Once I also need projects, authentication, agents, reports, tracking history, and an interface for other people, OpenSEO already has the code for most of that.
How I would use OpenSEO
I would start with one small project, probably Waiting Lists, and a few explicit questions.
First, what searches already bring people to the site? For that I would use Google Search Console, or Searchglass when I want the same clicks and impressions in the terminal. On my own sites I keep those reports, the crawl, and the follow-up checks in SEO Bot, so I would use OpenSEO for what those don’t give me: competitor and market data kept next to the project. The terminal commands for those checks are in how to run SEO checks from the terminal.
Then I would look at a few comparable products and their search results, looking for questions I can answer from real product knowledge. I wouldn’t ask an agent to build a publishing calendar from search volume alone, since volume suggests demand but can’t tell me whether the product solves someone’s problem.
I would save the useful keywords with their market settings, keep the project context factual, and have an external agent turn the research into a report with links to the evidence and a short list of actions. After reviewing it, I would make one change, like improving a confusing page or fixing an indexing issue, and watch the relevant searches with a small tracking set. AI visibility would be a separate experiment with a stable prompt list, and I would read the answers before trusting any trend.
I would run it with Docker first. If it proved useful across devices or for a trusted team, I would move to the Cloudflare deployment and test a restore before depending on scheduled work.
The limits matter as much. Self-hosting means I own the application, while market data and model features still depend on DataForSEO, OpenRouter, and Google, and every request costs money. The Cloudflare dependency goes deep, so moving to another runtime would mean replacing workflows, durable state, bindings, and storage. I wouldn’t put separate clients in one self-hosted workspace, because everyone behind Access sees everything. For a single technical crawl I might still reach for a desktop crawler, and for a quick check on this blog, Search Console usually has the answer already.
If I were building a focused SEO tool instead, the MIT-licensed code would be a good base to fork, after reading the data providers’ terms.
Want me to talk about your product? You can sponsor this site.
Related posts about seo: