User-uploaded images look simple until real traffic arrives. Files are larger than expected, two users choose the same filename, browsers retry uploads, private images become public and image variants multiply. Cloudflare R2, Cloudflare Images and Workers can form a strong upload pipeline when each product has a clear job.
This guide builds a design for a SaaS product that accepts profile and project images. It shows two upload paths, a validation stage, private originals and controlled delivery. It also explains where costs come from. The exact implementation depends on your file sizes, privacy rules and Cloudflare plan; verify current limits before deploying.
Choose the role of each product
| Product | Best role in this design | Decision to make |
|---|---|---|
| R2 | Store original files and, where useful, durable derivatives | Will the bucket be private or public? |
| Images | Resize, optimize or transform images | Transform on request or store prebuilt variants? |
| Workers | Authorize uploads and reads, create object keys, validate metadata and serve URLs | Proxy bytes or issue direct-upload credentials? |
Cloudflare distinguishes image transformations from storing images in the Images product. Images can transform files held outside Images, including R2; you do not necessarily need to store the original in Images. The binding-based transform flow shown in Cloudflare’s upload tutorial requires an Images Paid subscription, while other remote-image transformation features have their own plan rules. Check the path you intend to use before choosing a plan. Cloudflare Images pricing and feature model and binding tutorial prerequisites
For a new SaaS product, decide whether images are core customer data, public content or disposable previews. StadiaSoft’s SaaS development service treats storage and access rules as part of product design, not a later upload widget.
Map the full upload lifecycle
The safest mental model is a state machine:
requested → uploading → pending validation → ready → deleted
↘ rejected
A file is not public merely because bytes arrived in R2. The application should record who owns it, which tenant it belongs to, its expected purpose and whether it passed validation. For regulated or sensitive content, add malware scanning and moderation appropriate to the use case before it enters a public delivery path.
A typical flow is:
- Authenticated client requests permission to upload an image.
- Worker checks the user’s tenant and image quota.
- Worker creates a random object key in a private staging prefix.
- Client uploads to R2, either through the Worker or with a short-lived presigned URL.
- Client reports completion; backend reads object metadata and validates the actual file.
- Backend records the accepted object and publishes only the approved delivery route.
- Images transforms the original when a supported preset is requested.
- A cleanup process removes abandoned staging objects.
This separates “we received bytes” from “this is safe to display.”
Choose between Worker-proxied and direct uploads
Option A: Proxy through a Worker
The browser sends bytes to your Worker. The Worker can authenticate the request and write to an R2 binding in one controlled path. This simplifies authorization and error reporting, and it is suitable for small files within current Worker request limits. The cost is that the Worker sits in the upload data path and must handle large requests carefully.
Cloudflare’s own tutorial demonstrates a Worker that accepts an image, transforms it with Images and uploads the result to R2. That is a useful starting point. For production, add explicit user identity, file limits, private storage and cleanup. Cloudflare’s image-to-R2 tutorial
Option B: Issue a presigned PUT URL
For larger uploads, the Worker can authorize a request and return a short-lived presigned URL for one object key. The browser then uploads directly to R2. Cloudflare documents this path for browser uploads that should not pass through the Worker. R2 presigned URLs
Treat that URL like a temporary bearer credential: anyone who has it can perform its signed operation until it expires. Use an unpredictable key, bind the request to the intended method and content type where supported, keep expiry short, and never expose permanent R2 credentials to the browser. A presigned URL does not by itself prove the file’s contents, size, safety or owner after upload. Validate the object before marking it ready. Also avoid assuming a presigned URL is single use; Cloudflare notes it can be reused before expiry. Presigned R2 URLs use the S3 API domain, not a custom domain.
For browser uploads, configure R2 CORS to allow only the application’s origin, required method and required headers. CORS supports browser behavior; it is not an authorization system. Cloudflare documents that browser requests using valid presigned URLs can still fail if CORS is absent. Configure R2 CORS
Generate keys that do not expose user input
Never use a raw filename as the R2 key. Filenames can collide, contain unexpected characters or reveal personal information. Build keys from server-controlled identifiers, for example:
staging/<tenant-id>/<random-upload-id>/original
ready/<tenant-id>/<asset-id>/original
Keep the human-readable filename in trusted metadata if the product needs it. The tenant and asset IDs must come from authorized server context, not from unchecked form fields. Store the association in the application’s database so an image lookup can verify ownership before delivery.
Do not rely solely on the browser’s Content-Type or file extension. Read the uploaded bytes or use a trusted image decoder to establish the actual format. Reject unsupported formats, excessive dimensions, oversized files and decompression bombs. If your workflow includes scanning, keep the staging object private until scanning completes.
Transform images for the places they appear
Define named display variants based on actual interface needs:
| Variant | Example use | Design decision |
|---|---|---|
| Avatar | Account menu | Small square crop, stable cache key |
| Card | Search or listing | Moderate width and aspect ratio |
| Detail | Full content view | Larger responsive rendition |
Avoid allowing arbitrary transformation parameters from the public URL. A finite set of variants makes rendering predictable and helps control unique transformation counts. Let the Worker map a name such as card to allowed width, quality and format settings. Cloudflare can transform images through a Worker subrequest, and the Images binding can work with raw bytes from R2. Transform via Workers and Images binding
To implement the R2-to-Images path, first bind both products to the Worker. Replace the bucket name with your private bucket; the Images binding requires the applicable Images plan. R2 Worker binding setup and Images binding setup
{
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "private-media" }],
"images": { "binding": "IMAGES" }
}
After your application has authenticated the viewer, checked tenant access, looked up the asset’s server-owned object key and confirmed its state is ready, the core delivery operation can read the original and produce one approved variant:
const original = await env.MEDIA.get(authorizedAsset.objectKey);
if (!original) return new Response("Not found", { status: 404 });
const image = await env.IMAGES.input(original.body)
.transform({ width: 640 }) // server-selected `card` preset
.output({ format: "image/webp" });
return image.response({
headers: {
"Cache-Control": "private, no-store"
}
});
This is the transformation step, not a complete public endpoint: authorizedAsset must come from trusted application logic, never from an unchecked query string. Do not cache tenant-private responses in a shared cache. For public, versioned images, Cloudflare recommends configuring cache behavior explicitly so repeat requests do not re-run the transform. Test that browser output, access decisions and billing match the plan you selected. Images binding caching guidance
Serve responsive images with srcset and dimensions so browsers choose an appropriate file and reserve layout space. Measure visual quality on real photos and screenshots; aggressive compression can make product images or text inside screenshots unreadable.
Protect private delivery
A private R2 bucket is a good default for customer-owned images. A delivery Worker can authenticate the viewer, check tenant permission, read the object and return an approved variant. If your design uses signed delivery links, keep their lifetime short and bind them to the specific asset and action. Do not equate an unguessable URL with access control.
For public marketing images, a public bucket or public delivery route can be appropriate. Keep private and public assets in deliberately separate paths or buckets. Document how an asset changes visibility and whether old cached variants must be invalidated.
Set response headers intentionally: a versioned public asset can have a long cache lifetime, while a sensitive asset should have cache behavior consistent with the authorization model. Review whether cached responses could be served to a different user or tenant.
Handle replacements, deletion and abandoned uploads
An image replacement should create a new version rather than overwrite bytes at the same cacheable URL. Update the application’s asset pointer only after the new file has passed validation. That gives you a safe rollback path if the new image fails.
Use lifecycle rules for staging objects that never complete. Cloudflare supports R2 lifecycle rules that expire objects under selected prefixes. Deleting an application record should also trigger an object-deletion workflow, with a reconciliation report for objects that remain after a failed deletion. R2 object lifecycles
For customer data, align deletion timing with your product policy and retention obligations. Do not promise immediate physical deletion if your chosen storage and backup process does not provide it.
Calculate the whole cost, not just storage
Cloudflare’s current R2 pricing separates stored GB-months, Class A operations such as writes and Class B operations such as reads. R2 does not charge egress bandwidth, but connected products can still have their own costs. Official R2 pricing
Build a monthly worksheet using your expected activity:
Original storage = retained images × average original size × retention
R2 write operations = uploads + derivatives written + replacements
R2 read operations = origin reads needed after cache misses
Images usage = unique transformations or delivered/stored images,
depending on the feature selected
Workers usage = authorization, upload and delivery requests
As an illustrative scenario, 10,000 retained images averaging 2 MB represent roughly 20 GB of original data before variants and backups. That is a capacity estimate, not a bill: actual monthly charges depend on average storage over time, free allowances, operation counts, transformation behavior and plan selection. Cloudflare publishes separate Images pricing and Workers pricing because R2’s free egress is only one component.
Watch three less obvious multipliers: arbitrary variant sizes requested by clients, repeated failed uploads left in staging, and original reads that bypass effective caching.
Test the pipeline before launch
- Upload a valid image through each supported path.
- Reject an anonymous user and a valid user from the wrong tenant.
- Attempt to reuse an upload URL during its valid period; verify your application does not publish the wrong bytes.
- Upload a renamed non-image, oversized image and extreme-dimension image.
- Confirm the asset stays private until validation passes.
- Simulate failure after bytes reach R2 but before metadata is committed.
- Test replacement without exposing an incomplete version.
- Delete an asset and verify application metadata, delivery URLs and storage reconcile.
- Measure first-view and cached-view latency on mobile connections.
- Check the Images transformation count and estimated cost under expected variants.
If your media pipeline feeds a larger product API, the Workers, D1 and Queues SaaS guide explains how to track asynchronous processing and recover failed jobs.
The design decision
R2 is a strong place for durable originals when its object-storage model fits your product. Images handles the visual transformations, and Workers enforces the application rules around both. The quality of the system depends on what happens between those products: authorization, validation, state changes and observability.
If your application needs customer uploads at scale, talk with StadiaSoft about the upload workflow. We can map the exact privacy, performance and cost requirements before you commit to one delivery architecture. StadiaSoft also offers API integration services for connecting that pipeline to your existing systems.
Technical and pricing sources reviewed September 25, 2026. Confirm current plan limits and charges before implementation.
Frequently asked questions
Is Cloudflare Images the same as R2?
No. R2 stores objects. Images provides image storage and delivery options and can transform images stored elsewhere, including R2. The right combination depends on whether you need durable originals, managed image delivery or both.
Are presigned R2 upload URLs safe to share publicly?
They grant temporary access to the signed operation. Issue them only after authorization, use short expiry and random keys, and verify the resulting object before publishing it.
Does free R2 egress make the entire image pipeline free?
No. Storage, operations, Images features, Workers and connected services may generate charges. Model each part separately.