OpenArtifacts runs entirely on Cloudflare's developer platform: two Workers and five managed primitives. There is no container fleet, no Kubernetes, no server anyone SSHes into. Local development runs the same stack in miniature, and taking the platform to production was - almost - a configuration change.
"Almost" is what this post is about. Configuration does not remove distributed-systems choices; it just makes them legible. Each primitive we use earned its place by matching a specific job, and each one has a sharp edge we had to respect. Here is the real architecture, edges included.
The shape: two Workers that never share a fate
The control plane (openartifacts-api) handles everything an account does: the REST API, the dashboard, the MCP endpoint, the moderation queue consumer, the nightly metering cron. The serving plane (openartifacts-router) does exactly one thing: turn your-page.openartifactsusercontent.com into bytes.
The router is deliberately dumb. It can read the routing store and the blob store, and nothing else - no database binding, no billing, no sessions. A deploy storm, a migration, or a bug in the control plane cannot take serving down, and a traffic spike on someone's artifact cannot slow a publish. Separate workers, separate failure domains, one shared storage layer.
Workers: one runtime for every verb
Workers speak Web Platform APIs - Request, Response, fetch, Web Crypto, streams. Our API is HTTP-shaped, so the fit is natural: request handlers, scheduled events, and queue consumers share one runtime, one deploy command, and one config file. The dashboard is static files served by the same worker; there is no build step anywhere in the serving path.
The edge we hit for real: local simulation is not production. Our password hashing used PBKDF2 at 600,000 iterations - the OWASP number. Every local test passed, because the local runtime happily runs it. The production runtime caps PBKDF2 at 100,000 iterations, and the very first real registration failed. We store the iteration count per credential row, so raising it later is a data migration we never have to take; but the lesson stands. Test against the deployed platform before a human does.
D1: the system of record, never the hot path
D1 holds the relational truth: accounts, users, memberships, tokens, artifacts, versions, invites, and the billing ledger. These are shapes with real relations - an account has members, an artifact has versions, a ledger row explains a balance - and keeping them in SQL means every operational question this platform has ever gotten has been answerable with one query.
Migrations are files in the repo, applied with the same command locally and remotely. Production's schema is provably the schema we tested, which sounds boring until the first time it saves you.
The rule we keep: D1 never sits on the viewer hot path. A page view touches the routing store and the blob store; the database is for decisions, not delivery.
R2: content-addressed bytes, zero egress
Published files land in R2 under their SHA-256 hash - blobs/a1b2c3... - and a version is a small manifest mapping paths to hashes. Two consequences fall out of content addressing for free: identical files across versions and accounts deduplicate themselves, and blob names are immutable, so cache consistency questions mostly evaporate. A "new version" is a new manifest pointing at mostly old blobs; rollback is a pointer swap.
R2's zero egress fees are not a detail - they are the hosting economics. Serving a viral artifact costs us compute per request, not bandwidth per byte, which is why our free tier can include a million views a month and stay a rounding error.
KV: eventual consistency where staleness has an answer
The router resolves hostnames through Workers KV: one small record per artifact host - which manifest is live, its visibility, its moderation status. KV is eventually consistent, and the right question is never "is that fast?" but "what happens when this value is stale?"
Here, staleness has a defined answer: for up to a minute after an activation, some edge location may serve the previous version of the page. For static publishing that is not a bug; it is a CDN. We would never put a balance or a uniqueness decision in KV - which is exactly why we did not.
Durable Objects: where the money agrees with itself
Every account has one Durable Object holding its credit balance and publish counters. All spend goes through it, serialized. The reason is concrete: agents publish in parallel, and parallel debits against an eventually consistent counter overshoot. Overshoot in a view counter is noise; overshoot in a balance is money.
The split that emerged: analytical events tolerate lag, so they go to the analytics store; the decision that authorizes spending the last half-cent of credit cannot, so it meets a single object that answers one request at a time. The metering post walks the full design, including the arrears bug our independent verifier caught before launch.
Queues: at-least-once, and designed for it
Every publish enqueues a moderation scan on Queues. Delivery is at least once, so the consumer is written to be run twice safely: verdicts are idempotent writes keyed by version, and a re-scan of an already-scanned version changes nothing. Publishing never waits on the scan; the artifact serves while the verdict lands seconds later, and only a "block" verdict changes what viewers see.
Analytics Engine: counts with statistical semantics
Page views are data points in Analytics Engine, one per HTML serve, tagged by host. At high volume the platform samples, so our queries SUM(_sample_interval) instead of counting rows - and because a view count feeds a bill, we treat that query as billing code, tested against known totals. Traffic is billed one month in arrears, after the month's number is complete; an event stream is an input to a ledger, never the ledger.
What the config file actually bought us
The development loop. wrangler dev runs both workers locally against local D1, R2, KV, Durable Objects, and Queues - the same bindings, the same code paths. Our end-to-end suite boots the whole platform on scratch state and walks 38 steps from agent signup through claimed accounts, 402 payments, moderation verdicts, and team invites. Production is a second config file with real resource IDs and secrets; first deploy to live domains took an afternoon.
And the honest ledger of what local testing did not prove: the PBKDF2 cap, certificate propagation on day-old domains, and one corporate network filter that blocked our own site until it learned better. The loop shrinks the gap; it does not close it.
That is the whole system: web-standard compute, relations kept relational, objects kept immutable, one strongly consistent room where money changes hands, explicit queues, sampled analytics with the sampling respected. For a platform whose users are agents - bursty, parallel, and allergic to setup - we have not found a stack that gets a two-person team to production faster, or runs closer to free at both zero scale and real scale.
Addendum, 2026-10-02: a third Worker, and containers
Since 2026-09-24 OpenArtifacts also runs services, and the shape above grew. A third Worker, openartifacts-runner, has no public route: the api and the router reach it only through service bindings. It runs each service in a Cloudflare Container in one of three instance classes (small, medium, large), mounts the version's bundle from R2 read-only, and keeps the service's /data directory in R2 too. The router now forwards a service host's requests to the runner, which holds a request for up to 60 seconds while a sleeping service wakes. The router still has no database binding and no billing. When a container starts, every five minutes while it runs, and when it stops, the runner reports that container's awake time and CPU to the api, which bills them through the same per-account Durable Object. So "no container fleet" now means no fleet we operate: the containers are Cloudflare's.