Get a quote

Multi-Tenant File Storage Without S3 Egress Fees: Cloudflare R2 and Workers Architecture

For a SaaS product with 200 tenants each uploading receipts, invoices, or product images, S3 egress costs become a meaningful line item within the first year. Cloudflare R2 eliminates egress fees entirely, and Workers gives you tenant isolation logic running at the edge without a separate service to maintain.

For a SaaS product with 200 tenants each uploading receipts, invoices, or product images, S3 egress costs become a meaningful line item within the first year. Cloudflare R2 eliminates egress fees entirely, and Workers gives you tenant isolation logic running at the edge without a separate service to maintain. This is the architecture we use at Voxire for multi-tenant SaaS products serving Lebanon and the broader MENA region.

Why S3 egress fees compound for multi-tenant SaaS

AWS S3 charges for data transfer out to the internet at $0.09 per GB in most regions, including eu-west-1. For a SaaS product where tenants access their uploaded files regularly, this cost compounds quickly.

Consider a restaurant SaaS product where 200 restaurant tenants each upload an average of 500 MB of menu images, receipt scans, and daily report attachments per month. Total stored data grows at 100 GB per month. If tenants access 30% of their stored data in a given month, that is 30 GB of egress per month, roughly $2.70. That seems negligible.

But at 2,000 tenants, stored data grows at 1 TB per month. Monthly egress at 30% access rate is 300 GB, roughly $27. At 10,000 tenants the math is $135 per month just in egress for file downloads. Add CloudFront distribution costs on top, and file storage becomes a meaningful infrastructure line item for a SaaS product with modest per-tenant storage usage.

The subtlety is that egress costs are hard to predict at product inception. You set up S3, it works, and then six months into growth you discover that file access patterns generate more egress than the stored volume suggests.

How Cloudflare R2 pricing actually works

R2 charges for storage and operations, but not for egress. Data served from R2 to the internet has no transfer fee, including data served through Cloudflare Workers or the R2 public bucket URL.

The R2 pricing structure (as of mid-2026):

  • Storage: $0.015 per GB per month
  • Class A operations (writes, lists): $4.50 per million
  • Class B operations (reads): $0.36 per million
  • Egress: $0

For comparison, S3 in eu-west-1:

  • Storage: $0.023 per GB per month
  • PUT operations: $5.00 per million
  • GET operations: $0.40 per million
  • Egress: $0.09 per GB

At low scale, the per-operation cost difference is minor. At scale, R2's zero-egress model becomes the dominant cost advantage for file-heavy SaaS products.

R2's limitation is that it does not have a native CDN equivalent to CloudFront's edge network. Files served from R2 are served from Cloudflare's network, which has broad global coverage, but fine-grained cache control at the CDN level requires using Workers in front of R2 rather than direct bucket URLs.

Using Workers for tenant isolation on uploads and downloads

The architectural role of Cloudflare Workers in this setup is enforcing tenant isolation. Without a Worker in front of R2, bucket access must be controlled at the key prefix level or through presigned URLs. Both approaches work, but a Worker gives you a programmable policy layer at the edge.

The upload flow with a Worker:

  1. Client sends an upload request to your Go API backend
  2. Go backend validates the request and generates a signed upload URL with a scoped prefix: tenant/{tenant_id}/uploads/{filename}
  3. Client uploads directly to R2 via the signed URL
  4. Worker intercepts the upload, validates the signed URL signature and prefix, and rejects requests with mismatched tenant prefixes

A simplified Worker for download access control:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const pathParts = url.pathname.split('/');
    // Expect: /tenant/{tenant_id}/{filename}
    if (pathParts.length < 4 || pathParts[1] !== 'tenant') {
      return new Response('Not found', { status: 404 });
    }
    const pathTenantId = pathParts[2];
    const authToken = request.headers.get('Authorization')?.replace('Bearer ', '');
    const jwtTenantId = await validateJWT(authToken, env.JWT_SECRET);
    if (!jwtTenantId || jwtTenantId !== pathTenantId) {
      return new Response('Forbidden', { status: 403 });
    }
    const object = await env.R2_BUCKET.get(url.pathname.slice(1));
    if (!object) return new Response('Not found', { status: 404 });
    return new Response(object.body, {
      headers: { 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream' }
    });
  }
};

The Worker validates the JWT from the Authorization header and compares the tenant_id claim against the tenant_id in the file path. Requests for another tenant's files are rejected at the edge, before R2 is even queried.

