本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

本記事ではホスト名や ID を一般化したプレースホルダで表記しています。例:

  • 本番ホスト名: app.example.org
  • Worker URL: <worker-name>.<account>.workers.dev
  • CloudFront 配信ドメイン: dxxxxxxxxxx.cloudfront.net
  • WAF 名: your-app-waf

TL;DR

  • 自分の管理外の組織 DNS にあるサブドメイン(例として app.example.org と表記)を、Cloudflare Workers / Pages で配信したかった
  • Cloudflare Free / Pro / Business は外部ドメインのサブドメインを単独 zone として受け入れない(NS 委任不可、CNAME クレーム不可)
  • Cloudflare for SaaS は CNAME ベースで受け入れ可能だが Pro $25/月 以上が必須
  • Vercel は CNAME ベースで対応可能だが、対象サブドメインが既に別アカウントに排他クレームされていて利用不可、というケースに当たった
  • 最終的に AWS CloudFront + AWS WAF を前段に挟み、Origin として workers.dev を指す構成にした
  • DNS 管理者への依頼は「ACM 検証 CNAME 追加 → 後日 A → CNAME 切替」の 2 段階
  • 月額目安: 小規模トラフィックで約 $10

背景

ある Next.js アプリを Cloudflare Workers に移行する作業を進めていました。アプリ自体は @opennextjs/cloudflare で Worker 化し、Elasticsearch 接続も Cloudflare Tunnel + Cloudflare Access の Service Token に切替えて、ローカル動作・workers.dev での疎通までは順調に進みました。

詰まったのは本番ドメインへの切替フェーズでした。本番 URL は外部組織の管理するサブドメインで、

  • ユーザに見える URL の文字列は変更不可
  • 親ゾーンは外部組織の DNS チームが管理
  • DNS レコードの個別変更は依頼可能(一定の調整は必要)

という状況です。

試した選択肢

案 A: Cloudflare に NS 委任してもらう

最初に検討したのは「サブドメインだけ Cloudflare に NS 委任してもらう」案でした。実際、自前の DNS では別のサブドメインを AWS Route 53 や Azure DNS に NS 委任していて、これは DNS プロトコルとしては標準的な機能です。

ところが Cloudflare ダッシュボードでこのサブドメインを「Add Site」で登録しようとすると、

Please ensure you are providing the root domain and not any subdomains
(e.g., example.com, not subdomain.example.com)

と弾かれました。後で調べると Cloudflare 製品の仕様として、サブドメインを単独 zone にできるのは Enterprise プランのみという制約があるようです。Free / Pro / Business では root ドメイン (apex) しか zone にできません。DNS プロトコル上は可能でも、Cloudflare 側がそういう登録を受け付けない、というビジネス上の判断のようです。

案 B: Cloudflare for SaaS の Custom Hostname

Cloudflare の有料プラン Pro 以上で使える「Cloudflare for SaaS」を契約すれば、CNAME 認証ベースで外部ドメインを受け入れることができます。これは元来「SaaS 事業者が顧客のカスタムドメインを自社サービスで終端する」用途のための機能です。

費用は Pro $25/月 から、加えてホスト数が増えると $0.10/月/host が乗ります。1 ホストだけなら約 $25/月。今回のような小規模なサイトでは継続的なコスト負担が見合わないと判断しました。

案 C: Vercel に乗せる

Next.js なので Vercel との親和性は高く、Custom Domain も CNAME 認証ベースで受け入れられます。実際、移行元のアプリには vercel.json も残っていました。

ところが Vercel に当該サブドメインを Custom Domain として登録しようとしたところ、「他のアカウント / プロジェクトで既にクレーム済」というエラーで弾かれました。Vercel は Custom Domain の排他クレーム方式を採用していて、過去に同ドメインを試した別プロジェクトがどこかに残っていると、新しいプロジェクトでは登録できません。クレーム解除には所有権を DNS で証明 (TXT レコード) してサポートに依頼する手間が発生します。

案 D: 自前サーバでリバースプロキシ + 自前 WAF

既存の自前サーバで Traefik が動いていたので、そこに当該サブドメイン宛のリバースプロキシを 1 本追加して、内部的に workers.dev に転送する案も検討しました。

これは無料で実現できるものの、

  • 自前サーバが単一障害点として残る (Cloudflare Workers の冗長性メリットを失う)
  • WAF / DDoS 防御をホストレベルで自前運用する必要がある (ModSecurity + OWASP CRS など)
  • エッジキャッシュも効かない

といったデメリットがあり、移行の本来の目的 (自前サーバ依存の解消、エッジ配信) に逆行します。

案 E: AWS CloudFront + AWS WAF を前段に置く

最終的に採用したのがこの案です。Cloudflare Workers にトラフィックが届く手前に AWS CloudFront を挟みます。CloudFront はカスタムドメイン受け入れに ACM 証明書 + Distribution Aliases (CNAMEs) の組み合わせを使っていて、ゾーン管理権限とは独立しているので外部サブドメインでも問題なく動作します。

なぜ「CNAME を Workers に直接向ける」では動かないのか

