2026年6月14日
2026年6月14日
Cloudflare DNS で複数ドメインを管理する方法|Terraform で共通レコードをそろえ、既存レコードを取り込む手順
Cloudflare に置いた複数ドメインの DNS を Terraform で管理するには、全ドメインに共通するレコードを1つのモジュールにまとめ、ドメインの一覧を for_each で回してそのモジュールを呼び出す構成にすると、ドメインが増えても設定がそろい、変更内容を plan で確かめてから反映できます。この記事では、API トークンの準備、ディレクトリ構成、共通レコードとドメイン固有のレコードの書き分け、画面で作った既存レコードの取り込み、運用で守ることを順に説明します。
Cloudflare の Terraform プロバイダは、メジャーバージョン 5 でリソース名や書き方が大きく変わりました(2026年10月現在)。この記事の例はバージョン 5 の書き方に合わせていますが、細部はプロバイダのバージョンごとのドキュメントで確認してください。古い記事やサンプルで見かける cloudflare_record は、バージョン 4 までのリソース名です。本番のゾーンに apply する前に、影響のないテスト用ドメインで動きを確かめることをおすすめします。
なぜ複数ドメインを Terraform で管理するのか
Cloudflare の管理画面は使いやすく、ドメインが数件なら画面で十分に管理できます。しかし、WordPress サイトや予約用のドメインが10件、20件と増えると、次のような問題が出てきます。
- ドメインごとに SPF や DMARC の書き方が微妙に違い、どれが正しいのか分からなくなる
- サーバー移転のときに、同じ A レコードの書き換えを何度も手作業で繰り返す
- 誰がいつ何を変えたのかを、後から追いにくい
Terraform で管理すると、レコードの定義がコードとして Git に残り、変更は plan で差分を確かめてから反映する流れになります。共通の型をモジュールにすれば、新しいドメインの追加は一覧に1行足すだけになります。
手順1:API トークンを用意する
Cloudflare のダッシュボードで API トークンを作成します。グローバル API キーではなく、権限を絞った API トークンを使います。
- 権限は、DNS レコードの編集に必要なもの(ゾーンの DNS の編集)に絞ります。テンプレートや権限の表記は画面の案内に従ってください。
- 対象のゾーンは、Terraform で管理するドメインだけに限定します。
- 必要に応じて、接続元 IP の制限や有効期限を設定します。
トークンはコードに書かず、環境変数で渡します。プロバイダは CLOUDFLARE_API_TOKEN という環境変数を読み取ります。
export CLOUDFLARE_API_TOKEN="your-api-token"
各ドメインのゾーン ID は、ダッシュボードのドメインの概要ページか、API で確認できます。
curl -s -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
"https://api.cloudflare.com/client/v4/zones?name=example.com" | jq -r '.result[0].id'
手順2:ディレクトリ構成を決める
次のような構成にします。
cloudflare-dns/
├── versions.tf # Terraform とプロバイダのバージョン
├── domains.tf # ドメインの一覧とモジュールの呼び出し
├── extra_records.tf # ドメイン固有のレコード
└── modules/
└── standard-records/
└── main.tf # 全ドメイン共通のレコード
versions.tf では、プロバイダのメジャーバージョンを固定します。固定しないと、新しいメジャーバージョンが出たときに書き方の違いで plan が壊れることがあります。
terraform {
required_version = ">= 1.5"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
}
# state は共有ストレージに置く(設定は利用するバックエンドに合わせる)
# backend "s3" { ... }
}
provider "cloudflare" {}
手順3:共通レコードをモジュールにまとめる
全ドメインで同じ形になるレコードを、モジュールに書きます。ここでは、頂点と www を Web サーバーへ向けるレコード、SPF、DMARC を共通の型にしています。
# modules/standard-records/main.tf
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
}
}
}
variable "domain" { type = string }
variable "zone_id" { type = string }
variable "web_ip" { type = string }
variable "spf" { type = string }
variable "dmarc_policy" { type = string }
resource "cloudflare_dns_record" "apex" {
zone_id = var.zone_id
name = var.domain
type = "A"
content = var.web_ip
ttl = 1 # 1 は自動。プロキシする場合は自動になる
proxied = true
}
resource "cloudflare_dns_record" "www" {
zone_id = var.zone_id
name = "www.${var.domain}"
type = "CNAME"
content = var.domain
ttl = 1
proxied = true
}
resource "cloudflare_dns_record" "spf" {
zone_id = var.zone_id
name = var.domain
type = "TXT"
content = "\"${var.spf}\""
ttl = 3600
}
resource "cloudflare_dns_record" "dmarc" {
zone_id = var.zone_id
name = "_dmarc.${var.domain}"
type = "TXT"
content = "\"v=DMARC1; p=${var.dmarc_policy}\""
ttl = 3600
}
レコード名は www.example.com のようにドメイン名を含めた形で書いています。相対名で書いた場合の扱いはプロバイダのバージョンで異なることがあり、apply のたびに差分が出る原因になるため、完全な名前で書いておくと安定します。TXT の値を二重引用符で囲んでいるのも同じ理由で、引用符の有無で差分が出る場合は、plan の表示に合わせて書き方をそろえます。
手順4:ドメインの一覧を作り、for_each で呼び出す
ドメインごとに違う値(ゾーン ID、向け先の IP、メールの扱い)を一覧にし、モジュールを for_each で呼び出します。
# domains.tf
locals {
domains = {
"example.com" = {
zone_id = "your-zone-id-1"
web_ip = "203.0.113.10"
send_mail = true
spf = "v=spf1 include:_spf.example.net ~all"
}
"example.jp" = {
zone_id = "your-zone-id-2"
web_ip = "203.0.113.10"
send_mail = false
spf = "v=spf1 -all"
}
}
}
module "standard" {
source = "./modules/standard-records"
for_each = local.domains
domain = each.key
zone_id = each.value.zone_id
web_ip = each.value.web_ip
spf = each.value.spf
dmarc_policy = each.value.send_mail ? "none" : "reject"
}
メールを送るドメインは DMARC を p=none(監視のみ)から始め、レポートを見てから強めます。メールを送らないドメインは、なりすましに使われないよう SPF を -all、DMARC を reject にしています。
count ではなく for_each をドメイン名のキーで使うのが重要です。count で番号を振ると、一覧の途中のドメインを削除したときに後ろの番号がずれ、関係のないドメインのレコードまで作り直しの対象になってしまいます。
手順5:ドメイン固有のレコードを書き分ける
MX や、外部サービスの所有権確認用の TXT など、ドメインごとに違うレコードは別ファイルにまとめます。
# extra_records.tf
locals {
extra_records = {
"example-com-mx" = {
domain = "example.com"
name = "example.com"
type = "MX"
content = "mail.example.net"
priority = 10
}
"example-com-verify" = {
domain = "example.com"
name = "example.com"
type = "TXT"
content = "\"site-verification=your-verification-code\""
priority = null
}
}
}
resource "cloudflare_dns_record" "extra" {
for_each = local.extra_records
zone_id = local.domains[each.value.domain].zone_id
name = each.value.name
type = each.value.type
content = each.value.content
priority = each.value.priority
ttl = 3600
proxied = false
}
キー(example-com-mx など)は、後から変えないものを付けます。キーを変えると Terraform は別のレコードとみなし、削除と作成をやり直します。構成を整理してアドレスが変わるときは、moved ブロックで移動先を伝えると作り直しを避けられます。
手順6:既存のレコードを取り込む
すでに画面で作ったレコードがあるドメインで、そのまま apply すると、同じレコードを二重に作ろうとしてエラーになるか、重複したレコードができてしまいます。既存のレコードは、先に Terraform の管理下に取り込みます。
レコード ID は API で確認できます。
ZONE_ID="your-zone-id-1"
curl -s -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?per_page=100" \
| jq -r '.result[] | [.id, .type, .name, .content] | @tsv'
Terraform 1.5 以降では、import ブロックで取り込み先と ID を指定できます。ID の書式は「ゾーン ID/レコード ID」です。
import {
to = module.standard["example.com"].cloudflare_dns_record.apex
id = "your-zone-id-1/your-record-id"
}
terraform plan を実行し、取り込んだレコードに「変更なし」と表示されるか、意図した差分だけが出るかを確かめます。値や TTL の違いが出た場合は、コード側を実際の値に合わせるのか、実際の値をコードに合わせて変えるのかを、1件ずつ判断します。レコードが多い場合は、Cloudflare が公開している cf-terraforming という補助ツールで、既存のレコードから設定と取り込み用の記述を生成する方法もあります。プロバイダのバージョンとの対応はツールの説明で確認してください。
取り込み作業の前には、各ゾーンのレコードを API やダッシュボードのエクスポート機能で書き出して控えておきます。
運用で守ること
- 画面での変更と混在させない: Terraform で管理しているレコードを画面で直すと、次の apply で元に戻されます。管理対象のレコードはコードだけで変える、という約束をチームで共有します。
- plan を読んでから apply する: 特に「destroy」や「replace」と表示されたレコードは、意図したものかを確認します。CI で plan の結果をレビューに載せる運用にすると見落としが減ります。
- state を安全な場所に置く: state には管理対象の情報が入ります。共有ストレージとロックを使い、Git には入れません。
- ドメインが多いなら state を分ける: 数十件を1つの state にまとめると、plan に時間がかかり、1回の誤りの影響も大きくなります。顧客ごと、用途ごとなどでディレクトリを分けると、影響範囲を絞れます。
- 反映後は実際の応答を確かめる:
dig +short example.com TXTのように、変更したレコードが期待どおりに返るかを確認します。プロキシしているレコードは Cloudflare の IP が返るのが正常です。
まとめ
- 共通レコードをモジュールにまとめ、ドメインの一覧を
for_eachで回すと、ドメインが増えても設定がそろいます。 - API トークンは DNS の編集と対象ゾーンに絞り、環境変数
CLOUDFLARE_API_TOKENで渡します。 - プロバイダはメジャーバージョンを固定し、バージョン 5 の
cloudflare_dns_recordの書き方に合わせます。レコード名は完全な名前で書くと差分が出にくくなります。 - 既存のレコードは
importブロックで取り込み、plan で差分を確かめてから apply します。 - 画面での変更と混在させず、state は共有ストレージに置き、ドメインが多ければ state を分けます。