はじめに
私が所属している研究室では、Cloudflare Registrarで独自ドメインを取得し管理しています。このドメインは、Cloudflare TunnelとCloudflare Accessを用いたVPNレスSSH(#6)や、External DNSとCloudflare DNSを利用したサービスの名前解決(#16)などに利用されています。また、現在github.ioドメインでホストしている研究室のホームページにおいて、独自ドメインを使用することも検討しています。
さて、研究室のドメインは私が半年ほど前に私用のCloudflareアカウントで取得しました。よって、引き継ぎを行うには新しいアカウントを作成して別のメンバーを招待し、管理者権限を付与する必要があります。
本記事では、Cloudflare Registrarで登録したドメインをCloudflareアカウント間で移動させる方法と、それに関連してTerraformでCloudflareリソースをIaC化し、GitHub Actionsでワークフローを自動化する方法を紹介します。
- はじめに
- 1. 旧アカウントにおけるCloudflareリソースのIaC
- 2. Cloudflareアカウント間のドメイン移動
- 3. 新アカウントにおけるCloudflareリソースのIaC
- 4. IaCワークフローの自動化
- まとめ
1. 旧アカウントにおけるCloudflareリソースのIaC
Cloudflareの公式ドキュメントによると、アカウント間でドメインを移動させることによって、ソースアカウント上でそのドメインに紐付く構成や設定は失われてしまいます。
The move will result in the loss of all configurations and settings for the domain in the source account.
よって、ドメインの移動が完了してから、ソースアカウントの構成をターゲットアカウントで復元するまでの時間がサービスのダウンタイムになります。サービスは研究室内でのみ提供しているため長いダウンタイムも許容されますが、一部のメンバーは研究活動がサービスに依存しているためダウンタイムは短いに越したことはありません。
Cloudflareリソースは今まで手動で管理していましたが、IaC(Infrastructure as Code)を導入することで一般的には以下のようなメリットが得られます。
Benefits of adopting Infrastructure as Code:
- Reduces cost
- Increases speed of deployments
- Reduces errors
- Improves infrastructure consistency
- Eliminates configuration drift
このタイミングでIaCを導入することにより、ドメイン移動のダウンタイムを最小限に抑えられるだけでなく、研究室メンバーへの引き継ぎも容易になります。研究室サーバーでは既にAnsibleを利用して構成管理(#2、#8、#12)を行なっていますが、今回はIaCツールとしてTerraformを利用してCloudflareのリソースを管理します。
Terraformのバージョン管理には、tfenvの後継であるtenvを利用します。
$ tenv tf install # 省略 Installation of Terraform 1.14.0 successful $ terraform version Terraform v1.14.0 on darwin_arm64
既存のCloudflareリソースをTerraformで管理するためには、Terraform configuration(.tfファイル)とTerraform state(.tfstateファイル)が必要です。一般的には、resourceブロックを記述してから
terraform importコマンドでstateのみ取り込む方法や、importブロックを記述してからgenerate-config-outフラグ付きのterraform planコマンドでresourceブロックを生成する方法があります。
一方でCloudflareはcf-terraformingというツールを提供しており、Cloudflare API経由で情報を取得することでTerraform configurationを生成することができます。
リソースIDを指定する必要がある方法(terraform importコマンドやimportブロック)とは異なり、cf-terraformingは指定したリソースタイプの全リソースについてTerraform configurationを生成できるため、今回のようにIaCに移行するケースではより効率的なツールだと考えました。
まずはCloudflare ProviderのTerraform公式ドキュメントに従ってprovider.tfに以下を記述し、terraform initコマンドでプロジェクトを初期化します。
terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" version = "~> 5" } } } provider "cloudflare" { }
次に、READMEのSupported Resoucesを参考にリソースタイプを指定してコマンドを実行します。
$ export CLOUDFLARE_API_TOKEN='YOUR_CLOUDFLARE_API_TOKEN' $ export CLOUDFLARE_TERRAFORM_BINARY_PATH=$(which terraform) $ export CLOUDFLARE_ACCOUNT_ID='YOUR_CLOUDFLARE_ACCOUNT_ID' # export CLOUDFLARE_ZONE_ID='YOUR_CLOUDFLARE_ZONE_ID' $ cf-terraforming generate --resource-type "cloudflare_account" INFO[0000] Using Terraform binary from explicit path terraform-binary-path=/opt/homebrew/bin/terraform INFO[0000] detected provider registry=registry.terraform.io/cloudflare/cloudflare version=5.13.0 resource "cloudflare_account" "terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0" { name = "Kitsuya Azuma" type = "standard" settings = { abuse_contact_email = null access_approval_expiry = null api_access_enabled = null enforce_twofactor = false user_groups_ui_beta = false } }
CloudflareアカウントのTerraform configurationが標準出力に出力されました。リソース名のxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxの部分は、指定したアカウントIDになっているはずです。
ちなみにAPIトークン/アカウントID/ゾーンIDは、CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID/CLOUDFLARE_ZONE_IDという環境変数の他にtoken/account/zoneフラグでも指定することができますが、環境変数が指定されている場合は環境変数でオーバーライドされます。
リソースタイプはカンマ区切りで複数タイプ指定できますが、リソースタイプ毎に指定すべきIDタイプ(アカウントまたはゾーン)が異なるため、片方のIDを指定すべきリソースタイプでもう片方のIDも指定してしまうとエラー(FATA[0000] --account and --zone are mutually exclusive, support for both is deprecated)になってしまいます。そこで今回は、IDタイプ別にリソースタイプ配列を定義する、以下のようなシェルスクリプトを記述しました。
generate.sh
#!/bin/bash OUTPUT_FILE="generated.tf" if [ -z "$CLOUDFLARE_API_TOKEN" ]; then echo "Error: CLOUDFLARE_API_TOKEN environment variable is not set." exit 1 fi if [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then echo "Error: CLOUDFLARE_ACCOUNT_ID environment variable is not set." exit 1 fi if [ -z "$CLOUDFLARE_ZONE_ID" ]; then echo "Error: CLOUDFLARE_ZONE_ID environment variable is not set." exit 1 fi ACCOUNT_RESOURCES=( "cloudflare_account" "cloudflare_account_member" "cloudflare_account_subscription" "cloudflare_address_map" "cloudflare_calls_sfu_app" "cloudflare_calls_turn_app" "cloudflare_d1_database" "cloudflare_dns_firewall" "cloudflare_dns_zone_transfers_acl" "cloudflare_dns_zone_transfers_peer" "cloudflare_dns_zone_transfers_tsig" "cloudflare_email_routing_address" "cloudflare_email_security_block_sender" "cloudflare_email_security_impersonation_registry" "cloudflare_email_security_trusted_domains" "cloudflare_list" "cloudflare_load_balancer_monitor" "cloudflare_load_balancer_pool" "cloudflare_magic_wan_static_route" "cloudflare_mtls_certificate" "cloudflare_notification_policy" "cloudflare_notification_policy_webhooks" "cloudflare_pages_project" "cloudflare_queue" "cloudflare_r2_bucket" "cloudflare_registrar_domain" "cloudflare_stream" "cloudflare_stream_key" "cloudflare_stream_live_input" "cloudflare_stream_watermark" "cloudflare_stream_webhook" "cloudflare_turnstile_widget" "cloudflare_user" "cloudflare_web_analytics_site" "cloudflare_workers_custom_domain" "cloudflare_workers_for_platforms_dispatch_namespace" "cloudflare_workers_kv_namespace" "cloudflare_zero_trust_access_custom_page" "cloudflare_zero_trust_access_infrastructure_target" "cloudflare_zero_trust_access_key_configuration" "cloudflare_zero_trust_access_policy" "cloudflare_zero_trust_access_tag" "cloudflare_zero_trust_device_default_profile" "cloudflare_zero_trust_device_default_profile_local_domain_fallback" "cloudflare_zero_trust_device_managed_networks" "cloudflare_zero_trust_device_posture_integration" "cloudflare_zero_trust_device_posture_rule" "cloudflare_zero_trust_dex_test" "cloudflare_zero_trust_dlp_dataset" "cloudflare_zero_trust_dns_location" "cloudflare_zero_trust_gateway_certificate" "cloudflare_zero_trust_gateway_policy" "cloudflare_zero_trust_gateway_proxy_endpoint" "cloudflare_zero_trust_gateway_settings" "cloudflare_zero_trust_list" "cloudflare_zero_trust_risk_behavior" "cloudflare_zero_trust_risk_scoring_integration" "cloudflare_zero_trust_tunnel_cloudflared" "cloudflare_zero_trust_tunnel_cloudflared_route" "cloudflare_zero_trust_tunnel_cloudflared_virtual_network" ) ZONE_RESOURCES=( "cloudflare_api_shield_discovery_operation" "cloudflare_api_shield_operation" "cloudflare_api_shield_schema" "cloudflare_api_shield_schema_validation_settings" "cloudflare_argo_smart_routing" "cloudflare_argo_tiered_caching" "cloudflare_authenticated_origin_pulls_certificate" "cloudflare_bot_management" "cloudflare_certificate_pack" "cloudflare_content_scanning_expression" "cloudflare_custom_hostname" "cloudflare_custom_hostname_fallback_origin" "cloudflare_dns_record" "cloudflare_dns_zone_transfers_incoming" "cloudflare_dns_zone_transfers_outgoing" "cloudflare_email_routing_catch_all" "cloudflare_email_routing_dns" "cloudflare_email_routing_rule" "cloudflare_email_routing_settings" "cloudflare_filter" "cloudflare_healthcheck" "cloudflare_keyless_certificate" "cloudflare_leaked_credential_check" "cloudflare_leaked_credential_check_rule" "cloudflare_load_balancer" "cloudflare_logpull_retention" "cloudflare_managed_transforms" "cloudflare_origin_ca_certificate" "cloudflare_page_rule" "cloudflare_page_shield_policy" "cloudflare_rate_limit" "cloudflare_regional_hostname" "cloudflare_regional_tiered_cache" "cloudflare_snippet_rules" "cloudflare_snippets" "cloudflare_spectrum_application" "cloudflare_tiered_cache" "cloudflare_total_tls" "cloudflare_url_normalization_settings" "cloudflare_waiting_room_settings" "cloudflare_web3_hostname" "cloudflare_zone" "cloudflare_zone_cache_reserve" "cloudflare_zone_cache_variants" "cloudflare_zone_dnssec" "cloudflare_zone_lockdown" "cloudflare_zero_trust_device_default_profile_certificates" ) ACCOUNT_OR_ZONE_RESOURCES=( "cloudflare_logpush_job" "cloudflare_ruleset" "cloudflare_waiting_room" "cloudflare_zero_trust_access_application" "cloudflare_zero_trust_access_group" "cloudflare_zero_trust_access_identity_provider" "cloudflare_zero_trust_access_mtls_certificate" "cloudflare_zero_trust_access_mtls_hostname_settings" "cloudflare_zero_trust_access_service_token" "cloudflare_zero_trust_access_short_lived_certificate" "cloudflare_zero_trust_organization" ) echo "Generating configuration to $OUTPUT_FILE ..." echo "--- Account Level Resources ---" for resource in "${ACCOUNT_RESOURCES[@]}"; do echo "Generating $resource ..." CLOUDFLARE_ZONE_ID="" cf-terraforming generate \ --resource-type "$resource" >>"$OUTPUT_FILE" 2>/dev/null done echo "--- Zone Level Resources ---" for resource in "${ZONE_RESOURCES[@]}"; do echo "Generating $resource ..." CLOUDFLARE_ACCOUNT_ID="" cf-terraforming generate \ --resource-type "$resource" >>"$OUTPUT_FILE" 2>/dev/null done echo "--- Account or Zone Level Resources ---" for resource in "${ACCOUNT_OR_ZONE_RESOURCES[@]}"; do echo "Generating $resource ..." CLOUDFLARE_ACCOUNT_ID="" cf-terraforming generate \ --resource-type "$resource" >>"$OUTPUT_FILE" 2>/dev/null done echo "Done! Configuration saved to $OUTPUT_FILE."
シェルスクリプトを実行したところ、合計で600行ほどのTerraform configurationが生成されました。私用のCloudflareアカウントには、研究室用のリソースだけでなく趣味用のリソースも多く存在しています。アカウントIDを指定するリソースタイプ(cloudflare_registrar_domainやcloudflare_zero_trust_access_policyなど)については、生成された趣味用リソースのTerraform configurationのみ目視で削除しました。
ちなみにcf-terraforming importコマンドを実行することで、stateを取り込むためのterraform importコマンドを出力することができます。cf-terraforming importコマンドについては第3章で再登場しますが、今回は新アカウントでのリソース作成が目的であるため、旧アカウントでのstate取り込みは割愛します。
以上で、CloudflareリソースのIaCのための旧アカウント上での準備が完了しました。
2. Cloudflareアカウント間のドメイン移動
Cloudflareで新アカウントを作成したら、上記の公式ドキュメントの手順に従います。
まずは、新アカウントでドメインをWebサイトとして追加し、DNSSECがoffになっていることを確認します。
次に、旧アカウントでManage Domainsページから該当ドメインのManageを選択し、ConfigurationタブのMove to another Cloudflare accountでStartボタンをクリックします。




最後に、新アカウントでManage Domainsページからドメインの移動を許可します。



ネームサーバーの更新まで最大8時間かかると書いてありましたが、hereと書いてあるリンクをクリックしたところ数分程度でセットアップが完了しました。


以上で、Cloudflareアカウント間のドメイン移動が完了しました。
3. 新アカウントにおけるCloudflareリソースのIaC
移動されなかったドメイン以外のサービス・設定について、新アカウントで構成する必要があります。
新アカウント上で既に作成されているリソースのうちTerraformで管理したいリソースについては、第1章で紹介したcf-terraforming generateコマンドを利用してTerraform configurationを生成しつつ、cf-terraforming importコマンドでstateをローカルに取り込みます。cloudflare_accountやcloudflare_account_member、cloudflare_zoneの他にもデフォルトで多くのリソースが作成されていますが、Terraformで管理したい範囲を決めてstateを取り込むのが良いと考えました。
以下は、cloudflare_accountリソースのstateを取り込む例です。
$ export CLOUDFLARE_ACCOUNT_ID='YOUR_CLOUDFLARE_ACCOUNT_ID' $ export CLOUDFLARE_TERRAFORM_BINARY_PATH=$(which terraform) $ export export CLOUDFLARE_API_TOKEN='YOUR_CLOUDFLARE_API_TOKEN' $ cf-terraforming generate --resource-type "cloudflare_account" | tee account.tf INFO[0000] Using Terraform binary from explicit path terraform-binary-path=/opt/homebrew/bin/terraform INFO[0000] detected provider registry=registry.terraform.io/cloudflare/cloudflare version=5.13.0 resource "cloudflare_account" "terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0" { name = "Xxxxxx Xxxxxxxxxx" type = "standard" settings = { abuse_contact_email = null access_approval_expiry = null api_access_enabled = null enforce_twofactor = false user_groups_ui_beta = false } } $ sed -i.bak 's/terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0/main/g' account.tf && rm account.tf.bak $ cf-terraforming import --resource-type "cloudflare_account" INFO[0000] Using Terraform binary from explicit path terraform-binary-path=/opt/homebrew/bin/terraform terraform import cloudflare_account.terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx $ terraform import cloudflare_account.main xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx cloudflare_account.main: Importing from ID "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"... cloudflare_account.main: Import prepared! Prepared cloudflare_account for import cloudflare_account.main: Refreshing state... [id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx] Import successful! The resources that were imported are shown above. These resources are now in your Terraform state and will henceforth be managed by Terraform. $ terraform plan cloudflare_account.main: Refreshing state... [id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx] No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.
cf-terraformingで生成されたリソース名は扱いにくいので、適切なリソース名に変更しています。
新アカウントの既存リソースのうちTerraformで管理したいリソースのstate取り込みが完了したら、第1章で生成した旧アカウントTerraform configurationを参考に、新アカウントで作成したい新規リソース(Cloudflare Tunnel、Cloudflare Access、Cloudflare DNSなど)も記述します。
terraform.tf
terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" version = "~> 5.13.0" } } required_version = "~> 1.14.0" }
provider.tf
provider "cloudflare" { # API token will be read from CLOUDFLARE_API_TOKEN environment variable }
variables.tf
variable "account_name" { type = string description = "The name of the Cloudflare account." } variable "super_admin_emails" { type = list(string) description = "A list of email addresses assigned the Super Administrator role." } variable "domain_name" { type = string description = "The domain name for the Cloudflare zone." } variable "lab_member_emails" { type = list(string) description = "A list of email addresses for lab members." } variable "zero_trust_access_group_name" { type = string description = "The name of the Cloudflare Zero Trust access group." } variable "zero_trust_access_policy_name" { type = string description = "The name of the Cloudflare Zero Trust access policy." } variable "zero_trust_access_policy_session_duration" { type = string description = "Session duration for the Zero Trust access policy." } variable "zero_trust_organization_auth_domain" { type = string description = "The authentication domain for the Cloudflare Zero Trust organization." } variable "zero_trust_organization_name" { type = string description = "The name of the Cloudflare Zero Trust organization." } variable "zero_trust_access_application_name" { type = string description = "The name of the Cloudflare Zero Trust access application." } variable "zero_trust_access_application_domain" { type = string description = "The domain of the Cloudflare Zero Trust access application." } variable "zero_trust_access_application_session_duration" { type = string description = "Session duration for the Zero Trust access application." } variable "zero_trust_tunnel_cloudflared_name" { type = string description = "The name of the Cloudflare Zero Trust tunnel (cloudflared)." } variable "dns_record_name" { type = string description = "The name of the DNS record." }
account.tf
resource "cloudflare_account" "main" { name = var.account_name type = "standard" settings = { abuse_contact_email = null access_approval_expiry = null api_access_enabled = null enforce_twofactor = true user_groups_ui_beta = false } } data "cloudflare_account_roles" "all" { account_id = cloudflare_account.main.id } locals { super_admin_role_id = one([ for role in data.cloudflare_account_roles.all.result : role.id if role.name == "Super Administrator - All Privileges" ]) } resource "cloudflare_account_member" "super_admin" { for_each = toset(var.super_admin_emails) account_id = cloudflare_account.main.id email = each.value roles = [local.super_admin_role_id] lifecycle { ignore_changes = [status] } }
zone.tf
resource "cloudflare_zone" "main" { name = var.domain_name type = "full" account = { id = cloudflare_account.main.id } } resource "cloudflare_zone_dnssec" "main" { status = "disabled" zone_id = cloudflare_zone.main.id }
dns.tf
resource "cloudflare_dns_record" "polaris" { content = "${cloudflare_zero_trust_tunnel_cloudflared.main.id}.cfargotunnel.com" # name = cloudflare_zero_trust_access_application.main.domain name = var.dns_record_name proxied = true ttl = 1 type = "CNAME" zone_id = cloudflare_zone.main.id }
zero_trust.tf
resource "cloudflare_zero_trust_organization" "main" { account_id = cloudflare_account.main.id auth_domain = var.zero_trust_organization_auth_domain name = var.zero_trust_organization_name } resource "cloudflare_zero_trust_access_group" "main" { name = var.zero_trust_access_group_name zone_id = cloudflare_zone.main.id include = [ for email in var.lab_member_emails : { email = { email = email } } ] } resource "cloudflare_zero_trust_access_policy" "main" { account_id = cloudflare_account.main.id decision = "allow" name = var.zero_trust_access_policy_name session_duration = var.zero_trust_access_policy_session_duration include = [{ group = { id = cloudflare_zero_trust_access_group.main.id } }] } resource "cloudflare_zero_trust_access_application" "main" { app_launcher_visible = true domain = var.zero_trust_access_application_domain name = var.zero_trust_access_application_name session_duration = var.zero_trust_access_application_session_duration type = "self_hosted" zone_id = cloudflare_zone.main.id destinations = [{ type = "public" uri = var.zero_trust_access_application_domain }] policies = [{ id = cloudflare_zero_trust_access_policy.main.id }] } resource "cloudflare_zero_trust_tunnel_cloudflared" "main" { account_id = cloudflare_account.main.id config_src = "cloudflare" name = var.zero_trust_tunnel_cloudflared_name } resource "cloudflare_zero_trust_tunnel_cloudflared_config" "main" { account_id = cloudflare_account.main.id tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.main.id config = { ingress = [{ hostname = cloudflare_zero_trust_access_application.main.domain service = "ssh://localhost:22" }, { service = "http_status:404" }] } }
第1章で旧アカウントリソースのTerraform configurationを生成しましたが、ファイル分割や変数定義、命名規則などを考える過程で結局Cloudflare ProviderのTerraform公式ドキュメントを参照する必要がありました。
VPNレスSSH(#6)に必要なリソースの中では、cloudflare_zero_trust_tunnel_cloudflared_configというリソースタイプのみcf-terraformingがサポートしていませんでした。よって、旧アカウント側でterraform importコマンドを実行してterraform.tfstateファイルの内容を確認しながら、新アカウントでterraform applyコマンドが成功するまで試行錯誤しました*1。
以上で、新アカウントにおけるCloudflareリソースのIaCは概ね達成できました。
4. IaCワークフローの自動化
これまではlocal backendを利用してstateをローカルに保存していましたが、複数人のインフラ管理者で一貫したstate管理を実現するため、ワークフローを自動化してremote backendを利用する方針に変更します。
研究室では既にAnsible Playbookの実行にGitHub Actionsを利用しているため、Terraformワークフローの自動化についてもGitHub Actionsを採用し、CI/CD基盤の運用を統一しています。
上記の公式ドキュメントではGitHub ActionsによるTerraformの自動化にHCP Terraformが利用されていますが、研究室の規模ではstateのバージョニングやRBAC、監査ログといったHCP Terraformの恩恵を受けにくく、運用コストが増加してしまいます。
今回はTerraformのremote backendとして、Cloudflare R2(S3互換のオブジェクトストレージ)を使用します。
上記の公式ドキュメントに従って、R2バケットを作成してからオブジェクト読み書き用のAPIトークンを作成し、以下のようにterraformブロックに記述します。
terraform {
+ backend "s3" {
+ bucket = "<YOUR_BUCKET_NAME>"
+ key = "<YOUR_PATH_TO_STATE>"
+ region = "auto"
+ skip_credentials_validation = true
+ skip_metadata_api_check = true
+ skip_region_validation = true
+ skip_requesting_account_id = true
+ skip_s3_checksum = true
+ use_path_style = true
+ access_key = "<YOUR_R2_ACCESS_KEY>"
+ secret_key = "<YOUR_R2_ACCESS_SECRET>"
+ endpoints = { s3 = "https://<YOUR_ACCOUNT_ID>.r2.cloudflarestorage.com" }
+ }
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.13.0"
}
}
required_version = "~> 1.14.0"
}
terraform.tfファイルを編集したら、ローカルでterraform initコマンドを実行してlocal stateからremote stateへのマイグレーションを行います。-migrate-stateオプションでは既存のローカルstateを新しいs3 backendにコピーされますが、-reconfigureオプションでは既存のstateが無視されるので注意が必要です。
$ terraform init -migrate-state Initializing the backend... 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 Successfully configured the backend "s3"! Terraform will automatically use this backend unless the backend configuration changes. Initializing provider plugins... - Reusing previous version of cloudflare/cloudflare from the dependency lock file - Using previously-installed cloudflare/cloudflare v5.13.0 Terraform has been successfully initialized! # 省略

terrraform.tfstateというオブジェクトが作成される
以上で、remote backendへの移行が完了しました。この変更をコミットする前に、terraform.tfのaccess_keyとsecret_keyの2行は必ず削除してください。ローカルでterraform planコマンドを実行するメンバーのために、以下のような.env.templateをコミットするのがオススメです。
export CLOUDFLARE_API_TOKEN= export AWS_ACCESS_KEY_ID= export AWS_SECRET_ACCESS_KEY=
次に、GitHub Actionsを利用してワークフローを自動化します。
AWSやGoogle Cloudとは異なりCloudflareではGitHub ActionsのOIDC連携が利用できないため、R2バケット読み書き用のアクセスキーとTerraform実行用のAPIトークンをGitHub Actionsのシークレットに登録する必要があります。

GitHub Actions上でのTerraform CLIのセットアップには以下のアクションを利用しました。
.github/workflows/terraform.yaml
name: terraform on: push: branches: - main paths: - 'terraform/**' pull_request: paths: - 'terraform/**' permissions: contents: read pull-requests: write concurrency: group: terraform-${{ github.ref }} cancel-in-progress: false jobs: terraform: runs-on: ubuntu-latest defaults: run: working-directory: ./terraform env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} steps: - uses: actions/checkout@v5 - uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.14.0" - name: Terraform fmt id: fmt run: terraform fmt -check continue-on-error: true - name: Terraform Init id: init run: terraform init -input=false - name: Terraform Validate id: validate run: terraform validate -no-color - name: Terraform Plan id: plan if: github.event_name == 'pull_request' run: terraform plan -no-color -input=false continue-on-error: true - uses: actions/github-script@v7 if: github.event_name == 'pull_request' env: PLAN: "terraform\n${{ steps.plan.outputs.stdout }}" with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | // 1. Retrieve existing bot comments for the PR const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }) const botComment = comments.find(comment => { return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style') }) // 2. Prepare format of the comment const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\` #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\` #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\` <details><summary>Validation Output</summary> \`\`\`\n ${{ steps.validate.outputs.stdout }} \`\`\` </details> #### Terraform Plan 📖\`${{ steps.plan.outcome }}\` <details><summary>Show Plan</summary> \`\`\`\n ${process.env.PLAN} \`\`\` </details> *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Working Directory: \`./terraform\`, Workflow: \`${{ github.workflow }}\`*`; // 3. If we have a comment, update it, otherwise create a new one if (botComment) { github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body: output }) } else { github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: output }) } - if: steps.plan.outcome == 'failure' run: exit 1 - name: Terraform Apply if: github.ref == 'refs/heads/main' && github.event_name == 'push' run: terraform apply -auto-approve -input=false
paths:やworking-directory:を指定しているのは、リポジトリが以下のようなディレクトリ構造になっているためです。
.
├── README.md
├── .github
│ └── workflows
│ ├── ansible-on-merge.yaml
│ ├── ansible-on-pr.yaml
│ └── terraform.yaml
├── ansible
│ ├── ...
└── terraform
├── ...
./terraformディレクトリ以下に意図的な変更を加えると、ワークフローがトリガーされます。

terraform planコマンドの出力は▶︎ Show Planというトグルを開いて確認できます。
以上で、Terraformワークフローの自動化が完了しました。
まとめ
本記事では、Cloudflare間でドメインを移動する方法から、CloudflareリソースをIaC化してワークフローを自動化する方法まで紹介しました。
取り組んだ感想として、アカウント間のドメイン移動は驚くほどスムーズに進みましたが、IaC周りでそれなりに時間がかかってしまいました。また、cf-terraformingは非常に便利なツールだと感じたので今後も積極的に活用していきたいです。
研究室の現在のインフラ構成は以下の図の通りです。

メジャーなOSSを利用しつつ、GitOpsとIaCを組み合わせてインフラ管理することで、研究室メンバーへの引き継ぎはかなり容易になったと思います。
*1:Web UIでは自動的に設定されますが、Terraformではservice = "http_status:404"のように最後のIngressルールが全てのURLにマッチする必要があります(エラー:{"success":false,"errors":[{"code":1056,"message":"Bad Configuration: Validation failed: The last ingress rule must match all URLs (i.e. it should not have a hostname or path filter)\n"}],"messages":[],"result":null})



















































