概要 #
DNS レコードは Cloudflare のダッシュボードで手作業で管理していました。レコードは7件で、困ってはいません。ただ別の検証でサブドメインを委任することになったのを機に、Terraform での管理に切り替えました。
Terraform 化するなら state の置き場所も要ります。DNS は Cloudflare、state は別のクラウドという構成になる。資格情報も課金も監視も、2か所に分かれてしまいます。
そこで S3 互換の API を持つ Cloudflare のオブジェクトストレージの R2 を使いました。
既存レコードの取り込みから state の移行までを、試してみました。
対象読者 #
- Terraform で
applyまで実行したことがある terraform.tfstateが何かを知っている
扱わないもの #
- Terraform 自体の入門
- Cloudflare の Workers、Pages、WAF、Zero Trust
- 委任先となる GCP 側の作り方
DNS レコードと R2 バケットに絞ります。
検証すること #
- state ロック(
use_lockfile)は R2 で効くか(Cloudflare 側に記載がない) - ロックの解放に要る
DeleteObjectは、R2 のトークン権限に含まれるか - 資格情報とアカウント識別子を、リポジトリに残さずに済むか
-generate-config-outが生成した設定は、そのままコミットしてよいか- 既存レコードの取り込みで、運用中の DNS に変更を出さずに済むか
前提環境 #
- Cloudflare でドメインを1つ管理していること(Free プランで足りる)
- R2 を有効化できること(無料枠内でも支払い方法の登録が要る)
- 検証時のバージョン:Terraform v1.14.3、
cloudflare/cloudflarev5.24.0(2026-08-24 リリース)
出力はすべて、実際に自分のアカウントに対して実行した結果です。対象ゾーンはレコード7件です。
注意: v5 は v4 と互換性がない #
プロバイダの v5 は v4 からの全面書き換えで、DNS レコードのリソース名が変わっています。
| v4 | v5 | |
|---|---|---|
| リソース名 | cloudflare_record |
cloudflare_dns_record |
v4 前提の記事やサンプルをそのまま持ってくると動きません。 検索で出てくる情報の多くはまだ v4 のものです。参照する記事がどちらを前提にしているか、required_providers のバージョン指定を先に見てください。
構成 #
ディレクトリを2つに分けます。
terraform/
├── bootstrap/ state を置く R2 バケット。最初に一度だけ
└── cloudflare-dns/ DNS レコード
state は R2 の1バケットに置き、prefix で分けます。
terraform-state (R2)
├── bootstrap/terraform.tfstate
└── cloudflare-dns/terraform.tfstate
注意: 資格情報は2種類ある #
ここが一番つまずきました。Cloudflare API トークンと R2 のアクセスキーは別の仕組みで、互いに代用できません。
| Cloudflare API トークン | R2 アクセスキー | |
|---|---|---|
| 用途 | プロバイダ(DNS・バケットの操作) | backend(state の読み書き) |
| 方式 | Bearer トークン | S3 互換の署名鍵(SigV4) |
| 作成する画面 | ダッシュボード > My Profile > API Tokens > Create Token | ダッシュボード > R2 > API > Manage API tokens |
| 環境変数 | CLOUDFLARE_API_TOKEN |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
どちらもダッシュボードで作ります。作る場所が違うので、片方を作って満足しないよう注意してください。R2 側は、バケットを作ったあとでないとスコープを絞れません。
API トークンで R2 バケットを「作る」ことはできますが、そのバケットに「state を書く」ことはできません。前者は Cloudflare の REST API、後者は S3 互換 API で、認証方式が違うためです。
Cloudflare なのに AWS_* なのはなぜか
#
R2 は S3 互換 API を提供しており、Terraform 側はそれを backend "s3" で扱います。この backend が読む環境変数は、HashiCorp のドキュメントで名指しされています。
access_key について。
This can also be sourced from the
AWS_ACCESS_KEY_IDenvironment variable, AWS shared credentials file (e.g.~/.aws/credentials), or AWS shared configuration file (e.g.~/.aws/config).
endpoints.s3 について。
Custom endpoint URL for the AWS S3 API. This can also be sourced from the environment variable
AWS_ENDPOINT_URL_S3
つまり AWS_* という名前は backend の仕様として決まっています。
export AWS_ACCESS_KEY_ID='...' # 中身は Cloudflare R2 のキー
export AWS_SECRET_ACCESS_KEY='...'
export AWS_ENDPOINT_URL_S3="https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
紛らわしいので CF_R2_ACCESS_KEY_ID などに変えたくなりますが、変えると Terraform が見つけられません。
Error: No valid credential sources found
AWS のアカウントは一切関与しません。AWS_ENDPOINT_URL_S3 で向き先を R2 に固定しているので、AWS へ接続することもありません。名前だけの問題です。
公式の backend 例をそのまま使わない #
Cloudflare の手順は、資格情報を backend ブロックに直書きする例になっています。
terraform {
backend "s3" {
# 公式手順の書き方。この記事では採用しない
access_key = "<YOUR_R2_ACCESS_KEY>"
secret_key = "<YOUR_R2_ACCESS_SECRET>"
}
}
シークレット情報をコード内に直接記載することは、セキュリティ上好ましくありません。
そのため、シークレット情報を環境変数で Terraform に渡すコードにしています。
terraform {
backend "s3" {
bucket = "terraform-state"
key = "cloudflare-dns/terraform.tfstate"
region = "auto"
# https://<HOST>/<BUCKET> の形式にする。R2 はバケット名をホスト名の
# 先頭に付ける仮想ホスト形式に対応していない
use_path_style = true
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
skip_s3_checksum = true
use_lockfile = true
}
}
skip_* が5つ並びます。どれも AWS にしか無い仕組みを呼びに行くのを止めるためです。 R2 には無いので、止めないと失敗します。
| 設定 | 止めるもの | R2 で必要な理由 |
|---|---|---|
skip_credentials_validation |
STS API での資格情報の検証 | R2 に STS が無い |
skip_region_validation |
リージョン名の妥当性検証 | auto は AWS のリージョン名ではない |
skip_requesting_account_id |
アカウント ID の問い合わせ | R2 に IAM / STS / メタデータ API が無い |
skip_metadata_api_check |
EC2 メタデータ API の利用 | EC2 上で動かしていない |
skip_s3_checksum |
アップロード時のチェックサム付与 | S3 互換 API が対応していないことがある |
公式ドキュメントの記述もそれぞれ対応しています。
skip_credentials_validation。
Skip credentials validation via the STS API. Useful for testing and for AWS API implementations that do not have STS available.
skip_requesting_account_id。
Useful for AWS API implementations that do not have the IAM, STS API, or metadata API.
skip_s3_checksum。
Do not include checksum when uploading S3 Objects. Useful for some S3-Compatible APIs.
プロバイダ側も同じ考え方で、引数を書きません。
# CLOUDFLARE_API_TOKEN 環境変数から読む
provider "cloudflare" {}
バケットを作る前に、そのバケットへ state は置けない #
R2 バケットを Terraform で作りたいが、その Terraform の state を置く先が、まさに今から作るバケットです。 存在しないバケットは backend に指定できません。
1回目の apply:バケットはまだ無い → backend に指定できない → ローカル state
2回目以降 :バケットができている → backend に指定できる
そこで bootstrap ディレクトリだけ、2段階に分けます。
- backend を書かずに
applyする。state はローカルにできる - できたバケットを backend に指定し、1で作ったローカル state をそこへ移す
移したあとはローカルに state が残らず、バケットが自分自身の state を保持する形になります。
このバケットを消すと、そこに入っている全環境の state が消えます。Terraform から見た「現在の状態」を失うので、作り直しても既存リソースを認識できません。誤って destroy しないよう prevent_destroy を付けます。
resource "cloudflare_r2_bucket" "state" {
account_id = var.cloudflare_account_id
name = var.state_bucket_name
location = var.bucket_location
# InfrequentAccess は無料枠の対象外。state は毎回読むので Standard
storage_class = "Standard"
lifecycle {
# このバケットには全環境の state が入る。destroy しようとすると
# Terraform がエラーで止まる。消すときは、この行を外す判断が要る
prevent_destroy = true
}
}
R2 は先に有効化しておく #
権限が正しくても、アカウントで R2 が有効化されていないと弾かれます。
Please enable R2 through the Cloudflare Dashboard. (code 10042)
権限設定を疑って時間を使いましたが、原因はこれでした。ダッシュボードの R2 Object Storage から有効化します。
無料枠に収まる場合でも支払い方法の登録が要ります。 サブスクリプションの追加という扱いだからです。
無料枠は次のとおりです(Pricing · Cloudflare R2 docs)。
| 項目 | 無料枠 |
|---|---|
| ストレージ | 10 GB-month / 月 |
| Class A 操作 | 100万回 / 月 |
| Class B 操作 | 1,000万回 / 月 |
| Egress | 無料 |
state は通常 KB〜数MB で、今回のバケットは移行直後で 1,366 バイトでした。無料枠にはかなりの余裕があります。
同ページに書かれているとおり、無料枠は Standard のみで Infrequent Access は対象外です。 storage_class = "Standard" を明示しているのはこのためです。
なお PutObject や ListObjects は Class A、GetObject は Class B に数えられます。terraform plan を1回打つたびに数回ずつ消費しますが、月100万回に対しては誤差です。
ローカル state を R2 へ移す #
バケットができたので、backend を指定して移行します。手順は3つです。
1. R2 のアクセスキーを作り、環境変数に入れる
ダッシュボード > R2 > API > Manage API tokens を開きます。「アカウント API トークン」を作り、権限は Object Read & Write、対象は作ったバケットのみに絞る。
export AWS_ACCESS_KEY_ID='...'
export AWS_SECRET_ACCESS_KEY='...'
export AWS_ENDPOINT_URL_S3="https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
2. backend.tf を置く
前掲の backend "s3" ブロックを、key = "bootstrap/terraform.tfstate" にして置きます。
3. 移行する
terraform init -migrate-state
既存の state を新しい backend へ複製してよいか聞かれるので yes と答えます。
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "s3" backend. No existing state was found in the newly
configured "s3" backend. Do you want to copy this state to the new "s3"
backend? Enter "yes" to copy and "no" to start with an empty state.
Enter a value: yes
移行が終わると、ローカルの terraform.tfstate は 0 バイトになり、移行前の内容が terraform.tfstate.backup に残ります。
-rw-rw-r-- 1 <ユーザー> <ユーザー> 0 Sep 5 20:23 terraform.tfstate
-rw-rw-r-- 1 <ユーザー> <ユーザー> 1366 Sep 5 20:23 terraform.tfstate.backup
最後に一致を確認します。
$ terraform plan
No changes. Your infrastructure matches the configuration.
自動化するなら -force-copy が要る
#
この手順をスクリプトにすると、確認の入力が無いので落ちます。
Enter a value: ╷
│ Error: Error asking for confirmation: EOF
-force-copy を付けると yes と答えたことになります。
terraform init -migrate-state -force-copy
移行先に state があると上書きになります。 自動化するなら、まだ移行していないこと(backend.tf がまだ無いこと)を先に確認してください。
state ロックは R2 で動くのか #
use_lockfile は Terraform 1.10 で入った S3 ネイティブのロックです。条件付き書き込み(If-None-Match)で .tflock を作ります。DynamoDB を使う方式は非推奨になりました。
Locking can be enabled via S3 or DynamoDB. However, DynamoDB-based locking is deprecated and will be removed in a future minor version.
R2 は条件付き書き込みに対応しているので前提は満たします。ただし Cloudflare の Remote R2 backend の手順に use_lockfile の記載がありません。 動くとは言い切れないので、実測しました。
terraform plan を4本同時に実行します。
for i in 1 2 3; do
( terraform plan -lock-timeout=0s ) &
done
terraform plan -lock-timeout=0s
wait
結果は 1本成功、3本が拒否でした。
Error: Error acquiring the state lock
Error message: operation error S3: PutObject, https response error
StatusCode: 412, RequestID: , HostID: , api error PreconditionFailed: At
least one of the pre-conditions you specified did not hold.
Lock Info:
ID: 3510e8a7-0606-8081-0116-21979747348b
Path: terraform-state/bootstrap/terraform.tfstate
Operation: OperationTypePlan
Who: <ユーザー>@<ホスト>
412 PreconditionFailed が返っています。If-None-Match の条件付き書き込みがそのまま効いており、想定した仕組みで排他できています。終了後に .tflock は残りませんでした。
DeleteObject の権限が要る
#
ロックは .tflock というオブジェクトを置くことで表現されます。取得は PutObject、解放はそのオブジェクトを消すこと、つまり S3 API の DeleteObject です。
HashiCorp のドキュメントも3つの権限を挙げています。
If
use_lockfileis set, s3:GetObject, s3:PutObject, and s3:DeleteObject are required on the lock file.
DeleteObject が無いとロックは掛かるが外れず、一度実行したら次から毎回 force-unlock が要る状態になります。
一方、R2 API トークンの「Object Read & Write」の説明はこうです。
Allows the ability to read, write, and list objects in specific buckets
削除に触れていません。 含まれていなければ、ロックを取得できても解放できないという厄介な壊れ方をします。S3 互換 API を直接呼び出して確かめました。
| 操作 | 結果 |
|---|---|
| ListObjects | HTTP 200 |
| PutObject | HTTP 200 |
| GetObject | HTTP 200 |
| DeleteObject | HTTP 204 |
| 削除後の GetObject | HTTP 404 |
| ListBuckets | HTTP 403 |
削除できています。ListBuckets が 403 なのは、バケットを絞った Object 権限として正しい挙動です。バケットの一覧は Admin 権限の操作にあたります。
なお R2 API トークンには2種類あります。「ユーザー API トークン」はユーザーが組織を離れると無効になるため、「アカウント API トークン」を選びます。
既存レコードを取り込む #
import ブロックには、レコードごとの ID が要ります。ID はダッシュボードの画面には出ないので、API から引きます。
まずゾーン ID を取ります。
export CLOUDFLARE_API_TOKEN='...'
ZONE_ID=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=example.com" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["result"][0]["id"])')
そのゾーンのレコードを一覧します。
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?per_page=100" \
| python3 -c '
import json, sys
for r in json.load(sys.stdin)["result"]:
print(r["type"], r["name"], r["id"])'
CNAME example.com 90f6a8cb...
CNAME www.example.com 21c43654...
MX example.com 7982f763...
MX example.com 616d879d...
MX example.com a93f05c3...
TXT example.com 0d4d1c27...
TXT cf2024-1._domainkey.example.com 3daac6ea...
レコードが100件を超える場合は page を送ってページングしてください。 result_info.total_pages に総ページ数が入ります。
この一覧を見ながら import ブロックを書きます。
# CNAME example.com proxied
import {
to = cloudflare_dns_record.cname_root
id = "${local.zone_id}/90f6a8cb..."
}
import ブロックの id は plan 時に評価できればよいので、データソース由来の値を使えます。 ゾーン ID を直書きせずに済みます。
data "cloudflare_zones" "this" {
name = var.zone_name
}
locals {
zone_id = one(data.cloudflare_zones.this.result).id
}
to に書くリソース名は自分で決めます。同じ名前のレコードが複数あると衝突するので、連番を振りました。MX が3件あるので mx_root、mx_root_2、mx_root_3 です。
DKIM のように先頭が数字になる名前も、種別を前に付ければ識別子として成立します。
cf2024-1._domainkey.example.com → txt_cf2024_1_domainkey
設定を生成する #
import ブロックを書いても、対応する resource ブロックが無ければ apply できません。7件分を手で書き起こすのは大変なので、Terraform に書かせます。
terraform plan -generate-config-out=dns_records.tf
-generate-config-out は、import ブロックがあって設定が無いリソースについて、HCL を書き起こす機能です。 Terraform 1.5 で入りました。出力先には存在しないファイル名を指定します。既存ファイルを指定するとエラーになります。
ただし公式に実験的機能と明記されています。
Configuration generation is available in Terraform v1.5 as an experimental feature. Later minor versions may contain changes to the formatting of generated configuration and behavior of the
terraform plancommand using the-generate-config-outflag.
生成される内容も、あくまで推測だと書かれています。
Terraform’s best guess at the appropriate value for each resource argument
そのまま使うものではありません。 実際、次の節の問題がありました。
生成された設定をそのままコミットしてはいけない #
ここが一番の落とし穴でした。生成された設定は、全リソースにゾーン ID を直書きします。
# __generated__ by Terraform from "<ゾーンID>/<レコードID>"
resource "cloudflare_dns_record" "txt_root" {
comment = null
content = "..."
data = null
name = "example.com"
priority = null
private_routing = null
proxied = false
settings = {
}
tags = []
ttl = 1
type = "TXT"
zone_id = "<ゾーンID>"
}
これでは backend からアカウント ID を追い出した意味がありません。生成コメントにも入っているので、両方を落とします。
resource "cloudflare_dns_record" "txt_root" {
content = "..."
name = "example.com"
proxied = false
ttl = 1
type = "TXT"
zone_id = local.zone_id
}
null と空のコレクションも消しました。未設定と等価なので、消しても plan は No changes. のままです。
生成物は API の応答を写したもので、意図の表現にはなっていません。 読み替えが要る箇所もあります。
ttl = 1
これは1秒ではありません。
Setting to 1 means ‘automatic’.
取り込みの結果 #
plan で確認します。
Plan: 7 to import, 0 to add, 0 to change, 0 to destroy.
0 to add, 0 to change, 0 to destroy であることが重要です。 ここに変更が出るなら、生成された設定と実物がずれています。運用中の DNS なので、そのまま apply すると事故になります。
Apply complete! Resources: 7 imported, 0 added, 0 changed, 0 destroyed.
もう一度 plan を実行して、コードと実物が一致していることを確かめます。
No changes. Your infrastructure matches the configuration.
imports.tf は一度きりのものなので、役目を終えたら削除します。
つまずきどころ #
プロキシされたレコードは dig の結果が一致しない
#
このゾーンには A レコードがないにもかかわらず、dig は IP を返します。
$ dig +short A example.com
172.67.x.x
104.21.x.x
apex が proxied な CNAME になっているためです。Cloudflare がプロキシの IP を返しています。 terraform plan が No changes. でも dig の結果とは一致しないので、これを不整合と勘違いしないようにします。
サブドメインを委任すると Cloudflare を通らなくなる #
外部のネームサーバへ委任すると、そのサブドメインは Cloudflare を通らなくなります。 CDN もセキュリティ機能もかかりません。
用途によっては、これが必要な性質になります。委任せずプロキシ有効のまま A レコードを置くとどうなるか。curl で見えるのは Cloudflare の証明書で、委任先のものではありません。
なお「Subdomain setup」(サブドメインを独立したゾーンとして持つ機能)は Enterprise 限定です。NS レコードによる外部委任はそれとは別物で、プラン制限はありません。
Universal SSL は1階層までしか覆わない #
Universal SSL certificates cover your root domain (for example, example.com) and first-level subdomains (for example, www.example.com).
app.gcp.example.com のような2階層は対象外で、Free プランでは有効な証明書が出ません。ワイルドカードも1階層だけです。サブドメインは無料でいくつでも作れますが、TLS が付いてくるのは1階層までというのが実態です。
レコード数の上限はゾーンの作成日で変わる #
Free プランの上限は、ゾーンの作成が 2024-09-01 より前なら 1,000、以降なら 200 です。Email Routing などが自動で作る TXT / MX もこの枠を消費します。
記事の投稿が2022年から続いているので古いゾーンだろうと考えていましたが、確認すると違いました。
zone_created_on = "2025-12-10T10:47:03Z"
上限は200でした。 ドメインの取得時期とゾーンの作成日は別物です。出力に入れておくと、後から迷いません。
まとめ #
Cloudflare だけで DNS も state も完結しました。リポジトリにはアカウント識別子も資格情報も残っていません。
やってみると、公式ドキュメントに書かれていないことと、書かれているとおりにやると危ないことが両方出てきました。
実測して分かったことを並べます。
| 項目 | 公式の記載 | 実測 |
|---|---|---|
use_lockfile を R2 で使う |
Cloudflare 側に記載なし | 動く。412 で排他される |
| Object Read & Write に削除が入るか | 記載なし | 入る。DELETE が 204 |
ListBuckets |
— | 403(バケット限定の権限として正しい) |
| 生成設定のゾーン ID | — | 全リソースに直書きされる |
| Free のレコード上限 | 作成日で 1,000 / 200 | このゾーンは200 |
危ないのは次の2つです。
- 公式の backend 例は、アクセスキーを設定ファイルに直書きしている
-generate-config-outが生成する設定に、ゾーン ID が全リソース分埋め込まれる
どちらも動いてしまうので、動作確認だけでは気づけません。 生成された設定は必ず読む、という一点に尽きます。
use_lockfile については、Cloudflare 側に記載がないままです。仕様として保証されたわけではないので、バージョンが上がったら同じ確認をやり直します。同時実行を1回試すだけなので、手間はかかりません。
参考資料 #
- Remote R2 backend · Cloudflare Terraform docs
- Pricing · Cloudflare R2 docs
- S3 API compatibility · Cloudflare R2 docs
- R2 API tokens · Cloudflare R2 docs
- Delegate subdomains · Cloudflare DNS docs
- Limitations for Universal SSL · Cloudflare SSL/TLS docs
- FAQ · Cloudflare DNS docs
- Backend Type: s3 · Terraform
- Import · Terraform
- Generating configuration · Terraform
- cloudflare_dns_record · Terraform Registry
- cloudflare_r2_bucket · Terraform Registry