Opendoor

13 min read · Updated July 29, 2026

Now you’re Hosting with Projects

Building Opendoor’s internal static hosting on Cloudflare — one Worker, a bucket, a key-value store — and what 969 projects taught us about the last mile.

By Josh Leslie

The Spider-Man pointing meme: several identical Spider-Men point at each other, labeled Opendoor Hosted Projects, Wealthsimple Magic, and Shopify Quick.

Hosted Projects, or: how I built Opendoor’s internal static hosting on Cloudflare, grew it from an MVP on a pile of feedback, and distilled 969 projects-and-counting into learnings about sandboxes, agents, and the last mile of shipping.

The cost of building a thing collapsed. The cost of giving somebody a link to it didn’t move at all. That gap is where this starts.

Early this year the pattern became impossible to miss: a colleague would generate a genuinely useful dashboard, tool, or prototype in an afternoon, and then hit a wall trying to share it. They would reach for a git repo, a build or deploy pipeline, a hostname, or an infrastructure request. So instead they’d post a screenshot. Or spin it up on a personal Vercel account. Or stand up a Google Site. Or, most often, nothing — the artifact stayed on their laptop.

Tooling has gotten very good at producing self-contained HTML. Nothing in our stack was good at receiving it.

What we built

Drag a folder onto a page. Name it. You get a URL. The project is internal-only by default, behind our normal SSO, and live within seconds of the upload. If you want the outside world to see it, there’s one button that promotes it to a public hostname, and one button that takes it back down. Delete something by accident and it sits in a 30-day graveyard until you restore it.

That’s it. There is no build step, no framework, no configuration file, and no ticket.

Cloudflare, all the way down

The entire platform is one Cloudflare Worker. R2 holds the bytes, Workers KV holds the metadata, and the Worker is a Hono app with its API routes registered through zod-openapi, so the HTTP surface documents itself.

There is no cluster, no container, no autoscaling policy, and nothing to page anyone about at 3am. That was not an aesthetic preference. The first attempt at this threaded uploaded files through our normal Kubernetes and asset-CDN path across three pull requests, and every one of them died on the same problem: hosting a single HTML file took more moving parts than the file had kilobytes. Throwing it away and starting from a Worker was the change that made the thing exist.

One codebase serves two surfaces.

The internal surface is the whole application — dashboard, API, team management, uploads. Interactive routes are gated at Cloudflare’s edge by Access, with our identity provider behind it, and the Worker re-verifies the Access token against Cloudflare’s JWKS on the API surface. Scripted callers use bearer tokens the Worker verifies itself.

The public surface serves published bytes plus the client SDK bundles they load. API routes return 404 there. There is no dashboard, no identity endpoint, and no way to write.

Publishing is a key-value write from the internal surface directly into the public namespace. No service-to-service call, no queue, no deploy. The two surfaces share one bucket and disagree only about which namespace is authoritative.

Hosted Projects request topologyAuthors reach the internal surface through Cloudflare Access, which gates every interactive route; visitors reach the public surface directly, with no gate. Both surfaces are the same Worker. The internal surface writes uploaded bytes to the shared R2 bucket and metadata to the internal KV namespace; the public surface serves those bytes and reads the public KV namespace. Publishing is the one edge that crosses between the namespaces, and only a human can trigger it. The public surface has no write path.one WorkerAuthorAccessedge gateVisitorInternal surfacedashboard, API, uploadsPublic surfaceread-only, no writesR2 bucketshared bytesInternal KVPublic KVhumanpublish

External partners were the one case that needed more than our own SSO. Cloudflare Access can authenticate a one-time code sent to an email address, so a project can carry an allowlist of specific addresses or whole partner domains. Access authenticates them at the edge; the Worker enforces the project’s allowlist on every request.

The domain is a security decision

The internal surface is deliberately not on our production domain. It sits on a separate hostname we already owned.

If you host arbitrary user-uploaded HTML on the domain that also serves your real product, that HTML is same-site with your product’s session cookies. Every sandbox escape becomes a session-theft primitive. Putting the authoring surface elsewhere means unpublished content — nearly all of it — can never be same-site with a production session. For the projects a human has promoted, the sandbox and the policy are what stand between uploaded HTML and a same-site position next to our product cookies. That’s a narrower, reviewed bet, not an eliminated risk.

It cost one DNS record. It removes an entire class of incidents.

The sandbox is the product

Every served page is delivered under a CSP sandbox directive, which by default gives it an opaque origin, with default-src 'none' and each fetch directive enumerated rather than left to fall through. The first version of that policy was, functionally, a wall.

Inline scripts didn’t run — which is most of what a generated single-file dashboard is. Google Fonts didn’t load. localStorage threw a SecurityError. fetch('./data.json') failed CORS against a file sitting in the same directory, because an opaque origin is never same-origin with the URL’s own origin — the request goes out with Origin: null, and CSP 'self' matches nothing either. target="_blank" was inert. CSV export was blocked, because downloads from a sandboxed document need their own flag.

Every early bug report was the sandbox. The obvious move is to delete the content security policy and get on with your life.