技術的な確認です。仮に組織側の DNS で

app.example.org.  CNAME  <worker-name>.<account>.workers.dev.

と設定したとします。流れは:

  1. ブラウザが app.example.org を解決 → Cloudflare の Anycast IP が返る
  2. ブラウザが Cloudflare の edge に TLS handshake、SNI に app.example.org を載せる
  3. Cloudflare edge: 「このホスト名のルートも証明書もうちでは登録されていない」
  4. レスポンスとして 1014 (CNAME Cross-User Banned) または 530 を返す。あるいは workers.dev のデフォルト証明書を返却 → ブラウザは cert mismatch エラー

つまり止まる理由は 2 つあります。

問題
ルーティングCloudflare は自分のゾーン or Cloudflare for SaaS 登録済のホスト名しか受け付けない (CDN 層で振り分け先が分からない)
TLS 証明書当該ホスト名用の証明書を発行する仕組みがない (Cloudflare のドメイン所有確認は zone を前提にしている)

CloudFront はなぜ動くか

CloudFront は同じことを以下の 3 つの仕組みで実現しています。

仕組み内容
ACM 証明書 (DNS validation)DNS で TXT/CNAME を 1 本立てるだけで、所有権が確認でき次第 ACM が証明書を発行する。ゾーン管理権限は不要
Distribution の Aliases (CNAMEs) 設定Distribution に「このホスト名宛の SNI を受け付ける」と宣言する
SNI dispatchedge は SNI を見て、登録された alias から該当 distribution に振り分ける

これらは Free 枠 (リクエスト課金) で全て利用可能です。Cloudflare との違いを整理すると:

Cloudflare 通常AWS CloudFront
証明書発行の前提ホスト名のゾーンが Cloudflare 配下ACM の DNS validation で発行 (zone 不問)
カスタムドメイン受入Cloudflare for SaaS (Pro 必須)Distribution の Aliases に登録するだけ
SNI dispatchゾーン管理ベースAliases に列挙されたホスト名で振り分け
課金Pro $25/月〜リクエスト/転送量ベース、小規模で月数ドル

要するに「住所証明をゾーン単位でやるか、証明書単位でやるか」の設計の違いです。CloudFront 側は証明書単位で済むので、ゾーンを誰が管理しているかに関係なく動きます。

構成

最終的な構成です。

Visitor → app.example.org
         ↓ DNS CNAME (組織側 DNS で設定)
AWS CloudFront (WAF 適用)
         ↓ Origin (HTTPS, Host: workers.dev)
Cloudflare Worker (Next.js / OpenNext)
         ↓ Service Token (CF-Access-Client-*)
Cloudflare Tunnel
         ↓ docker network 内
Elasticsearch (自前サーバの内部)

構築手順

すべて AWS CLI で実行可能です。前提として aws sts get-caller-identity が通る IAM クレデンシャルがあること、および Worker 側 (<worker-name>.<account>.workers.dev のような実体) が既に動いていることが必要です。

1. ACM 証明書をリクエスト

CloudFront が使う証明書は必ず us-east-1 リージョンにある必要があります。

aws acm request-certificate \
  --region us-east-1 \
  --domain-name app.example.org \
  --validation-method DNS \
  --tags Key=Project,Value=your-app

返ってきた CertificateArn を控えます。次にこの証明書の DNS validation 用レコード (CNAME) を取得します。

aws acm describe-certificate \
  --region us-east-1 \
  --certificate-arn "$CERT_ARN" \
  --query 'Certificate.DomainValidationOptions[].ResourceRecord.[Name,Type,Value]' \
  --output text

出力例:

_xxxxxxxxxxxxxxx.app.example.org.  CNAME  _yyyyyyyyyyyyyyy.acm-validations.aws.

この 1 行を、組織側の DNS に追加してもらう必要があります。

2. WAF Web ACL を作成 (CLOUDFRONT scope)

CloudFront に attach する WAF は scope=CLOUDFRONT で us-east-1 に作ります。AWS Managed Rules を 3 つ有効化:

  • 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"
      }
    }
    // 他 2 ルールも同様
  ],
  "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

注意: Description フィールドの正規表現は ^[\w+=:#@/\-,\.][\w+=:#@/\-,\.\s]+[\w+=:#@/\-,\.]$ なので、括弧などは弾かれます。最初に括弧を入れてエラーになりました。

3. CloudFront Distribution を作成

ここで一手間あります。ACM 証明書がまだ PENDING_VALIDATION の状態だと CloudFront に attach できません (InvalidViewerCertificate エラー)。組織側の DNS が動かないと validation が完了しないので、

  • 第 1 弾として alias なし・default 証明書で Distribution を作成
  • 後ほど DNS 検証完了 + 証明書発行が完了してから alias と ACM cert を追加

という 2 段階に分けます。

第 1 弾の minimum 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
}

