シリーズ目次
| 回 | タイトル | 状態 |
|---|---|---|
| 第1回 | Terraform 入門 — 基礎と最初の一歩 | 公開済み |
| 第2回 | AWS 構築 — VPC/ECS/RDS/ALB をコードで作る | 公開済み |
| 第3回 | Cloudflare 連携と CI/CD パイプライン構築(本記事) | 本記事 |
| 第4回 | 運用・保守・スケールアップ | 近日公開 |
はじめに
第2回では AWS 側の構築(VPC / ECS Fargate / RDS / ALB)を Terraform でコード化し、ALB の DNS 名を直接叩けばアプリが動く状態まで進めました。ただし、この状態ではまだ課題が残っています。
-
本番ドメインが割り当たっていない —
*.ap-northeast-1.elb.amazonaws.comという長い URL しかない - HTTP(S) の証明書検証が完了していない — 第2回で ACM に DNS 検証レコードを手動で登録するよう書きましたが、コードに落とせていない
- API とフロントエンドが別オリジンになっている — CORS / Cookie / CSP の問題が山積み
-
デプロイが手作業 —
docker push→ ECS コンソールから手動更新
本記事ではこの 4 つをまとめて解消します。ゴールは「コードを main ブランチに push したら自動でフロント・バックエンドともに本番へデプロイされ、https://yourdomain.example/ で動作している」状態です。
最重要の設計判断(再掲)
このシリーズの構成では Cloudflare を全リクエストの入口にした同一オリジン設計 を採用しています。フロントエンドは Cloudflare Pages(Nuxt SSR)に置き、/api/** と /ws だけ AWS ALB へプロキシします。
ユーザー ──── HTTPS ────▶ Cloudflare (DNS + CDN + WAF)
│
┌────────────┴─────────────┐
│ パスで振り分け │
▼ ▼
/api/**, /ws それ以外
┌───────────────┐ ┌──────────────────┐
│ AWS ALB │ │ Cloudflare Pages │
│ ↓ │ │ (Nuxt SSR) │
│ ECS Fargate │ └──────────────────┘
│ (Spring Boot) │
└───────────────┘
これにより次の問題がすべて発生しません。
- CORS のプリフライト設定(
Access-Control-Allow-Credentials等) - 認証 Cookie の
SameSite緩和(Strict → Noneへの変更) - CSP の
connect-src拡張
開発中のコードが SameSite=Strict のままほぼ無修正で動きます。このシリーズを通じて最も重要な設計判断です。
1. Cloudflare アカウントとドメインの準備
1-1. ネームサーバーの切替
ドメインを取得したレジストラの管理画面で、ネームサーバーを Cloudflare が指定する 2 つのアドレスに変更します。Cloudflare ダッシュボードでドメインを追加すると「このネームサーバーに変更してください」と表示されるので、その値を使います。
DNS の TTL の関係で反映には最大 24〜48 時間かかりますが、実際は数分〜1 時間以内に切り替わることがほとんどです。
1-2. API トークンの発行(最小権限で作る)
Terraform から Cloudflare を操作するには API トークンが必要です。Global API Key は使いません。漏洩した場合の影響範囲が広すぎるためです。
Cloudflare ダッシュボードの「マイプロフィール → API トークン → トークンを作成」から、以下のスコープだけを持つトークンを作成します。
| 権限 | 理由 |
|---|---|
| Zone / DNS / 編集 | DNS レコードの作成・更新 |
| Zone / Zone Settings / 編集 | WAF・Rate Limiting の設定 |
| Account / Cloudflare Pages / 編集 | Pages プロジェクトの作成・デプロイ |
| Account / Workers Scripts / 編集 | Workers スクリプトのデプロイ |
「ゾーンリソース」は「特定のゾーン」→「自分のドメイン」に絞ります。全ゾーンへのアクセスは不要です。
1-3. Terraform への渡し方
発行したトークンを tfvars ファイルやコードに直書きしてはいけません。環境変数で渡します。
export CLOUDFLARE_API_TOKEN="発行したトークンの値"
export CLOUDFLARE_ACCOUNT_ID="Cloudflare ダッシュボードの URL に含まれる 32 桁のID"
Terraform の Cloudflare provider はこれらの環境変数を自動で読み取ります。
2. Terraform で Cloudflare を管理する
2-1. Cloudflare provider の追加
第2回で作った versions.tf に Cloudflare provider を追加します。
# versions.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.0"
}
}
}
provider "cloudflare" {
# CLOUDFLARE_API_TOKEN 環境変数から自動読み取り
# ここに api_token を直書きしない
}
terraform init を再実行すると Cloudflare provider がダウンロードされます。
2-2. 変数の定義
# variables.tf(既存ファイルに追記)
variable "cloudflare_zone_id" {
description = "Cloudflare のゾーン ID(ドメインの管理画面で確認)"
type = string
}
variable "cloudflare_account_id" {
description = "Cloudflare のアカウント ID"
type = string
}
variable "domain_name" {
description = "本番ドメイン(例: yourdomain.example)"
type = string
}
# terraform.tfvars(git 管理する。シークレットは含めない)
cloudflare_zone_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
cloudflare_account_id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
domain_name = "yourdomain.example"
2-3. ALB への CNAME を作成する
# cloudflare_dns.tf
resource "cloudflare_record" "app" {
zone_id = var.cloudflare_zone_id
name = "@" # ルートドメイン(yourdomain.example)
content = aws_lb.app.dns_name # 第2回で作った ALB の DNS 名
type = "CNAME"
proxied = true # オレンジ雲 ON — Cloudflare 経由でプロキシされる
ttl = 1 # proxied=true のとき ttl=1(自動)が正しい値
}
proxied = true にするのがポイントです。これにより ALB の IP アドレスが外部から隠れ、Cloudflare の WAF・DDoS 防御・CDN が有効になります。
2-4. ACM 証明書の DNS 検証レコードを Terraform で張る
第2回では ACM の DNS 検証レコードを手動で登録するよう記載していました。これを Terraform でコード化します。
# acm_validation.tf
# 第2回で作った ACM 証明書の検証レコードを Cloudflare に自動作成する
resource "cloudflare_record" "acm_validation" {
for_each = {
for dvo in aws_acm_certificate.app.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
value = dvo.resource_record_value
type = dvo.resource_record_type
}
}
zone_id = var.cloudflare_zone_id
name = each.value.name
content = each.value.value
type = each.value.type
proxied = false # 検証レコードはプロキシしない
ttl = 60
}
# ACM 証明書の検証完了を待つリソース
resource "aws_acm_certificate_validation" "app" {
certificate_arn = aws_acm_certificate.app.arn
validation_record_fqdns = [for record in cloudflare_record.acm_validation : record.hostname]
}
これで terraform apply が ACM の検証レコードを Cloudflare に作成し、証明書の発行完了まで待機するようになります。初回の apply は証明書の発行待ち(最大 5〜10 分)で止まりますが、正常な動作です。
3. /api と /ws のルーティング
3-1. Workers でパスルーティングする
/api/** と /ws を AWS ALB に振り分け、それ以外を Cloudflare Pages に渡すためのパスルーティングは Cloudflare Workers で実装します。十数行の fetch ハンドラーで完結します。
// workers/router/index.js
export default {
async fetch(request, env) {
const url = new URL(request.url)
// /api/** と /ws は AWS ALB(Spring Boot)へ転送
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
const targetUrl = new URL(request.url)
targetUrl.hostname = env.API_ORIGIN // ALB の DNS 名(環境変数で注入)
targetUrl.protocol = 'https:'
// WebSocket の Upgrade ヘッダーを含むリクエストもそのまま通す
return fetch(targetUrl.toString(), request)
}
// それ以外は Cloudflare Pages のアセットへ
return env.ASSETS.fetch(request)
},
}
env.API_ORIGIN に ALB の DNS 名(第2回で作った aws_lb.app.dns_name)を設定します。
3-2. Workers の設定ファイル
Workers の設定は wrangler.toml で管理します。
# workers/router/wrangler.toml
name = "app-router"
main = "index.js"
compatibility_date = "2024-01-01"
[vars]
API_ORIGIN = "your-alb-xxxxxxxx.ap-northeast-1.elb.amazonaws.com"
本番の API_ORIGIN は Secrets として管理することもできます(公開情報であれば vars のままで問題ありません)。
3-3. Terraform と wrangler — どちらで管理するか
Workers のデプロイ手段は 2 つあります。
| 手段 | 向いているケース |
|---|---|
wrangler CLI(wrangler deploy) |
小規模・シンプルな Workers。CI から叩くだけなら最も手軽 |
Terraform(cloudflare_worker_script) |
他の Cloudflare リソースと一元管理したい場合。状態管理が Terraform state に集約される |
シリーズのゴールである「IaC で全体を管理」の観点では、Terraform に寄せるのが理想です。ただし Workers スクリプトのコード自体(JS ファイル)は Terraform の外に置くことになるため、「インフラ設定は Terraform、コードのデプロイは wrangler」と分けるチームも多いです。
本記事では CI/CD の章(第7章)で wrangler を使う方針をとります。
3-4. WebSocket の注意点
Cloudflare はアイドル状態の WebSocket 接続を約 100 秒で切断します。これはアプリ側のハートビートで対処します。
Spring Boot(STOMP)側では configureMessageBroker でハートビート間隔を設定します:
// WebSocketConfig.java
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue")
.setHeartbeatValue(new long[]{10000, 10000}); // 10秒間隔
}
フロントエンド(SockJS / STOMP.js)側でも対応する設定を追加します(公式ドキュメントの heartbeat オプションを参照)。
100 秒未満のハートビートを設定しておけば、Cloudflare の切断タイムアウトに引っかかることはありません。
4. フロントエンド(Nuxt 3)を Pages にデプロイする
4-1. Nuxt の Nitro preset を変更する
Cloudflare Pages(Workers ランタイム)向けにビルドするには、Nitro の preset を変更します。
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
preset: 'cloudflare_module',
},
// ...
})
4-2. ランタイム互換性の確認
Cloudflare Workers は Node.js とは異なる workerd というランタイムで動作します。Node.js のネイティブモジュール(fs、path、child_process など)はそのままでは動きません。
確認方法は実際にビルドして動かすのが一番確実です:
npm run build
npx wrangler pages dev .output/
ローカルで wrangler pages dev を使うと workerd 上で実際に動作確認できます。
切り分けの指針:
-
nuxt/image、@vueuse/coreなど Vue 系ライブラリ → 通常問題なし -
sharp(画像リサイズ)、bcryptなど Native Add-on → workerd では動かない - 重い Node.js ライブラリを
server/配下で使っている場合 → 要確認
コンテナ SSR に退避する判断基準:
workerd で動かないライブラリが「代替なしに必須」であれば、Nuxt をコンテナ(ECS Fargate)で動かし、Cloudflare を純粋に CDN/WAF として使う構成に切り替えます。この場合、Workers での /api ルーティングではなく、ALB の前段で Cloudflare の Origin Rules を使ってパスベースの振り分けを設定します。詳細は公式ドキュメントを参照してください。
4-3. ハイブリッドレンダリング — SSR は「公開ページだけ」でいい
ここで一度立ち止まって、「そもそもどのページに SSR が必要か」を整理します。
SSR が効くのは 検索エンジンと SNS ボットに中身を見せる必要があるページだけです。SNS の OGP プレビューボットは JavaScript を実行しないため、SPA(クライアント描画)のページは URL を共有してもタイトルやサムネイルが出ません。一方、ログイン後の画面は検索エンジンに見せる必要がなく、SPA で何も困りません。
Nuxt は routeRules でページ単位に描画方式を切り替えられます:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 公開ページ(SEO / OGP が必要)→ SSR
'/': {},
'/public/**': {},
'/discover': {},
// ログイン後のアプリ画面 → SPA(サーバー処理ゼロ)
'/dashboard/**': { ssr: false },
'/settings/**': { ssr: false },
},
})
このハイブリッド構成には実利が2つあります:
- workerd 互換性の検証範囲が縮む — 4-2 の互換性確認は「SSR で実行されるコード」に対してだけ必要です。SSR を公開ページに限定すれば、ログイン後画面でしか使わない重いライブラリは検証対象から外れます
- エッジの実行コストが減る — SPA ページは静的アセット配信だけになり、Workers の実行時間を消費しません
「全ページ SSR か、全ページ SPA か」の二択ではなく、集客導線(公開ページ)だけ SSR、アプリ本体は SPA が、個人開発の Web サービスでは多くの場合の正解です。
4-4. wrangler で手動デプロイ(動作確認用)
npm run build
npx wrangler pages deploy .output/ --project-name=my-app
初回はプロジェクトが自動作成されます。本番ドメインの割り当ては Cloudflare ダッシュボードの「Pages → Custom domains」から設定します。
CI による自動デプロイは第6章で扱います。
5. エッジのセキュリティをコードで管理する
Cloudflare のセキュリティ設定も Terraform で管理します。設定をコードに落としておくことで、「いつ・誰が・何を変更したか」が git の履歴で追跡できます。
5-1. WAF マネージドルールの有効化
Cloudflare の WAF には OWASP ルールセット等のマネージドルールが用意されています。
# cloudflare_security.tf
resource "cloudflare_ruleset" "managed_waf" {
zone_id = var.cloudflare_zone_id
name = "マネージド WAF ルール"
description = "Cloudflare マネージドルールセット"
kind = "zone"
phase = "http_request_firewall_managed"
rules {
action = "execute"
action_parameters {
id = "efb7b8c949ac4650a09736fc376e9aee" # Cloudflare Managed Ruleset の ID
}
expression = "true"
description = "Cloudflare マネージドルールセットを適用"
enabled = true
}
}
ルールセット ID は Cloudflare のドキュメントを参照してください(プランによって利用可能なルールセットが異なります)。
5-2. Rate Limiting ルール
アプリ内のレートリミッタ(Spring の Bucket4j や Caffeine ベースのもの)は単一サーバーのインメモリカウンタです。将来コンテナを複数台に増やすと、実効上限がコンテナ台数倍に緩んでしまいます。エッジ(Cloudflare)にもレートリミットを置くことで、サーバー台数に依存しない多層防御になります。
# cloudflare_security.tf(続き)
resource "cloudflare_ruleset" "rate_limiting" {
zone_id = var.cloudflare_zone_id
name = "レートリミットルール"
description = "ログイン・登録エンドポイントの過剰リクエスト防御"
kind = "zone"
phase = "http_ratelimit"
# ログインエンドポイントのレートリミット
rules {
action = "block"
action_parameters {
response {
status_code = 429
content_type = "application/json"
content = "{\"error\":\"Too Many Requests\"}"
}
}
ratelimit {
characteristics = ["ip.src"]
period = 60 # 60 秒間
requests_per_period = 10 # 10 リクエストを超えたらブロック
mitigation_timeout = 300 # 5 分間ブロック
}
expression = "(http.request.uri.path eq \"/api/v1/auth/login\")"
description = "ログインエンドポイントのレートリミット"
enabled = true
}
# ユーザー登録エンドポイントのレートリミット
rules {
action = "block"
action_parameters {
response {
status_code = 429
content_type = "application/json"
content = "{\"error\":\"Too Many Requests\"}"
}
}
ratelimit {
characteristics = ["ip.src"]
period = 3600 # 1 時間
requests_per_period = 5 # 5 リクエストを超えたらブロック
mitigation_timeout = 3600
}
expression = "(http.request.uri.path eq \"/api/v1/auth/register\")"
description = "ユーザー登録エンドポイントのレートリミット"
enabled = true
}
}
5-3. Bot Fight Mode
ボット対策は Terraform の cloudflare_bot_management または Cloudflare ダッシュボードで設定します。Free プランでは「Bot Fight Mode」が利用できます(Super Bot Fight Mode は有料プランから)。
resource "cloudflare_bot_management" "app" {
zone_id = var.cloudflare_zone_id
fight_mode = true # Bot Fight Mode を有効化(Free プラン)
}
注意: Bot Fight Mode を有効にすると、一部の正規クローラー(Googlebot 等)も一時的にブロックされる場合があります。詳細な挙動は公式ドキュメントを確認してください。
6. CI/CD パイプライン — 本記事の山場
コードを main に push したら自動でデプロイされる仕組みを作ります。
6-1. 設計の考え方
2 種類のパイプラインを分離して管理します。
| パイプライン | トリガー | 内容 |
|---|---|---|
| インフラ用 | PR 作成・更新 / main マージ |
Terraform plan/apply |
| アプリ用 |
main マージ |
docker build → ECR push → ECS 更新 / Pages デプロイ |
分離する理由は、インフラ変更のサイクルとアプリ変更のサイクルが異なるためです。毎回のコードデプロイのたびに terraform apply が走っても構いませんが、Terraform の plan 結果を人間がレビューしてからマージするフローを入れるには、インフラ用 PR を独立させた方が運用しやすいです。
6-2. GitHub OIDC → AWS IAM ロール連携
長期アクセスキーを GitHub Secrets に置かないのが現代のベストプラクティスです。GitHub OIDC を使うと、ワークフロー実行時に GitHub が一時的な認証情報を発行し、AWS はその JWT を検証してロールを引き受けます。アクセスキーの有効期限管理・ローテーションが不要になります。
# github_oidc.tf
# GitHub の OIDC プロバイダーを AWS に登録
data "tls_certificate" "github" {
url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
}
# GitHub Actions が引き受けるロール
resource "aws_iam_role" "github_actions" {
name = "github-actions-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
# あなたのリポジトリからのみ許可
"token.actions.githubusercontent.com:sub" = "repo:your-github-username/your-repo:*"
}
}
}
]
})
}
# デプロイに必要な最小権限ポリシーをアタッチ
resource "aws_iam_role_policy" "github_actions_deploy" {
name = "github-actions-deploy-policy"
role = aws_iam_role.github_actions.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["ecr:GetAuthorizationToken"]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"ecr:BatchGetImage",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
]
Resource = aws_ecr_repository.app.arn
},
{
Effect = "Allow"
Action = [
"ecs:DescribeTaskDefinition",
"ecs:RegisterTaskDefinition",
"ecs:UpdateService",
"ecs:DescribeServices",
]
Resource = "*"
},
{
Effect = "Allow"
Action = ["iam:PassRole"]
Resource = aws_iam_role.ecs_task_execution.arn
},
]
})
}
6-3. インフラ用ワークフロー — PR で plan、main マージで apply
「plan の結果を必ず人間が読んでからマージする」という運用を実現するワークフローです。これはインフラのコードレビューと同じです。
# .github/workflows/terraform.yml
name: Terraform
on:
pull_request:
paths:
- 'infra/**'
- '.github/workflows/terraform.yml'
push:
branches: [main]
paths:
- 'infra/**'
permissions:
id-token: write # OIDC トークンの取得に必要
contents: read
pull-requests: write # PR コメントの投稿に必要
jobs:
terraform:
name: Terraform Plan / Apply
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra/
steps:
- uses: actions/checkout@v4
- name: AWS 認証(OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: ap-northeast-1
- name: Terraform のセットアップ
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~> 1.5"
- name: terraform init
run: terraform init
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: terraform fmt チェック
run: terraform fmt -check
- name: terraform validate
run: terraform validate
- name: terraform plan(PR 時のみ)
if: github.event_name == 'pull_request'
id: plan
run: terraform plan -no-color -out=tfplan
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# plan 結果を PR コメントに貼る
- name: PR に plan 結果をコメント
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const output = `#### Terraform Plan 結果
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\`
*このコメントは自動生成されました。内容を確認してからマージしてください。*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
- name: terraform apply(main マージ時のみ)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
GitHub Secrets に設定する値:
| シークレット名 | 値 |
|---|---|
AWS_OIDC_ROLE_ARN |
前節で作った IAM ロールの ARN |
CLOUDFLARE_API_TOKEN |
第1章で発行したトークン |
Terraform の状態ファイル(terraform.tfstate)は S3 バックエンドに置きます(第2回で設定済みの前提)。ローカルの tfstate をリポジトリにコミットしてはいけません。
6-4. アプリ用ワークフロー — バックエンド(ECS デプロイ)
# .github/workflows/deploy-backend.yml
name: バックエンドデプロイ
on:
push:
branches: [main]
paths:
- 'backend/**'
- '.github/workflows/deploy-backend.yml'
permissions:
id-token: write
contents: read
jobs:
deploy:
name: Docker ビルド → ECR push → ECS 更新
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: AWS 認証(OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: ap-northeast-1
- name: ECR ログイン
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Docker イメージのビルドと push
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
REPOSITORY: my-app-backend
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG ./backend
docker tag $REGISTRY/$REPOSITORY:$IMAGE_TAG $REGISTRY/$REPOSITORY:latest
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
docker push $REGISTRY/$REPOSITORY:latest
- name: ECS タスク定義の更新
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: infra/ecs-task-definition.json
container-name: app
image: ${{ steps.login-ecr.outputs.registry }}/my-app-backend:${{ github.sha }}
- name: ECS サービスのデプロイ
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: my-app-service
cluster: my-app-cluster
wait-for-service-stability: true
6-5. アプリ用ワークフロー — フロントエンド(Pages デプロイ)
# .github/workflows/deploy-frontend.yml
name: フロントエンドデプロイ
on:
push:
branches: [main]
paths:
- 'frontend/**'
- '.github/workflows/deploy-frontend.yml'
jobs:
deploy:
name: Nuxt ビルド → Cloudflare Pages デプロイ
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Node.js のセットアップ
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: 依存パッケージのインストール
working-directory: frontend/
run: npm ci
- name: Nuxt ビルド
working-directory: frontend/
run: npm run build
env:
NUXT_PUBLIC_API_BASE: "" # 同一オリジン構成では空文字(相対パス)
- name: Cloudflare Pages へデプロイ
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy frontend/.output/ --project-name=my-app
GitHub Secrets に追加する値:
| シークレット名 | 値 |
|---|---|
CLOUDFLARE_ACCOUNT_ID |
Cloudflare アカウント ID |
6-6. もっと UI が欲しい場合
上記の手動 CI/CD でも十分ですが、「plan 結果をもっと見やすく」「ドリフト検知を自動化したい」という場合は既製のツールを検討してください。
-
Atlantis: セルフホスト型の Terraform 管理ツール。PR に
atlantis planとコメントするだけで plan が走る。GitHub との統合が最もシームレス -
Terraform Cloud(無料枠あり): HashiCorp 公式の SaaS。Free プランで 500 ランク/月まで無料。
tfstateの保管・チームのアクセス管理・UIが一体化されている
自作の CI/CD スクリプトを作り込むより、これらの既製品に乗るほうが長期的なメンテナンスコストが下がります。規模が大きくなってきたタイミングで移行を検討してください。
7. 動作確認チェックリスト
CI/CD が整ったら、本番ドメインで以下を順番に確認します。
7-1. フロントエンド表示
-
https://yourdomain.example/にアクセスして Nuxt アプリが表示される -
ブラウザの開発者ツール → ネットワークタブで、レスポンスヘッダーに
cf-rayが含まれている(Cloudflare 経由の証拠) -
https://であることを確認(HTTP アクセスが HTTPS にリダイレクトされる)
7-2. API 応答
-
https://yourdomain.example/api/v1/actuator/healthが{"status":"UP"}を返す - ログイン・ユーザー情報取得など認証付き API が正常に動作する
-
開発者ツール → Application → Cookies で、認証 Cookie が
Secure; HttpOnly; SameSite=Strict属性を持っている
7-3. WebSocket 接続
-
STOMP クライアントが
wss://yourdomain.example/wsに接続できる - ハートビートが機能しており、3〜5 分放置しても接続が維持される
- リアルタイム更新(チャット・通知等)が正常に動作する
7-4. セキュリティ設定
-
curl -I https://yourdomain.example/でcontent-security-policyヘッダーが含まれる - ログインエンドポイントへの連続リクエストが 429 でブロックされる(Rate Limiting の確認)
- Cloudflare ダッシュボード → セキュリティ → イベントで WAF のログが記録されている
7-5. 決済 Webhook の到達確認
-
Stripe ダッシュボードの Webhook ログで
https://yourdomain.example/api/v1/payments/webhookへの到達が確認できる -
Webhook の署名検証が通過している(Spring Boot のログで
webhook signature verifiedが出る等)
まとめ
本記事では Cloudflare の DNS 管理・パスルーティング・エッジセキュリティを Terraform でコード化し、GitHub Actions による CI/CD パイプラインを整備しました。
実現できたこと:
- Cloudflare を入口にした同一オリジン構成で、CORS / Cookie / CSP 問題を完全排除
- ACM 証明書の DNS 検証レコードを Terraform で自動化し、手作業ゼロに
- WAF マネージドルール・Rate Limiting をコードで管理し、変更履歴を git で追跡可能に
- GitHub OIDC で長期アクセスキーを排除し、セキュアな CI/CD 認証を実現
- PR で
terraform planを自動実行し、インフラ変更を人間がレビューするフローを確立
設計上の重要な選択肢を整理すると:
| テーマ | 採用 | 却下 | 理由 |
|---|---|---|---|
| API トークン | スコープ付きトークン | Global API Key | 漏洩時の影響範囲 |
| AWS 認証 | OIDC 一時認証 | 長期アクセスキー | ローテーション不要・セキュア |
| インフラ/アプリ CI | 分離 | 一体化 | サイクルの違い・レビュー容易性 |
| フロント SSR | Pages (workerd) | コンテナ SSR | エッジ配信でレイテンシ最小(依存互換前提) |
第4回の予告
第4回では、本番稼働後の運用・保守・スケールアップを扱います。
- CloudWatch メトリクス・アラームの設定(CPU / メモリ / 5xx 率 / DB 接続数)
- ログ設計(stdout JSON → CloudWatch Logs → 検索)
- RDS のバックアップとリストア手順
- コンテナを複数台に増やす際の変更点(WebSocket ブローカーの外部化・レートリミッタの Redis 化)
- コスト最適化(NAT Gateway の代替・Fargate Spot の活用)
インフラを育て続けるための継続的な改善サイクルを整理する予定です。第4回もぜひお読みください。