Building a Multi-Tenant CDN with CloudFront Functions and KeyValueStore
Serving multiple tenants from a single CloudFront distribution is a cost-effective way to scale a SaaS platform. Rather than spinning up separate infrastructure for each customer, you can use CloudFront...
Serving multiple tenants from a single CloudFront distribution is a cost-effective way to scale a SaaS platform. Rather than spinning up separate infrastructure for each customer, you can use CloudFront Functions combined with KeyValueStore (KV) to dynamically route requests based on hostname—all at the edge, with sub-millisecond latency.
In this post, I’ll walk through an architecture that supports three tenant models from a single distribution:
- Subdomain tenants —
customer.yourdomain.com - Custom domain tenants —
www.customerdomain.com - Dedicated bucket tenants — Customers with their own S3 bucket for complete isolation
The Core Idea
All tenant content lives in a shared S3 bucket, organized by prefix:
s3://shared-tenant-bucket/
├── acme/
│ ├── index.html
│ └── about/index.html
├── globex/
│ ├── index.html
│ └── products/index.html
└── initech/
└── index.html
When a request comes in for acme.yourdomain.com/about, the CloudFront Function rewrites it to /acme/about/index.html before fetching from S3. The tenant never sees the prefix—they just see their own clean URL structure.
The Routing Function
Here’s the complete CloudFront Function that handles all three tenant models:
import cf from 'cloudfront';
var kv = cf.kvs();
async function handler(event) {
var request = event.request;
var uri = request.uri;
// Bypass error pages to prevent routing loops
if (uri.startsWith('/_error')) {
return request;
}
var host = request.headers.host ? request.headers.host.value : "";
var tenantId;
var customBucket;
// Check for custom S3 bucket override
customBucket = await kv.get(host + '_s3').catch(() => null);
if (customBucket) {
// Tenant has their own dedicated bucket
cf.updateRequestOrigin({
"domainName": customBucket,
"originAccessControlConfig": {
"enabled": true,
"signingBehavior": "always",
"signingProtocol": "sigv4",
"originType": "s3"
},
"customHeaders": {}
});
// Apply index.html rewriting for SPA support
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
// Check for custom domain mapping
if (!host.endsWith('yourdomain.com')) {
tenantId = await kv.get(host).catch(() => null);
if (!tenantId) {
return {
statusCode: 404,
statusDescription: 'Unknown domain'
};
}
} else {
// Standard subdomain - extract tenant from first segment
tenantId = host.split('.')[0];
}
// Apply index.html rewriting before prepending tenant prefix
if (uri.endsWith('/')) {
uri += 'index.html';
} else if (!uri.includes('.')) {
uri += '/index.html';
}
// Rewrite path to include tenant prefix
request.uri = '/' + tenantId + uri;
return request;
}
Let’s break down what each section does.
Handling Dedicated Buckets
The first lookup checks if this hostname has a dedicated S3 bucket:
customBucket = await kv.get(host + '_s3').catch(() => null);
If the KV store contains an entry like enterprise.yourdomain.com_s3 → enterprise-bucket.s3.us-east-1.amazonaws.com, the function dynamically switches the origin using cf.updateRequestOrigin(). This is powerful—you’re changing where CloudFront fetches content at runtime, per-request.
The origin configuration includes OAC (Origin Access Control) settings, ensuring the tenant’s bucket remains private and only accessible through CloudFront with proper SigV4 signing.
Custom Domain Resolution
For tenants who want to use their own domain (like www.clientsite.com), the function checks the KV store for a domain-to-tenant mapping:
if (!host.endsWith('yourdomain.com')) {
tenantId = await kv.get(host).catch(() => null);
if (!tenantId) {
return {
statusCode: 404,
statusDescription: 'Unknown domain'
};
}
}
The KV store entry might look like: www.clientsite.com → clientco
If no mapping exists, we return a 404 rather than defaulting to some other tenant’s content—security matters here.
Subdomain Extraction
For the simple case of tenant.yourdomain.com, we just split the hostname:
tenantId = host.split('.')[0];
This is fast and requires zero KV lookups for subdomain tenants, keeping latency minimal.
SPA-Friendly URL Rewriting
Static sites and single-page applications expect /about to serve /about/index.html. The function handles this:
if (uri.endsWith('/')) {
uri += 'index.html';
} else if (!uri.includes('.')) {
uri += '/index.html';
}
The heuristic is simple: if the path has no file extension, assume it’s a directory and append index.html. This works well for most static sites, though you’ll want to consider edge cases for your specific application.
The KeyValueStore Schema
The KV store needs just two types of entries:
| Key Pattern | Value | Purpose |
|---|---|---|
{domain} |
Tenant ID | Map custom domain to tenant |
{domain}_s3 |
S3 bucket domain | Override to dedicated bucket |
For example:
www.clientsite.com→clientcoenterprise.yourdomain.com_s3→enterprise-bucket.s3.us-east-1.amazonaws.com
The beauty of KV is that updates propagate globally in seconds, so onboarding a new custom domain tenant is nearly instantaneous.
Request Flow Visualization
Here’s how a request flows through the system:
┌─────────────────────────────────────────────────────────────┐
│ Incoming Request │
│ customer.yourdomain.com/products │
└─────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────┐
│ Check for dedicated │
│ bucket override │──── Found ──▶ Switch origin
└────────────────────────┘ to tenant bucket
│
│ Not found
▼
┌────────────────────────┐
│ Custom domain? │──── Yes ───▶ Lookup tenant
│ (!*.yourdomain.com) │ in KV store
└────────────────────────┘
│
│ No (subdomain)
▼
┌────────────────────────┐
│ Extract tenant from │
│ subdomain segment │
└────────────────────────┘
│
▼
┌────────────────────────┐
│ Rewrite URI: │
│ /products │
│ ↓ │
│ /customer/products/ │
│ index.html │
└────────────────────────┘
│
▼
┌────────────────────────┐
│ Fetch from S3 │
└────────────────────────┘
Why This Architecture Works
Cost efficiency — One CloudFront distribution, one S3 bucket (for most tenants), one ACM certificate with SANs. You’re not paying for infrastructure per tenant.
Instant provisioning — Adding a subdomain tenant means creating an S3 folder. That’s it. Custom domains need a KV entry and DNS update.
Flexibility at the edges — Some tenants want isolation? Give them a dedicated bucket. Most are fine sharing? They share. The routing logic handles both transparently.
Edge performance — CloudFront Functions run at edge locations worldwide with sub-millisecond execution. KV lookups are similarly fast. Your tenants get the same performance regardless of which model they’re on.
Security boundaries — Each tenant’s content is isolated by prefix or bucket. OAC ensures S3 buckets aren’t publicly accessible. Custom domain tenants can’t accidentally (or intentionally) access another tenant’s content.
Considerations
A few things to keep in mind:
Subdomain validation — The simple host.split('.')[0] extraction doesn’t validate that a tenant exists. A request to nonexistent.yourdomain.com will try to fetch from /nonexistent/ in S3 and return whatever error S3 returns (likely 403 or 404). Consider adding KV validation for subdomains if you want cleaner error handling.
The www problem — If someone requests www.yourdomain.com, the tenant ID becomes www. Handle this explicitly if your root domain also uses this distribution.
Path heuristics — The !uri.includes('.') check assumes paths with dots are files. A route like /api/v2.0/users won’t get index.html appended, which is probably fine for APIs but worth considering for your routing needs.
Wrapping Up
CloudFront Functions with KeyValueStore give you a surprisingly powerful toolkit for multi-tenant architectures. The combination of edge execution, dynamic origin selection, and fast KV lookups means you can build sophisticated routing logic without the latency penalty of hitting a backend service.
The function shown here handles the common patterns—subdomains, custom domains, and dedicated buckets—but the same primitives can be extended for feature flags, A/B testing, or geographic routing. Once you’re thinking at the edge, a lot of interesting possibilities open up.