This article was co-authored with generative AI. Facts have been checked against public documentation where feasible, but errors may remain. Please verify primary sources before relying on this for important decisions.
Hostnames and IDs in this article are replaced with generalized placeholders. Examples:
- Production hostname:
app.example.org- Worker URL:
<worker-name>.<account>.workers.dev- CloudFront distribution domain:
dxxxxxxxxxx.cloudfront.net- WAF name:
your-app-waf
TL;DR
- Wanted to serve a subdomain managed by an external organization's DNS (written here as
app.example.org) via Cloudflare Workers / Pages - Cloudflare Free / Pro / Business does not accept external domain subdomains as standalone zones (no NS delegation, no CNAME claim)
- Cloudflare for SaaS supports CNAME-based acceptance, but requires Pro ($25/month or above)
- Vercel supports CNAME-based custom domains, but the target subdomain had already been exclusively claimed by another account
- Final solution: placed AWS CloudFront + AWS WAF in front, pointing to workers.dev as the origin
- DNS manager coordination required two rounds: "add ACM validation CNAME → later switch A to CNAME"
- Estimated monthly cost: ~$10 for low-traffic sites
Background
Migration of a Next.js app to Cloudflare Workers was in progress. The app itself had been converted to a Worker using @opennextjs/cloudflare, the Elasticsearch connection was switched to Cloudflare Tunnel + Cloudflare Access Service Token, and everything worked fine locally and on workers.dev.
The problem appeared at the production domain cutover stage. The production URL was a subdomain managed by an external organization, with these constraints:
- The URL string visible to users cannot change
- The parent zone is managed by the external organization's DNS team
- Individual DNS record changes can be requested (with some coordination)
Options Explored
Option A: NS Delegation to Cloudflare
The first idea was "have them NS-delegate just the subdomain to Cloudflare." NS delegation of a subdomain to a different provider (Route 53, Azure DNS, etc.) is standard DNS protocol behavior used in other contexts.
However, attempting to register this subdomain as "Add Site" in the Cloudflare dashboard returned:
Please ensure you are providing the root domain and not any subdomains
(e.g., example.com, not subdomain.example.com)
Looking into it, Cloudflare's product design only allows subdomains as standalone zones on Enterprise plans. Free / Pro / Business plans only accept root domains (apex). DNS protocol permits it; Cloudflare has made a business decision not to accept such registrations.
Option B: Cloudflare for SaaS Custom Hostname
Subscribing to Cloudflare for SaaS (Pro or above) allows accepting external domains via CNAME authentication. This is designed for SaaS operators who want to terminate customers' custom domains on their own service.
Cost is Pro at $25/month, plus $0.10/month per additional host. For a single host, approximately $25/month. For a small site at this scale, the ongoing cost was not considered worthwhile.
Option C: Deploy on Vercel
Next.js has natural affinity with Vercel, and Custom Domains are accepted via CNAME authentication. The migrating app even had a vercel.json remaining.
However, attempting to register the subdomain as a Custom Domain on Vercel returned an error indicating it was "already claimed by another account / project." Vercel uses an exclusive claim model for custom domains — if a past project somewhere tried the same domain, a new project cannot register it. Releasing the claim requires proving ownership via DNS (TXT record) and contacting support.
Option D: Reverse Proxy on Own Server + Self-Managed WAF
An existing server running Traefik could have a reverse proxy rule added for this subdomain, forwarding internally to workers.dev.
This would work at no cost, but:
- The own server remains a single point of failure (losing the redundancy benefit of Cloudflare Workers)
- WAF / DDoS defense would need to be self-operated at the host level (ModSecurity + OWASP CRS, etc.)
- No edge caching either
These disadvantages run counter to the migration's original goals (eliminating own-server dependency, achieving edge delivery).
Option E: AWS CloudFront + AWS WAF in Front (Chosen)
This is the approach ultimately adopted. AWS CloudFront is placed in front of traffic reaching Cloudflare Workers. CloudFront handles custom domain acceptance using ACM certificates + Distribution Aliases (CNAMEs), which operate independently of zone management rights — making it work fine with external subdomains.
Why "CNAME the Workers URL Directly" Doesn't Work
A technical clarification. Suppose the organization's DNS set:
app.example.org. CNAME <worker-name>.<account>.workers.dev.
The flow would be:
- Browser resolves
app.example.org→ gets Cloudflare's Anycast IP - Browser initiates TLS handshake with Cloudflare's edge, presenting
app.example.orgin the SNI - Cloudflare edge: "This hostname has no registered zone or certificate with us"
- Response is 1014 (CNAME Cross-User Banned) or 530, or workers.dev default certificate is returned → browser gets cert mismatch error
Two reasons it stops:
| Layer | Problem |
|---|---|
| Routing | Cloudflare only accepts hostnames from its own zones or Cloudflare for SaaS registrations (the CDN layer doesn't know where to route it) |
| TLS certificate | No mechanism to issue a certificate for that hostname (Cloudflare's domain ownership verification assumes a zone) |
Why CloudFront Works
CloudFront handles the same situation via three mechanisms:
| Mechanism | Description |
|---|---|
| ACM certificate (DNS validation) | A single TXT/CNAME in DNS confirms ownership and ACM issues the certificate. No zone management rights required |
| Distribution Aliases (CNAMEs) | Declare to the distribution: "accept SNI for this hostname" |
| SNI dispatch | Edge matches the SNI against registered aliases and routes to the corresponding distribution |
All of this is available under the free tier (request-based billing). Comparing with Cloudflare:
| Standard Cloudflare | AWS CloudFront | |
|---|---|---|
| Certificate issuance prerequisite | Hostname's zone must be under Cloudflare | ACM DNS validation (zone agnostic) |
| Custom domain acceptance | Cloudflare for SaaS (Pro required) | Just add to Distribution Aliases |
| SNI dispatch | Zone-management based | Based on listed Aliases |
| Billing | Pro $25/month+ | Request/transfer volume, a few dollars/month for small sites |
The core difference is "proof of address at the zone level versus at the certificate level." CloudFront only needs the certificate level, so it doesn't matter who manages the zone.
Final Configuration
Visitor → app.example.org
↓ DNS CNAME (set in organization's DNS)
AWS CloudFront (WAF applied)
↓ Origin (HTTPS, Host: workers.dev)
Cloudflare Worker (Next.js / OpenNext)
↓ Service Token (CF-Access-Client-*)
Cloudflare Tunnel
↓ inside docker network
Elasticsearch (self-hosted server internal)
Setup Steps
All steps are executable with the AWS CLI. Prerequisites: IAM credentials where aws sts get-caller-identity works, and the Worker (<worker-name>.<account>.workers.dev) is already running.
1. Request ACM Certificate
Certificates used by CloudFront must be in the us-east-1 region.
aws acm request-certificate \
--region us-east-1 \
--domain-name app.example.org \
--validation-method DNS \
--tags Key=Project,Value=your-app
Note the returned CertificateArn. Then retrieve the DNS validation record (CNAME) for this certificate:
aws acm describe-certificate \
--region us-east-1 \
--certificate-arn "$CERT_ARN" \
--query 'Certificate.DomainValidationOptions[].ResourceRecord.[Name,Type,Value]' \
--output text
Example output:
_xxxxxxxxxxxxxxx.app.example.org. CNAME _yyyyyyyyyyyyyyy.acm-validations.aws.
This single record needs to be added to the organization's DNS.
2. Create WAF Web ACL (CLOUDFRONT scope)
WAF attached to CloudFront must have scope=CLOUDFRONT and be in us-east-1. Three AWS Managed Rules are enabled:
AWSManagedRulesCommonRuleSetAWSManagedRulesKnownBadInputsRuleSetAWSManagedRulesAmazonIpReputationList
{
"Name": "your-app-waf",
"Scope": "CLOUDFRONT",
"DefaultAction": {"Allow": {}},
"Description": "WAF for CloudFront fronting your-app Worker",
"Rules": [
{
"Name": "AWS-AWSManagedRulesCommonRuleSet",
"Priority": 1,
"Statement": {
"ManagedRuleGroupStatement": {
"VendorName": "AWS",
"Name": "AWSManagedRulesCommonRuleSet"
}
},
"OverrideAction": {"None": {}},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "AWS-CommonRuleSet"
}
}
// other 2 rules follow the same pattern
],
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "your-app-waf"
}
}
aws wafv2 create-web-acl \
--region us-east-1 \
--cli-input-json file://web-acl.json
Note: the Description field's regex is ^[\w+=:#@/\-,\.][\w+=:#@/\-,\.\s]+[\w+=:#@/\-,\.]$, so parentheses are rejected. An initial attempt with parentheses caused an error.
3. Create CloudFront Distribution
One complication: if the ACM certificate is still PENDING_VALIDATION, it cannot be attached to CloudFront (InvalidViewerCertificate error). Since validation can't complete until the organization's DNS is updated, this is split into two phases:
- Phase 1: Create the Distribution without aliases and with the default certificate
- Phase 2: After DNS validation completes and the certificate is issued, add the alias and ACM cert
Minimum Phase 1 config:
{
"CallerReference": "your-app-1730000000",
"Comment": "your-app via CF Worker, fronted with WAF",
"Enabled": true,
"Aliases": {"Quantity": 0},
"Origins": {
"Quantity": 1,
"Items": [{
"Id": "cf-worker-origin",
"DomainName": "<worker-name>.<account>.workers.dev",
"CustomOriginConfig": {
"HTTPPort": 80,
"HTTPSPort": 443,
"OriginProtocolPolicy": "https-only",
"OriginSslProtocols": {"Quantity": 1, "Items": ["TLSv1.2"]},
"OriginReadTimeout": 30,
"OriginKeepaliveTimeout": 5
}
}]
},
"DefaultCacheBehavior": {
"TargetOriginId": "cf-worker-origin",
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": {
"Quantity": 7,
"Items": ["GET","HEAD","OPTIONS","PUT","POST","PATCH","DELETE"],
"CachedMethods": {"Quantity": 2, "Items": ["GET","HEAD"]}
},
"Compress": true,
"CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
"OriginRequestPolicyId": "b689b0a8-53d0-40ab-baf2-68738e2966ac"
},
"ViewerCertificate": {"CloudFrontDefaultCertificate": true},
"WebACLId": "$WAF_ARN",
"PriceClass": "PriceClass_200",
"HttpVersion": "http2and3",
"IsIPV6Enabled": true
}
Key points:
OriginProtocolPolicy: https-only: workers.dev is HTTPS onlyCachePolicyId: 4135ea2d-...= AWS managed CachingDisabled (dynamic site; defer to the Worker's cache headers)OriginRequestPolicyId: b689b0a8-...= AllViewerExceptHostHeader (rewrites the Host header when passing to origin; without this, the Cloudflare Worker returns 404 for an unknown hostname)WebACLIdset to the ARN of the WAF created above
aws cloudfront create-distribution \
--distribution-config file://cf-distribution.json
The returned DomainName (e.g., dxxxxxxxxxx.cloudfront.net) becomes the CNAME target in the organization's DNS.
At this point, hitting https://dxxxxxxxxxx.cloudfront.net/ should return the Worker's response via CloudFront. WAF is already active at this stage.
4. Request to Organization DNS Team (Round 1)
Request addition of one CNAME for ACM validation. The existing A record (pointing to the old server) can remain — no impact on site availability.
;; Add (ACM DNS validation CNAME)
_xxxxxxxxxxxxxxx.app.example.org. 86400 IN CNAME _yyyyyyyyyyyyyyy.acm-validations.aws.
Once this record propagates, ACM polls the DNS and the certificate status transitions to ISSUED within a few minutes to tens of minutes.
aws acm describe-certificate \
--region us-east-1 \
--certificate-arn "$CERT_ARN" \
--query 'Certificate.Status' --output text
## → ISSUED means success
5. Add Alias and Certificate to Distribution
After the certificate is issued, update the CloudFront Distribution config to add the alias and cert. The pattern is: get-distribution-config → edit JSON → update-distribution.
(The API calls are verbose, but via AWS Console it's simply Edit → Settings → Alternate domain name (CNAMEs) and Custom SSL certificate.)
6. Request to Organization DNS Team (Round 2, Cutover)
Once CloudFront shows Deployed with the new Aliases, request the final DNS change:
;; Remove
;; app.example.org. 300 IN A <old server IP>
;; Add
app.example.org. 300 IN CNAME dxxxxxxxxxx.cloudfront.net.
A TTL of 300 allows faster rollback if problems arise.
7. Adjust Worker-side Environment
Code inside the Worker that generates canonical URLs or OGP links should reference NEXT_PUBLIC_SITE_URL rather than the request's Host header (standard practice in Next.js). Set this to the production domain and redeploy:
echo -n "https://app.example.org" | wrangler secret put NEXT_PUBLIC_SITE_URL
(For Pages: wrangler pages secret put)
Cost
Estimated monthly cost for CloudFront + WAF at a small public site scale. Actual costs vary with traffic.
| Item | Monthly estimate |
|---|---|
| CloudFront requests | $0.5–2 |
| CloudFront transfer (Asia edge) | $0.5–2 |
| AWS WAF (Web ACL fixed) | $5 |
| AWS WAF (managed rules × 3) | $3 ($1 × 3) |
| AWS WAF requests | $0.60 per 1M requests |
| ACM certificate | Free |
| Estimated total | ~$10/month |
Even at 10M requests/month, the total would stay in the $20–30 range. Slightly cheaper than or comparable to Cloudflare for SaaS Pro at $25/month.
Trade-offs and Caveats
To be straightforward about what is lost by routing through CloudFront:
- Cloudflare's DDoS protection / Bot Fight Mode and other edge defenses no longer apply (replaced by CloudFront WAF)
- The additional hop may add tens of milliseconds of latency (CloudFront edge processing → Cloudflare edge processing → origin)
- Two accounts (AWS and Cloudflare) need to be managed
- WAF Managed Rules false positives (mainly Common Rule Set) require attention — check sampled logs in the dashboard for blocked legitimate requests before going to production
On the other hand, what was gained:
- Ability to use Workers under an externally managed subdomain
- Standard attack blocking via AWS WAF Managed Rules
- Automatic certificate renewal via ACM
- Own server removed from the front path (physical server can be decommissioned in the future)
Closing
This was a fairly narrow problem — "Cloudflare's subdomain acceptance restriction" and "Vercel's exclusive claim" individually have workarounds, but when combined, options run out. If you find yourself in the same dead end, consider borrowing AWS CloudFront as a CDN/WAF layer.
After getting it running, it's apparent that the number of moving parts is significant: Worker / Pages / Tunnel / Service Token / CloudFront / WAF / ACM. Taking the time to clarify "what each layer is responsible for" before starting construction turns out to be faster overall.
Comments
…