ポイント:

  • OriginProtocolPolicy: https-only: workers.dev は HTTPS のみなので
  • CachePolicyId: 4135ea2d-... = AWS マネージドの CachingDisabled (動的サイトなので Worker 側のキャッシュヘッダに任せる)
  • OriginRequestPolicyId: b689b0a8-... = AllViewerExceptHostHeader (Host ヘッダを書き換えて origin に渡す。これがないと Cloudflare Worker が「未知のホスト名」として 404 を返してしまう)
  • WebACLId に先ほど作成した WAF の ARN を指定
aws cloudfront create-distribution \
  --distribution-config file://cf-distribution.json

返ってきた DomainName (例: dxxxxxxxxxx.cloudfront.net) が後で組織側 DNS に CNAME で指定するターゲットになります。

この時点で https://dxxxxxxxxxx.cloudfront.net/ を叩くと、CloudFront 経由で Worker のレスポンスが返ってくるはずです。WAF はもうこの段階から効いています。

4. 組織 DNS 担当への依頼 (第 1 段階)

ACM 検証用の CNAME を 1 本追加してもらいます。既存の A レコード (=旧サーバを指している) はそのまま残して大丈夫です。サイトの稼働には影響しません。

;; 追加 (ACM DNS 検証用 CNAME)
_xxxxxxxxxxxxxxx.app.example.org. 86400 IN CNAME _yyyyyyyyyyyyyyy.acm-validations.aws.

この 1 本が反映されると、ACM が DNS をポーリングして数分〜十分程度で証明書のステータスが ISSUED になります。

aws acm describe-certificate \
  --region us-east-1 \
  --certificate-arn "$CERT_ARN" \
  --query 'Certificate.Status' --output text
## → ISSUED が返れば成功

5. Distribution に alias と cert を追加

証明書が発行されたら、CloudFront Distribution の設定を更新して alias と cert を入れます。get-distribution-config で現状を取得 → JSON を編集 → update-distribution で適用、という形になります。

(具体的な API 呼び出しは長くなるので割愛しますが、AWS Console からだと Edit → Settings → Alternate domain name (CNAMEs) と Custom SSL certificate を入力するだけです)

6. 組織 DNS 担当への依頼 (第 2 段階, カットオーバー)

CloudFront が新しい Aliases で Deployed になったら、最後の DNS 切替を依頼します。

;; 削除
;; app.example.org.  300  IN  A  <旧サーバ IP>

;; 追加
app.example.org.    300  IN  CNAME  dxxxxxxxxxx.cloudfront.net.

TTL を 300 にしておくと、問題が出たときの切戻しが速くなります。

7. Worker 側 env の調整

Worker 内のコードが canonical URL や OGP リンクを生成するときに、リクエストの Host ヘッダではなく NEXT_PUBLIC_SITE_URL を見るような設計になっているはずです (一般的な Next.js では)。これを本番ドメインに設定して再デプロイしておきます。

echo -n "https://app.example.org" | wrangler secret put NEXT_PUBLIC_SITE_URL

(Pages の場合は wrangler pages secret put)

コスト

CloudFront + WAF の構成で、想定される月額の目安です。あくまで小規模公開サイトの試算で、トラフィック量で変動します。

項目月額目安
CloudFront リクエスト$0.5〜2
CloudFront 転送量 (アジアエッジ)$0.5〜2
AWS WAF (Web ACL fixed)$5
AWS WAF (managed rule × 3)$3 ($1 × 3)
AWS WAF リクエスト$0.6 / 100 万リクエスト
ACM 証明書無料
合計目安約 $10/月

仮にトラフィックが 1000 万リクエスト/月になっても $20〜30 程度の範囲に収まります。Cloudflare for SaaS の Pro $25/月よりやや安い、または同等という位置です。

トレードオフと留意点

正直に書いておくと、CloudFront 経由にすることで失うものもあります。

  • Cloudflare の DDoS / Bot Fight Mode などのエッジ防御は効かなくなる (CloudFront 側の WAF に置き換える形)
  • 経路が増える分のレイテンシは数十 ms 増える可能性がある (CloudFront edge で処理 → Cloudflare edge で処理 → origin)
  • AWS と Cloudflare の 2 つのアカウントを管理する必要がある
  • WAF Managed Rules の誤検知 (主に Common Rule Set) には注意。本番投入前にダッシュボードのサンプルログで blocked された正常リクエストがないか確認するのが安全

逆に得たもの:

  • 外部組織管理のサブドメインで Workers を使えた
  • AWS WAF の Managed Rules による定型攻撃の遮断
  • ACM の自動更新で証明書管理が自動化される
  • 自前サーバをフロントから外せた (将来的に物理サーバを縮退可能)

おわり

「Cloudflare のサブドメイン受入れ制限」と「Vercel のクレーム排他」というそれぞれ単独だと回避策があるけれど、両方が組み合わさると行き場がなくなる、という割と狭い問題のための記録です。同じ袋小路に入った人がいたら、AWS CloudFront を CDN/WAF レイヤーとして借りる選択肢も検討してみてください。

実際に動かしてみると、Worker / Pages / Tunnel / Service Token / CloudFront / WAF / ACM と関係する登場人物が多いので、構築前に「どの層が何を担当しているか」を一度整理してから着手したほうが結果として速いと感じました。