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:

  1. Browser resolves app.example.org → gets Cloudflare's Anycast IP
  2. Browser initiates TLS handshake with Cloudflare's edge, presenting app.example.org in the SNI
  3. Cloudflare edge: "This hostname has no registered zone or certificate with us"
  4. 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:

LayerProblem
RoutingCloudflare only accepts hostnames from its own zones or Cloudflare for SaaS registrations (the CDN layer doesn't know where to route it)
TLS certificateNo 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:

MechanismDescription
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 dispatchEdge 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 CloudflareAWS CloudFront
Certificate issuance prerequisiteHostname's zone must be under CloudflareACM DNS validation (zone agnostic)
Custom domain acceptanceCloudflare for SaaS (Pro required)Just add to Distribution Aliases
SNI dispatchZone-management basedBased on listed Aliases
BillingPro $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:

  • AWSManagedRulesCommonRuleSet
  • AWSManagedRulesKnownBadInputsRuleSet
  • AWSManagedRulesAmazonIpReputationList
{
  "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 only
  • CachePolicyId: 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)
  • WebACLId set 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.

ItemMonthly 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 certificateFree
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.