I did the other thing: each capability the sandbox breaks became a named, per-project permission, and the response’s policy is computed from the project’s permission set at serve time. Frankly, this was driven by a mindset of “if you’re going to shoot yourself in the foot, you won’t hit my foot too.”

A project that has asked for nothing gets this — abridged, but the shape is real:

Content-Security-Policy:
  sandbox allow-scripts allow-forms;
  default-src 'none';
  script-src https://project.example 'unsafe-inline';
  style-src https://project.example 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com;
  img-src https://project.example data: blob:;
  connect-src https://project.example;
  object-src 'none';
  frame-ancestors 'none'
PermissionWhat it actually unlocks
Same-origin accessTrades the opaque origin for a real one: localStorage, cookies, sibling CSS and JS imports, same-origin fetch
DownloadsThe download attribute on links, blob and CSV exports
Popupstarget="_blank", window.open()
Allowed originsWhere the browser may load images and media, fetch directly, embed a frame, and where a native form may post
Script originsThird-party script src — a CDN charting library, for instance
Call external APIsA server-side routed request through the platform
Query the warehouseLive SQL as the signed-in viewer

A project that asks for nothing gets self-only egress apart from Google Fonts. And most projects ask for nothing — which is the entire argument for doing it this way. An “advanced mode” checkbox would have been less code and would have collapsed the distinction between “this page needs localStorage” and “this page can talk to anywhere on the internet.” Named grants tell you a project’s egress class — with the caveat that Popups and Script origins are both unrestricted outbound channels, which makes those two the grants that still call for reading the code.

Internally, each connector capability is one entry in a registry carrying its policy fragments, its sandbox implications, and the client bundle to inject:

{
  enabled: (fp) => !!fp.usesSnowflake,
  scriptTag: SNOWFLAKE_SCRIPT_TAG,
  cacheKey: 'sf',
  needsPopups: true,
  connectSrc: [SNOWFLAKE_ORIGIN, OKTA_ORIGIN],
  frameSrc: [OKTA_ORIGIN],
}

Injection, cache-key folding, and policy assembly all derive from that entry; the sandbox and CSP levers are computed from the permission set at serve time. The symmetry cuts both ways: it makes new capabilities cheap, which makes it much easier to be honest about whether a given one should exist.

Two levers people constantly conflate

The most common misunderstanding is that “allowed origins” and the platform proxy are the same lever. They aren’t, and the split is worth stating plainly.

Allowed origins widens the browser’s own policy. The browser makes the request directly, carries no credential of ours, and the answer to “may this page load pictures from there” is the project author’s to give.

The platform proxy routes the call through the Worker. It’s CORS-free and credential-capable by design — no credential is injected today — which makes it a completely different question — not “where may this page load pictures from” but “what may our infrastructure call on this page’s behalf.” So the reachable hosts are not a project setting at all. They’re a default-deny allowlist checked into the repository, and adding one is a reviewed code change. Underneath it sits hostname hygiene that rejects non-HTTPS schemes, IP literals in every spelling, and internal names — but the exact-host allowlist is the real control, and the code says so rather than pretending the filter is an SSRF guarantee.

Live data without handing out a credential

The request that kept coming back was for dashboards that weren’t a stale CSV someone re-pasted every Monday.

There were two ways to do it. A shared service account is easy, and means every viewer of every dashboard reads with the same broad grants. One page’s data-access mistake is everyone’s.

Per-user identity is harder, but correct.

Worth noting that this isn’t a lonely opinion. Databricks Apps authorizes against the app user’s own identity, so a signed-in viewer’s credentials are what reach governed data, and Streamlit in Snowflake shares an app through the account’s existing role-based access control. Two vendors, different stacks, neither of them ours, both landing on run-as-the-viewer instead of a service account.

The shipped flow: the page runs a silent PKCE authorization in a hidden iframe against our identity provider, using the SSO session the viewer already has, and exchanges the code for a token the warehouse trusts through an external OAuth integration. The warehouse maps the email claim to that user and pins the session to a single read-only role on a dedicated warehouse. If the page’s JavaScript asks for a more privileged role, the integration refuses. The allowed-roles list is server-side, not a client hint. Browsers that won’t send a third-party session cookie into an iframe fall back to a popup.

Turning on “Query the warehouse” injects the connector and the SDK core into the page and adds exactly the two origins the flow needs to the policy. There is no script tag to remember and no key anywhere in the project’s files. Pages that don’t opt in carry none of it, which keeps both their egress surface and our audit story small.

What the agents can and can’t do

The dashboard turned out to be the second-most-important interface. The one that changed behavior was “tell the agent to deploy it.”

The platform’s API is exposed as MCP tools, so an assistant can create a project, replace files, set file permissions, adjust access control, soft-delete, unpublish, revoke a credential, and copy or move a project between team spaces. That layer isn’t mine — a colleague built it out, and built it inside our central MCP service rather than as a one-off server bolted to this Worker, so the tools show up alongside every other internal tool an assistant can already reach. In practice the useful shape turned out to be a single wizard-style tool that collapses create-or-update plus permissions plus access into one call, because every extra round trip is another chance for a model to drop a field.