The signed URL pattern for secure file access

For SaaS products where files should be accessible without requiring an Authorization header on every request (embedded images, PDF reports opened in a browser tab), the signed URL pattern avoids the Worker authentication flow.

The Go backend generates a time-limited signed URL that embeds the tenant_id and an expiry timestamp in an HMAC-signed query parameter:

func SignFileURL(tenantID, filePath string, expiry time.Duration, secret []byte) string {
    expiresAt := time.Now().Add(expiry).Unix()
    payload := fmt.Sprintf("%s:%s:%d", tenantID, filePath, expiresAt)
    h := hmac.New(sha256.New, secret)
    h.Write([]byte(payload))
    sig := hex.EncodeToString(h.Sum(nil))
    return fmt.Sprintf(
        "https://files.example.com/tenant/%s/%s?expires=%d&sig=%s",
        tenantID, filePath, expiresAt, sig,
    )
}

The Worker validates the signature and expiry before serving the file. Links expire automatically and cannot be shared across tenant boundaries because the tenant_id is embedded in the signed payload.

For restaurant and retail SaaS products serving Lebanon and the Gulf, this pattern handles the common use case of sharing an invoice PDF with a customer via a time-limited URL, without exposing the underlying storage bucket structure.

Delivery and caching with Cloudflare CDN

Cloudflare Workers can use the Cache API to cache R2 objects at Cloudflare edge nodes, reducing R2 read operations and improving delivery latency for frequently accessed files.

A Worker that caches R2 objects:

const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
  const object = await env.R2_BUCKET.get(key);
  response = new Response(object.body, {
    headers: {
      'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
      'Cache-Control': 'public, max-age=86400',
    }
  });
  ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;

For product images and menu photos that change infrequently, a 24-hour cache TTL reduces R2 read operations dramatically. For receipts and reports that should not be cached across signed URL refreshes, set Cache-Control: no-store.

Cost comparison at real scale

For a restaurant SaaS with 1,000 tenants, each storing 500 MB and generating 150 MB of monthly egress:

S3 + CloudFront:

  • Storage: 500 GB x $0.023 = $11.50/month
  • Egress via CloudFront: 150 GB x $0.085 = $12.75/month
  • CloudFront requests: minor
  • Total: approximately $24/month

R2 + Workers:

  • Storage: 500 GB x $0.015 = $7.50/month
  • Egress: $0
  • Workers invocations: 1M free, then $0.30/million
  • Total: approximately $8/month

At 1,000 tenants the savings are modest. At 10,000 tenants they are substantial. More importantly, the R2 cost scales predictably with storage, not with access patterns. A product feature that suddenly increases file access (a new mobile app, a new export feature) does not create a surprise egress bill.

Data residency for MENA products

As of mid-2026, R2 stores data across Cloudflare's network without a hard per-region guarantee comparable to AWS S3's per-region model. For most SaaS products serving Lebanon, Jordan, and the Gulf, this is acceptable.

For products with Gulf enterprise customers who have explicit data residency requirements for Saudi Arabia or UAE, the architecture decision requires more careful evaluation. R2's jurisdictional data control feature allows constraining storage to specific geographic jurisdictions, but the feature set is newer and the tooling is less mature than AWS's established per-region compliance model.

For Lebanon-based SaaS products primarily serving SMBs in Lebanon and neighboring markets, R2's current data handling is operationally suitable. For Gulf government-adjacent buyers, evaluate the residency requirements before committing to R2 as the file storage layer.


Key lessons from production

R2's zero-egress pricing eliminates a cost variable that is hard to predict at product inception. The operational tradeoff is a younger tooling ecosystem compared to S3.

Workers for tenant isolation logic at the edge eliminate a class of infrastructure you would otherwise need: a dedicated file access service. The Worker is the access control layer.

Signed URLs with HMAC signatures handle the common case of sharing files with end users without the complexity of session-authenticated requests.

Cache at the Worker level for infrequently changing files. For dynamic or time-limited content, set explicit no-cache headers.

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

Not sure where to start?

Voxire architects and builds cloud infrastructure for SaaS products in Lebanon and the MENA region, including multi-tenant file storage, CDN configuration, and Cloudflare Workers deployment. If your file storage costs are growing faster than your user base, we can review your current setup and model the alternatives.

https://voxire.com/get-a-quote/

Back to blog
Chat on WhatsApp