Documents accumulate quietly in a business application. The offer PDF attached to a purchase request, the signed copy of a contract, ten photos from the site; small on their own, tens of gigabytes by the end of the year. Many projects write those files into the database as bytea in the first release: one backup, transactional integrity for free, no extra infrastructure. The bill arrives a year later, when ninety percent of the database is files and every backup, every migration and every query carries that weight.
When designing Supply Management we never went down that road: the document store was built on Cloudflare R2 from day one, and the database holds only information about each document. This post summarises the decisions we made in that design and what we learned.
What R2 is, and why not S3
R2 is Cloudflare’s object storage. It speaks the same API as Amazon S3, so every client library written for S3, including AWSSDK.S3 in .NET, connects to R2 by changing only the endpoint. The difference is in two places:
- No egress fees. On S3, getting data out can cost more than storing it. On R2, download traffic is free; a document downloaded by a thousand users costs the same as one downloaded by a single user.
- Simple pricing. Standard storage is $0.015 per GB-month; write-type operations (Class A) cost $4.50 per million, read-type operations (Class B) $0.36 per million. The free tier covers 10 GB-month of storage, 1 million writes and 10 million reads per month; a small organisation’s document archive often stays inside it.
If the application already runs on Cloudflare (Workers, Pages or just DNS), R2 is enabled in the same account without another contract or another cloud account.
What the database holds, and what R2 holds
The split is clear from the start. The database holds information about the document: owner, version, uploader, SHA-256 digest, size, content type, object key. The bytes of the file live only in R2. The database therefore stays small and fast forever; as the document archive grows, the only thing that grows is the bucket.
Every document version carries a storage mode field saying where its content lives. In production that is always R2; the field exists for the development environment. If the R2 keys are not configured, the application notices at startup and falls back to a mode that temporarily writes content to the database, so a developer can work without setting anything up. In production the Required flag is on: instead of starting with a half-configured store, it stops at startup. An upload that “succeeds” while writing content to the wrong place is the most expensive kind of silent failure.
Key design: making the bucket browsable
Object storage has no folders, only keys; but design the key well and the bucket becomes an archive a human can navigate. The format we use:
{ownerType}/{ownerName}-{ownerId}/{documentNo}-{documentId}/v{N}-{sha256:12}.{ext}
Project/sample-site-42/DOC-2026-001-7/v1-2d711642b726.pdf
Each part has a reason:
- Name and id side by side. With only the id, someone looking at the bucket would understand nothing; with only the name, two projects with the same name would merge into one folder and a rename would break the path. Together they are both readable and reliable.
- Version number and digest. The second version of a document is a new object; the old one is never overwritten. The digest lets us verify integrity and detect the same file being uploaded again.
- The user’s file name never enters the key. Whatever the user uploads, the path stays predictable and safe; the original name is stored only in the database and returned on download.
A deliberate limit: when a project is renamed, old objects are not moved. The path is stored on the record, so reads are unaffected; only the bucket’s appearance ages over time. Moving would mean thousands of copy operations for one rename; it is not worth it.
Two small settings in the .NET client
Two settings save time when pointing the S3 client at R2:
new AmazonS3Config
{
ServiceURL = $"https://{accountId}.r2.cloudflarestorage.com",
ForcePathStyle = true, // bucket name goes in the path, not the host name
AuthenticationRegion = "auto" // R2 has no regions; the signature expects this
};
Path-style addressing avoids a TLS certificate clash when the bucket name contains a dot. The auto region is the most common cause of signing errors; forget it and the error message will send you looking in the wrong place.
Access keys never enter the repository: user-secrets in development, environment variables in production. Creating the key as an API token scoped to that single bucket with “Object Read & Write” limits the damage of a leak to one bucket.
Location and data protection
Documents contain personal data: a name on a signed contract, a face in a photo, contact details in an offer. Where the data lives is therefore a compliance question.
R2 has two separate mechanisms, and both are set when the bucket is created and cannot be changed later:
- A location hint is for performance; say “Eastern Europe” and the data will most likely land there, but there is no guarantee.
- A jurisdictional restriction is a guarantee; a bucket created with
EUnever leaves the European Union and its endpoint becomeseu.r2.cloudflarestorage.com.
For an organisation in Türkiye, both cases are an international transfer under KVKK and must be stated in the privacy notice. The EU jurisdiction still makes the adequacy and contractual side easier. Decide this when creating the bucket; the only way to change it later is a new bucket and a migration.
Downloads: proxy through the server or signed URLs?
There are two paths. Streaming the document through the API keeps authorisation in one place and produces an audit record; the cost is that the server accompanies every download. A signed URL instead produces a short-lived link with embedded authorisation for one object; the user downloads directly from R2 and the server only signs the link.
In Supply Management we use the first path for now: documents are small and “who downloaded what, when” matters for audit. For large files or direct uploads from the mobile app, signed URLs are the right tool; R2 supports them exactly as S3 does.
A cost example
Take an organisation with a thousand document versions, 2 MB on average, and five thousand downloads a year. Storage is 2 GB, inside the free tier. A thousand writes and five thousand reads, far below the quota. The monthly bill is zero. In the same scenario S3 charges a few dollars for storage and egress; the real gap appears when ten thousand users download. On R2 the bill does not change.
For rarely accessed archives the “Infrequent Access” class lowers storage to $0.01 per GB, but adds a thirty-day minimum and a $0.01 per GB retrieval fee. Keep the active document archive on standard; moving documents past their legal retention period to the lower class with a lifecycle rule makes sense.
Checklist
Decide these up front when building a document store on R2:
- Bucket name, jurisdiction and location hint; they cannot be changed later.
- Key format; readable and id-based.
- Only document information and the object key in the database; bytes only in the bucket.
- An API token scoped to that bucket only; keys in environment variables.
- Behaviour when configuration is missing: fall back to the database in development, stop in production.
- Download path: proxy or signed URL; choose by audit needs.
- International transfer and retention periods in the privacy notice.
Keeping documents in object storage from day one is one of the least exciting but longest-lived design decisions an application can make. Backups stay small, migrations stay fast, and file weight never stands between a query and its result.