One tool is conspicuously missing: publish.

No credential an agent can hold will publish: the MCP layer has no publish tool, and the bearer tokens scripts use get a 403 on publish and on every other irreversible route. An agent driving a human’s own session is the human’s action — which is exactly where the pre-publish checklist is drawn. Publishing is effectively irreversible, after all: once something has been on the open internet you assume it was seen, cached, and archived, and the inputs to an agent include text written by other people. So promotion to the public surface stays a deliberate action on a human surface, with a checklist about secrets, PII, and internal-only data.

That is a consent boundary rather than a permission boundary, and it’s worth being precise about the difference, because the tokens are the case where the boundary really is structural. Personal and team-scoped tokens exist, are shown once, are stored only as a hash, and are revocable. They are also barred from every destructive or administrative operation — publish, unpublish, hard delete, access-control writes, team management, minting or revoking other credentials. Those return a 403. It’s enforced by a denylist, and a test enumerates the live router and fails CI if any mutating route is missing from the classification, so a new one can’t be added without classifying it.

What people actually built

Numbers as of today:

  • 969 projects by 147 distinct authors
  • 100 promoted to the public surface
  • 12,450 files, 1.90 GB total — still inside R2’s free storage tier
  • 10 team spaces, each with its own admins and members
New projects per month, not a running total
ActualProjected, ±1 SD
69
Apr: 90
May: 185
Jun: 223
313
Aug: 362 projected, plus or minus 31
Sep: 424 projected, plus or minus 35
Oct: 486 projected, plus or minus 41
Nov: 549 projected, plus or minus 46
611
MarAprMayJunJulAugSepOctNovDec
Actual points are complete calendar months; the projection is a least-squares fit on them (R² 0.97) with ±1 prediction standard deviation, and assumes the trend stays linear with nothing saturating. August's first eleven days ran nearer 250 a month than the 362 plotted, so treat the dashed line as the optimistic end.

Two numbers say more about what this is than the total does. 64% of projects are a single file, and the median project is 163 KB. And 52% were never updated after the day they were created.

That second one reads like a failure metric and isn’t. Most of these are the answer to a question somebody had that week: a funnel breakdown, a weekly brief, a tracker, an onboarding guide, a prototype UI to argue over in a meeting. They were supposed to be disposable. The platform’s job was to make them cost nothing to create and nothing to abandon — which is also why soft-delete, a 30-day graveyard, and a cron reaper were in the first version. Nobody should have to decide whether a thing is worth keeping at the moment they make it.

What I’d tell you before you build one

The sandbox will be your whole bug queue at first. Budget for turning each break into a named permission rather than for an escape hatch. The escape hatch is one afternoon of work and permanently costs you the ability to reason about any project.

Don’t put it on your main domain. User-uploaded HTML that is same-site with your product’s cookies is a bad trade at any price.

Keep the irreversible door human. Everything else can be an API, a token, an agent tool. Public exposure shouldn’t be.

Treat ephemerality as the feature. Soft-delete plus a graveyard plus a reaper is less code than any retention policy you’d write, and it removes the only real decision a user has to make.

Constrain the capability set on purpose. Every capability is a policy fragment, an injected bundle, and a support surface forever. Make them cheap to add and then don’t. This platform has one maintainer, which is not a complaint — it’s the constraint that keeps the answer to most feature requests honest.

Get the infrastructure into code sooner than I did. The Access applications, their policies, the firewall exception, the key-value namespaces, and the buckets all started life as dashboard configuration. That’s a completely reasonable way to find out whether an idea is real, and a bad way to keep running one once it is — un-diffable, unreviewed, and unrecoverable. Fixing it later is work you can see coming from the first week.

Credit

The idea and the first working version were mine, thought through in January and prototyped across February, before it had a name, a domain, or a second user.

What isn’t mine is everything else. The MCP layer was somebody else’s build, in somebody else’s service. The early reviews and the back-and-forth that pushed the permission model into the shape it’s in came from colleagues who argued with it hard enough to change it. Every capability was a requirement — a long tail of feature requests, bug reports, and “can it do X?” from across the company. Every row in that permissions table exists because a real project needed it and said so.

The last mile was never anyone’s job

Someone put it well in a thread this week, watching another company publish their version of this: everyone eventually stumbles onto the same solution.

That isn’t because static hosting is a hard problem. It’s been solved for thirty years. It’s that the last mile — a URL, behind your own SSO, that you didn’t have to ask anybody for — sat in the gap between the platform teams who own production and the individuals who own their own curiosity. Nobody’s roadmap had it, so everybody’s afternoon project died on it.

It’s a Worker, a bucket, a key-value store, and some plumbing. The interesting part was never the hosting, but rather building it for us.

Frequently asked questions

Author

Josh Leslie

Our team combines AI-powered research with hands-on expertise from licensed real estate professionals to ensure that every article is accurate, clear, and up-to-date at time of publication.