SYNON 記事 Cloudflare・インフラ

synon.co.jp 公開手順書 — 既存リポジトリを Cloudflare Workers に載せて STUDIO から切り替える

作成: 2026-08-20 / 対象リポジトリ: pacenotesio/synon-co-jp(ブランチ develop / main) 前提資料: studio-to-cloudflare-workers-migration.md(2026-08-19)— 本書はその続編。前回は「どう作るか」の計画、本書はすでに出来ているリポジトリを実際に公開するための実行手順

0サマリ

0.1 今どこにいるか

前回資料の時点では「これから作る」だったが、サイトはすでに出来ている。実測した現在地は以下。

領域 状態
サイト本体 完成site/content/*.jsonsite/build/build-visual.mjsdist-visual/ に14ページ生成
Worker 完成worker/src/index.ts に 静的配信 + /mcp(リモートMCP)+ /api/content/*(WebMCP用)+ /api/inquiry(フォーム)
wrangler 設定 ⚠️ 要修正。このままでは wrangler deploy が通らない/誤ったものが出る(§2 の B-1・B-4・B-5・B-6)
GitHub Actions 存在しない.github/ ディレクトリなし)
DNS Xserver のまま。Cloudflare ゾーンなし
301リダイレクト 未着手_redirects なし
STUDIO 側 稼働中(全20URL)

つまり 残っているのは「配線」だけで、作り直しは発生しない。

0.2 全体の流れ

Step 0  DNS移管(Xserver → Cloudflare)        … 半日 + 待ち最大24h   ← 唯一の他社依存
Step 1  リポジトリの整理(v1削除・URL方式の統一) … 2〜3時間          ← 🔴 SEO直結
Step 2  wrangler.jsonc の確定・KV作成            … 1時間
Step 3  _redirects(旧20URL → 新URL)            … 1時間
Step 4  _headers / 404 / SEO仕上げ               … 1時間
Step 5  GitHub Actions                           … 1〜2時間
Step 6  www で先行検証 → apex 切替               … 半日 ×2日
Step 7  切替後72時間の監視                       … 3日
Step 8  STUDIO 解約(切替の1ヶ月後)             … 30分

Step 0 と Step 1〜5 は並行できる。 Step 1〜5 は Cloudflare ゾーンが無くても wrangler devworkers.dev で全部やれるので、DNS移管の申請を出した当日から着手してよい。

0.3 いちばん危ないのはここ

dist-visual/ の全ページが .html 付きURLで作られていること。 Cloudflare Workers の静的アセットは既定で /services.html/services307リダイレクトする。canonical も sitemap も内部リンクも全部 .html なので、このまま公開すると全ページが「リダイレクトされたURL」として Search Console に載り、インデックスされない。 → §2 B-2 と §4。


1実測した現状

1.1 リポジトリ(2026-08-20 実測)

synon-co-jp/
├── site/
│   ├── content/*.json          … 全文言データ(10ファイル)
│   ├── assets/                 … style.css / nav.js / webmcp.js / favicon.svg
│   ├── assets-v2/              … style-visual.css + img/(v2専用)
│   └── build/
│       ├── build.mjs           … v1 → dist/          (11ページ)
│       ├── build-visual.mjs    … v2 → dist-visual/   (14ページ)★本番
│       ├── layout.mjs          … <head>・ヘッダー・フッター共通
│       ├── pages/              … v1のページ定義(8ファイル)
│       └── pages-visual/       … v2のページ定義(11ファイル)★本番
├── worker/
│   ├── src/index.ts            … ルーティング(/mcp, /api/*, それ以外は ASSETS)
│   ├── src/tools.ts            … MCPツール定義 + 問い合わせ保存
│   ├── wrangler.jsonc          … ⚠️ 要修正
│   └── wrangler.dev.jsonc      … ローカル用(.gitignore 済み)
├── scripts/                    … 画像収集・スクショ(移行後は不要)
├── design/ archive/ screenshots/ plan/   … 制作過程の資産
└── .gitignore                  … dist/ dist-visual/ node_modules/ .env を除外

1.2 新サイトの14ページ(dist-visual/

生成ファイル 公開URL(Step 1 実施後) 内容
index.html / トップ
fde.html /fde FDE サービス
services.html /services サービス一覧(#ai-development #system-development #products のアンカーあり)
works.html /works 実績
company.html /company 会社概要
news.html /news お知らせ一覧
news/coding-labo.html /news/coding-labo 記事
news/renewal.html /news/renewal 記事
contact.html /contact お問い合わせ
proposal.html /proposal 製造業向け提案(フッターのみ)
partner.html /partner パートナープログラム(フッターのみ)
syncsync-form.html /syncsync-form シンシンフォーム(フッターのみ)
privacy.html /privacy プライバシーポリシー
terms.html /terms 利用規約

加えて sitemap.xmlrobots.txt を自動生成。

1.3 STUDIO 側の20URL(前回資料の実測 + 今回のタイトル確認)

旧URL <title> 実体
/ シンオン株式会社 トップ
/1 シンオン株式会社 トップの複製
/about シンオン株式会社 | COMPANY 会社概要
/company-org シンオン株式会社 | COMPANY 会社概要の別ページ
/company-1 シンオン株式会社 | COMPANY 複製
/service シンオン株式会社 | SERVICE サービス
/service-1 シンオン株式会社 | SERVICE 複製
/development シンオン株式会社 | DEVELOPMENT 開発
/development-1 (未取得) 複製と推定
/works-system シンオン株式会社 | WORK 実績(システム)
/work-app シンオン株式会社 | WORK-APP 実績(アプリ)
/work-1 (未取得) 複製と推定
/topics シンオン株式会社 | NEWS お知らせ一覧
/topics/dify CMS記事(2025-01-11)
/topics/info CMS記事(2025-01-10)
/posts/zsFXXiMM CMS記事(2026-01-07・自動生成スラッグ)
/posts/renew CMS記事(2025-01-10)
/contact お問い合わせ
/privacy プライバシーポリシー
/termsofuse シンオン株式会社 | TERMS OF USE 利用規約

STUDIO はクライアントサイドレンダリングのため、外部からは <title> と meta しか取得できない。/development-1 /work-1 の中身と、CMS4記事の本文は、STUDIO 管理画面にログインして目視で確認すること。 特に /topics/dify は新サイトに対応記事が無いので、残す価値があるなら site/content/news.json に移植した上でリダイレクト先を変えるべき。

1.4 DNS(前回実測・2026-08-19時点、未変更の前提)

NS    ns1〜ns5.xserver.jp
A  @  203.0.113.10      ← STUDIO(Google Cloud の共有IP)
A  *  203.0.113.10      ← ワイルドカード
A  www 198.51.100.10     ← Xserver(apex と別基盤)
MX    Google Workspace ×5
CAA / AAAA / DS  なし

MX が apex を指していないので、apex の A を差し替えてもメールは無関係。 ここが syncsync.jp との決定的な違いで、apex を安全に切り替えられる根拠。


2🔴 着手前に必ず直す7点(リリースブロッカー)

すべて worker/wrangler.jsoncsite/build/ の修正で、コード量は多くない。

B-1 公開ディレクトリが v1 のまま

"assets": { "directory": "../dist", ... }   // ← v1(11ページ・旧デザイン)

本番は v2(dist-visual/)。このまま wrangler deploy すると旧デザインの11ページが本番に出る。

→ §5.1 の完成形で ../dist-visual に変更。あわせて v1 系(build.mjs / pages/ / dist/)は削除する。2系統を残すと「どっちをビルドしたか」で必ず事故る。

B-2 🔴 全ページのURLが .html 付き=全部307リダイレクトされる

これが本件で最も影響が大きい。実機(wrangler 4.124.0)で検証した結果:

リクエスト                応答
/services.html            307 → /services
/services                 200  (dist-visual/services.html を配信)
/news/renewal.html        307 → /news/renewal
/news/renewal             200

Cloudflare Workers 静的アセットの既定 html_handling: "auto-trailing-slash" の仕様どおりの挙動。ところが現在の生成物は:

対処は2案あるが、案Aを強く推奨。

案A(推奨):生成側を拡張子なしURLに統一する。 site/build/layout.mjsbuild-visual.mjs の URL 生成を .html なしに変える。ファイル名は services.html のままでよく、URLの表記だけ変える。修正箇所は「ナビ定義」「フッター定義」「各ページの path」「sitemap生成」の4か所。

案B(非推奨):html_handling: "none" にする。 .html 付きURLが200で返るようになるが、実測で / が404になったnone は完全一致しか見ないため、/ から /index.html への解決すら行わない)。トップページが死ぬので採用不可。

# html_handling: "none" の実測
/services.html   200
/services        404
/                404   ← これで没

B-3 404.html が存在しない

not_found_handling: "404-page" を指定しているのに dist-visual/404.html が無い。この場合 本文が空の404 が返る。site/build/pages-visual/notfound.mjs を追加して生成すること。

B-4 KV namespace の id がプレースホルダー

"kv_namespaces": [{ "binding": "INQUIRIES", "id": "PLACEHOLDER_REPLACE_WITH_KV_ID" }]

→ §5.2 で実際に作成して差し替え。この状態では wrangler deploy が通らない。

B-5 routes 指定が Custom Domain になっていない

"routes": [
  { "pattern": "synon.co.jp/*", "zone_name": "synon.co.jp" },   // ← Routes 方式
  { "pattern": "www.synon.co.jp/*", "zone_name": "synon.co.jp" }
]

Routes 方式は「Cloudflare がプロキシしている DNS レコードが別途必要」で、レコードが無いと ERR_NAME_NOT_RESOLVED になる。Worker 自体がオリジンなら Custom Domain 方式が正解で、こちらは DNS レコードと証明書を Cloudflare が自動作成する。

"routes": [
  { "pattern": "synon.co.jp", "custom_domain": true },
  { "pattern": "www.synon.co.jp", "custom_domain": true }
]

どちらの方式も「Cloudflare 上のアクティブなゾーン」が前提なので、Step 0(DNS移管)が終わるまでこの行はコメントアウトしておき、workers.dev で検証する。

B-6 Rate Limiting が unsafe バインディングのまま

"unsafe": { "bindings": [{ "name": "INQUIRY_LIMITER", "type": "ratelimit", ... }] }

Rate Limiting バインディングは 2025-09-19 に GA(安定版)になり、ratelimits という正式キーがある(Wrangler 4.36.0 以降)。unsafe 経由は「移行のために動き続けているだけ」の扱い。namespace_id を同じ値のままキー名だけ移せばカウンタも維持される。

B-7 worker/package.json の依存宣言が実体とずれている

// コードの import
import { McpServer } from "@modelcontextprotocol/server";   // v2 系
// package.json の宣言
"@modelcontextprotocol/sdk": "^1.30.0"                       // v1 系

コードのほうが正しい。 MCP TypeScript SDK は 2026-07-27 に v2 で分割パッケージ化され、@modelcontextprotocol/server@2.0.0 が現行。今は agents の peerDependencies 経由で npm が自動インストールしているためたまたま動いている(実際にクリーンインストール + tsc --noEmit で検証済み・エラー0)。

問題は agents@0.20.x の peer が 完全一致ピン"@modelcontextprotocol/sdk": "1.30.0" を要求している点。package.json 側が ^1.30.0 なので、SDK が 1.31.0 をリリースした瞬間に npm install が ERESOLVE で落ちる。 CI が無言で止まるタイプの時限爆弾なので、明示宣言に直す(§5.3)。


3Step 0 — DNS を Xserver から Cloudflare へ移管

前回資料の Step 1 と同じ内容だが、.co.jp 固有の注意を加えた実行版。

3.0 前提の確認(.co.jp 固有)

3.1 T-48h: TTL を下げる

Xserver の DNS レコード設定で、A(@) A(www) MX TXT の TTL を 300秒 に変更。移行の48時間以上前に実施すること(現在の TTL が反映されきるまで待つ必要があるため)。

親(.co.jp)が持つ 委任NSレコードのTTLは自分では下げられない。ここは待つしかない。

3.2 ゾーン作成と「レコードの手作業照合」

  1. Cloudflare ダッシュボード → ドメインを追加 → synon.co.jp → Free プラン
  2. 自動スキャンが走るが、Cloudflare 自身が「全レコードを見つける保証はない」と明記している。 スキャン結果を鵜呑みにしない
  3. 移管前に、Xserver の権威サーバーへ直接問い合わせて正解表を作る
for t in A AAAA MX TXT NS SOA CAA SRV; do
  echo "--- $t ---"; dig @ns1.xserver.jp synon.co.jp $t +noall +answer
done
# サブドメインも個別に
for h in www mail ftp smtp _dmarc google._domainkey; do
  dig @ns1.xserver.jp $h.synon.co.jp ANY +noall +answer
done
  1. Cloudflare 側の一覧と1行ずつ突き合わせる。特に落ちやすいのは以下 - MX … Google Workspace ×5 - TXT の SPF(v=spf1 ... include:_spf.google.com ...) - TXTgoogle-site-verification - google._domainkey の DKIM … セレクタ名は推測不能なのでスキャンでは絶対に取れない - _dmarc の TXT - STUDIO / Xserver の所有権確認用 TXT
  2. すべてグレー雲(DNS only)で登録する。 オレンジ雲は Step 6 まで一切使わない
  3. A *(ワイルドカード)はこの時点で登録しない。STUDIO 解約後も共有IPを指し続けると、他人のサイトが なんとか.synon.co.jp で表示されうる

3.3 移管前の検証(切り替える前にやる)

Cloudflare が割り当てたネームサーバーへ直接問い合わせて、Xserver と同じ答えが返ることを確認する。ここを飛ばすと、切り替えた瞬間にメールが落ちる。

CFNS=xxx.ns.cloudflare.com    # ダッシュボードで割り当てられた値
for t in A MX TXT; do
  diff <(dig @ns1.xserver.jp synon.co.jp $t +short | sort) \
       <(dig @$CFNS          synon.co.jp $t +short | sort) && echo "$t OK"
done

3.4 Xserver でネームサーバーを変更

3.5 アクティベーション待ち

Cloudflare は増加間隔で再チェックする。数分〜数時間が通常、24時間が上限。ゾーンが Active になるまで Step 6 には進めない(Step 1〜5 は並行可)。

Active 後にやること: - メールの往復テスト(受信・送信の両方) - https://synon.co.jpまだ STUDIO で表示されることを確認(= サイトは無停止で移管できている)


4Step 1 — リポジトリを本番構成に整える

4.1 v1 系の削除

git checkout -b chore/prepare-production develop
git rm -r site/build/pages site/build/build.mjs
# dist/ は .gitignore 済みなのでローカル削除のみ
rm -rf dist

.gitignore から dist-visual/ の行は残したまま、ビルド出力先を dist/ に統一するのが最終的には分かりやすい。ただし今回は差分を小さく保つため dist-visual/ のままで進め、統一は移行完了後の整理タスクとする(本書は dist-visual/ 前提で書く)。

4.2 🔴 URL を拡張子なしに統一する(B-2 の対処)

site/build/layout.mjsbuild-visual.mjs の中で、URL文字列として使われている .html を落とす。ファイル出力名は変えない。

修正対象は次の4系統:

場所 現状 変更後
layout.mjs のナビ定義(header() 内) href="/services.html" href="/services"
layout.mjs のフッター定義 + setFooterExtras() の呼び出し /proposal.html /proposal
pages-visual/*.mjslayout() に渡す path "/services.html" "/services"
build-visual.mjs の sitemap 生成 .map(p => p === "index.html" ? "/" : "/"+p) 末尾 .html を除去する1行を追加

sitemap 生成側の最小差分:

const urls = written
  .map((w) => w.relPath)
  .map((p) => (p === "index.html" ? "/" : `/${p.replace(/\.html$/, "")}`))   // ← ここ
  .map((loc) => `  <url><loc>${SITE_URL}${loc}</loc></url>`)
  .join("\n");

layout()path から canonical を組み立てているので、path を直せば canonical と og:url も自動的に直る。

検証(必須)

node site/build/build-visual.mjs
# .html を含むURLが残っていないこと(アンカー付きも含めて確認)
grep -ro 'href="/[^"]*\.html' dist-visual/ | sort -u      # → 0件になること
grep -o '<loc>[^<]*</loc>' dist-visual/sitemap.xml         # → 拡張子なしであること
grep -ho 'rel="canonical" href="[^"]*"' dist-visual/*.html # → 拡張子なしであること

4.3 404 ページの追加

site/build/pages-visual/notfound.mjs を作り、build-visual.mjs から emit("404.html", ...) する。中身は既存の pageHero を使った簡素なもので十分。

sitemap には含めないこと。 build-visual.mjswritten 配列から sitemap を作っているので、404 だけは written.push() せずに emit() だけ呼ぶか、除外条件を入れる。

4.4 ローカル確認

cd worker && npm ci && cd ..
node site/build/build-visual.mjs
npx wrangler dev --config worker/wrangler.dev.jsonc

# 別ターミナル
for u in / /services /company /news /news/renewal /contact /nope; do
  printf "%-18s " "$u"
  curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8787$u"
done
# → 全部200、/nope だけ404(本文つき)

5Step 2 — Worker 設定の確定

5.1 worker/wrangler.jsonc 完成形

wrangler 4.124.0 で --dry-run 検証済み(バインディング3つが正しく認識されることを確認)。

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "synon-site",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-01",
  "compatibility_flags": ["nodejs_compat"],

  // 🔴 本番は workers.dev を閉じる(重複コンテンツ対策)
  //    Cloudflare は workers.dev に noindex を付けないため、開けたままだと
  //    synon-site.<subdomain>.workers.dev がインデックスされうる。
  "workers_dev": false,
  // workers_dev:false にすると preview_urls も既定で off になるため明示的に開ける
  "preview_urls": true,

  "assets": {
    "directory": "../dist-visual",
    "binding": "ASSETS",
    "html_handling": "auto-trailing-slash",  // 既定値。明示して意図を残す
    "not_found_handling": "404-page",        // dist-visual/404.html が必要(B-3)
    "run_worker_first": ["/mcp", "/api/*"]
  },

  "kv_namespaces": [
    { "binding": "INQUIRIES", "id": "<§5.2 で作成した ID>" }
  ],

  // Rate Limiting は GA 済み。unsafe.bindings から移行(B-6)
  "ratelimits": [
    { "name": "INQUIRY_LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } }
  ],

  "observability": { "enabled": true }

  // 🔴 Step 0(ゾーンが Active)まではコメントアウトのまま。
  //    Custom Domain は「アクティブな Cloudflare ゾーン」が前提で、
  //    DNSレコードと証明書は Cloudflare が自動作成する。
  // ,"routes": [
  //   { "pattern": "synon.co.jp", "custom_domain": true },
  //   { "pattern": "www.synon.co.jp", "custom_domain": true }
  // ]
}

設定値の根拠

キー 理由
html_handling auto-trailing-slash none にすると / が404になる(実測済み)
not_found_handling 404-page 最も近い 404.html を探して404で返す。無ければ本文空の404
run_worker_first ["/mcp", "/api/*"] Wrangler 4.20.0 以降で配列形式に対応。/mcp は完全一致
workers_dev false ダッシュボードで無効化しても、設定ファイルに書いていないと次のデプロイで復活する
ratelimits 正式キー Wrangler 4.36.0 以降

⚠️ run_worker_first に一致するパスには _redirects_headers も適用されない/mcp/api/* のヘッダーは Worker のコード側で付けること(現状のコードは付けている)。

⚠️ Free プランでは、run_worker_first 一致パスが上限超過時に静的アセットへフォールバックせず 429 を返す。ただし /mcp /api/* のみが該当し、静的アセットへのリクエストは課金対象外・無制限。

5.2 KV 名前空間の作成

cd worker
npx wrangler kv namespace create INQUIRIES
# 出力される id を wrangler.jsonc の "<§5.2 で作成した ID>" に貼る

# プレビュー用も作っておく(PRデプロイで本番KVを汚さないため)
npx wrangler kv namespace create INQUIRIES --preview

5.3 worker/package.json の修正(B-7)

"dependencies": {
  "@modelcontextprotocol/server": "2.0.0",   // ← 明示。agents の peer は完全一致ピン
  "@modelcontextprotocol/client": "2.0.0",   // ← agents が内部で参照する
  "agents": "^0.21.0",
  "zod": "^4.4.3"                            // v2 は zod >= 4.2.0 必須(v3不可)
},
"devDependencies": {
  "@cloudflare/workers-types": "^5.20260818.1",
  "typescript": "^7.0.2",
  "wrangler": "^4.124.0"
}

"@modelcontextprotocol/sdk"削除する(コードは v2 パッケージしか import していない)。

cd worker && rm -rf node_modules package-lock.json && npm install && npx tsc --noEmit
# → エラー0 であること(検証済み)
git add package.json package-lock.json

5.4 workers.dev で先行検証

node site/build/build-visual.mjs
cd worker && npx wrangler deploy
# → https://synon-site.<subdomain>.workers.dev

このときだけ一時的に workers_dev: true にする。検証が済んだら false に戻して再デプロイすること(戻し忘れが最も多い事故)。

確認項目: - 14ページすべてが200 - /mcp に MCP Inspector か Claude から接続できる - /api/content/company.json が JSON を返す - /contact のフォーム送信 → wrangler kv key list --binding INQUIRIES に入る - 存在しないURLで404ページが出る


6Step 3 — 301リダイレクト(旧20URL → 新URL)

6.1 🔴 リダイレクト先は必ず「拡張子なし」で書く

実測で確認した落とし穴:

# _redirects に「/service /services.html 301」と書いた場合
GET /service       → 301 → /services.html
GET /services.html → 307 → /services          ← 2ホップになる
GET /services      → 200

# 「/service /services 301」と書いた場合
GET /service  → 301 → /services
GET /services → 200                            ← 1ホップ

_redirectshtml_handling より先に評価される(実測)。だから宛先を .html にすると必ず2ホップの連鎖になる。Google はリダイレクトチェーンを辿るが評価は目減りするので、1ホップに収めること。

6.2 site/assets/_redirects を作る

build-visual.mjscp(site/assets → dist-visual/assets)assets/ 配下にコピーするため、_redirectsアセットディレクトリの直下(dist-visual/_redirectsに置く必要がある。site/ 直下に置いて build-visual.mjs から明示コピーするのが素直。

// build-visual.mjs の末尾に追加
await cp(path.join(SITE, "_redirects"), path.join(DIST, "_redirects"));
await cp(path.join(SITE, "_headers"),   path.join(DIST, "_headers"));

6.3 site/_redirects の内容

# ── STUDIO 旧URL → 新URL(2026-08-20 作成)────────────────
# 宛先は必ず拡張子なし。.html を書くと307が連鎖して2ホップになる。

# 会社概要
/about          /company    301
/company-org    /company    301
/company-1      /company    301

# サービス・開発
/service        /services   301
/service-1      /services   301
/development    /services   301
/development-1  /services   301

# 実績
/works-system   /works      301
/work-app       /works      301
/work-1         /works      301

# トップの複製
/1              /           301

# お知らせ(STUDIO CMS の2系統)
/topics         /news       301
/posts/renew    /news/renewal 301
/topics/dify    /news       301
/topics/info    /news       301
/posts/zsFXXiMM /news       301

# 上記に無い旧CMS記事を拾うワイルドカード(必ず個別指定の後に置く)
/topics/*       /news       301
/posts/*        /news       301

# 利用規約はスラッグが変わる
/termsofuse     /terms      301

# /contact /privacy は旧新で同じパスなので指定しない

実測での動作確認結果(wrangler dev):

/service          301 → /services
/about            301 → /company
/topics           301 → /news
/topics/dify      301 → /news
/posts/zsFXXiMM   301 → /news
/1                301 → /

6.4 判断が要るところ

6.5 site/_headers

# セキュリティヘッダー(全ページ)
/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: geolocation=(), microphone=(), camera=()
  Strict-Transport-Security: max-age=31536000; includeSubDomains

# 内容ハッシュを持たないアセットは短めに
/assets/*
  Cache-Control: public, max-age=3600
/assets-v2/*
  Cache-Control: public, max-age=3600

⚠️ _headersWorker が生成したレスポンスには適用されない/mcp /api/* にヘッダーを足したい場合は worker/src/index.ts 側で付ける。 ⚠️ 上限は 100ルール・1行2,000文字。 ⚠️ Strict-Transport-Security は一度入れると取り消しが効きにくい。includeSubDomains を付けると *.synon.co.jp の全サブドメインが HTTPS 必須になるので、社内システムやステージングに http のホストが無いことを確認してから入れること。不安なら初回は max-age=300 で様子を見る。


7Step 4 — SEO 仕上げ

7.1 構造化データ

前回資料の指摘どおり、STUDIO 版には JSON-LD が無かった。layout.mjs は既に jsonLd を受け取る作りになっているので、トップと会社概要に Organization を入れる

// pages-visual/index.mjs の render() 内
jsonLd: {
  "@context": "https://schema.org",
  "@type": "Organization",
  name: company.name,
  url: SITE_URL,
  logo: `${SITE_URL}/assets/favicon.svg`,
  telephone: company.tel,
  address: { "@type": "PostalAddress", addressCountry: "JP", /* company.json から */ },
  sameAs: [ /* 各SNS */ ]
}

7.2 OGP 画像の回収

前回の実測で、STUDIO 版の OGP 画像は storage.googleapis.com(STUDIO のストレージ)にあった。STUDIO を解約すると消える。 新サイトの layout.mjs には現在 og:image の出力自体が無いので、

  1. STUDIO 管理画面から OGP 画像をダウンロード(またはデザインし直す)
  2. site/assets-v2/img/ogp.png として配置(1200×630)
  3. layout.mjs<meta property="og:image" content="${SITE_URL}/assets-v2/img/ogp.png"> を追加

7.3 Search Console

7.4 robots.txt

build-visual.mjsSitemap: https://synon.co.jp/sitemap.xml を出力しており、STUDIO 版と同じパスなのでそのままでよい。STUDIO のインデックス形式(子3ファイル)は不要で、単一ファイルで問題ない。


8Step 5 — GitHub Actions

8.1 API トークン

Cloudflare ダッシュボード → My Profile → API Tokens → Create Custom Token

スコープ 権限
Account → Workers Scripts Edit
Account → Workers KV Storage Edit
Account → Account Settings Read
User → Memberships Read
Zone → Workers Routes(synon.co.jp Edit

GitHub リポジトリ → Settings → Secrets → CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID

8.2 .github/workflows/deploy.yml

name: Deploy synon.co.jp

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with: { node-version: '22' }

      - name: Install worker deps
        run: npm ci
        working-directory: worker

      - name: Typecheck worker
        run: npx tsc --noEmit
        working-directory: worker

      - name: Build site
        run: node site/build/build-visual.mjs

      # デプロイ前の自己検証。壊れた成果物を本番に送らないための関所。
      - name: Verify build output
        run: |
          set -e
          test -f dist-visual/index.html
          test -f dist-visual/404.html
          test -f dist-visual/_redirects
          test -f dist-visual/sitemap.xml
          # URL に .html が残っていないこと(B-2 の再発防止)
          if grep -rq 'href="/[^"]*\.html' dist-visual/; then
            echo "::error::拡張子つきの内部リンクが残っています"; exit 1
          fi
          if grep -q '\.html<' dist-visual/sitemap.xml; then
            echo "::error::sitemap に .html が残っています"; exit 1
          fi

      - uses: actions/upload-artifact@v7
        with: { name: dist, path: dist-visual/ }

  deploy-preview:
    needs: build
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/download-artifact@v7
        with: { name: dist, path: dist-visual/ }
      - run: npm ci
        working-directory: worker
      - uses: cloudflare/wrangler-action@v4
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          workingDirectory: worker
          command: versions upload --preview-alias pr-${{ github.event.number }}
      # → https://pr-123-synon-site.<subdomain>.workers.dev
      #   本番トラフィックには一切影響しない

  deploy-production:
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production          # ← 承認ゲート
    steps:
      - uses: actions/checkout@v6
      - uses: actions/download-artifact@v7
        with: { name: dist, path: dist-visual/ }
      - run: npm ci
        working-directory: worker
      - uses: cloudflare/wrangler-action@v4
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          workingDirectory: worker
          command: deploy

      - name: Smoke test
        run: |
          set -e
          sleep 15
          for p in / /services /company /news /contact; do
            code=$(curl -s -o /dev/null -w '%{http_code}' "https://synon.co.jp$p")
            echo "$p -> $code"
            [ "$code" = "200" ] || exit 1
          done
          # 代表的な301を確認
          loc=$(curl -s -o /dev/null -w '%{redirect_url}' "https://synon.co.jp/about")
          echo "/about -> $loc"
          case "$loc" in *"/company") ;; *) exit 1 ;; esac

      - name: Rollback on failure
        if: failure()
        uses: cloudflare/wrangler-action@v4
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          workingDirectory: worker
          command: rollback --message "smoke test failed on ${{ github.sha }}"

注意点


9Step 6 — 本番切替

www → apex の順。 www を先に Workers へ向ければ、本番URL(apex)に影響を与えずに実ドメイン・実証明書で検証できる。

9.1 Day 1: www を Workers へ

現状 www は Xserver(198.51.100.10)を指しているだけで、実質使われていない。ここを踏み台にする。

// wrangler.jsonc — www だけ有効化
"routes": [
  { "pattern": "www.synon.co.jp", "custom_domain": true }
]
cd worker && npx wrangler deploy

Custom Domain 作成時に Cloudflare が DNS レコードと証明書を自動発行する。その前に既存の www A 198.51.100.10 を削除しておくこと。 Cloudflare は「既に CNAME レコードがあるホスト名には Custom Domain を作れない」と明記しており、A レコードについても既存レコードと衝突させる意味がないため、先に消すのが安全。

検証(実ドメイン・実証明書)

for p in / /services /company /works /fde /news /news/renewal /contact /privacy /terms /proposal /partner /syncsync-form; do
  printf "%-20s " "$p"; curl -s -o /dev/null -w "%{http_code}\n" "https://www.synon.co.jp$p"
done
# 301の確認
for p in /about /service /development /works-system /topics /termsofuse /1; do
  printf "%-16s " "$p"
  curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" "https://www.synon.co.jp$p"
done
# 証明書
echo | openssl s_client -connect www.synon.co.jp:443 -servername www.synon.co.jp 2>/dev/null | openssl x509 -noout -dates -issuer

9.2 Day 2: apex を Workers へ

www で問題が出なかったら apex に進む。MX は apex を指していないので、メールへの影響はない。

"routes": [
  { "pattern": "synon.co.jp", "custom_domain": true },
  { "pattern": "www.synon.co.jp", "custom_domain": true }
]
  1. Cloudflare DNS から A @ 203.0.113.10(STUDIO)を削除
  2. A * 203.0.113.10(ワイルドカード)も削除
  3. wrangler deploy
  4. Custom Domain が自動で apex のレコードを作る

切替直後の確認(10分以内)

dig synon.co.jp A +short                    # Cloudflare の IP になっていること
curl -sI https://synon.co.jp/ | head -3     # 200
curl -s https://synon.co.jp/sitemap.xml     # 新しい14URL
curl -s https://synon.co.jp/robots.txt

9.3 Day 3: www → apex の301に切り替える

両方が Custom Domain のままだと同じ内容が2つのURLで出る=重複コンテンツ。www は Cloudflare の Single Redirect ルール(ゾーンレベル・Free で利用可)で apex に飛ばす。

  1. wrangler.jsonc から www.synon.co.jp の Custom Domain を削除 → wrangler deploy
  2. Cloudflare ダッシュボード → Rules → Redirect Rules → Single Redirect - 条件: Hostname equals www.synon.co.jp - 動作: Dynamic redirect → concat("https://synon.co.jp", http.request.uri.path) - ステータス: 301、クエリ文字列を保持
  3. curl -sI https://www.synon.co.jp/services301 Location: https://synon.co.jp/services

_redirects ファイルはドメインレベルのリダイレクトに非対応なので、ここは Redirect Rules を使う。


10Step 7 — ロールバック

3段階。上ほど速い。

段階 手段 所要 使う場面
wrangler rollback --message "" 即時 デプロイした版が壊れている
Custom Domain を削除 → A @ 203.0.113.10グレー雲で再作成 数分 Workers 全体がおかしい/STUDIO に戻したい
Xserver でNSを ns1〜ns5.xserver.jp に戻す 最大24時間 Cloudflare ごと切り戻す

11Step 8 — 切替後の運用

11.1 切替後72時間の監視

項目 見る場所 何を見るか
エラー率 Workers Logs(observability.enabled: true で有効) 5xx が出ていないか
リクエスト数 Workers Analytics 静的アセットは無制限・課金対象外。/mcp /api/* の実行回数が Free の100,000/日に対してどうか
404 Workers Logs 想定外の旧URLへのアクセス → _redirects に追加
インデックス Search Console 「ページにリダイレクトがあります」が出ていないか(= B-2 の取りこぼし)
フォーム KV 実際に届いているか(毎日確認)
メール Gmail 送受信できているか

11.2 運用カレンダーに追加すること

頻度 作業
毎日(切替後1週間) KV に問い合わせが溜まっていないか確認
月次 Search Console のカバレッジ確認
年次 🔴 Cloudflare API トークンのローテーション(TTL切れで自動デプロイが無言で停止する。サイトは動き続けるので気づかない)
都度 お知らせ追加は site/content/news.json を編集して push(CMS なし・開発者運用)

11.3 STUDIO 解約(切替の1ヶ月後)


12未確定・要判断の事項

# 内容 誰が決めるか
1 /development-1 /work-1 の中身 — STUDIO 管理画面で目視確認。単なる複製なら §6.3 の表どおりでよい 実さん
2 CMS 4記事を新サイトに移植するか — 移植するなら news.json に追記して /news/<slug> へ301。しないなら /news 実さん
3 /contact フォームの現在の送信先 — STUDIO のフォーム機能なら解約で止まる。新サイトは KV に保存するだけで通知が飛ばない。運用として「KV を毎日見る」で足りるか、通知が要るか 実さん
4 フォーム通知の実装 — MailChannels は 2024-08-31 提供終了。Cloudflare Email Sending は Workers 有料プラン限定。Free のままなら Resend / SendGrid の API を Worker から叩くのが現実的 要決定
5 HSTS の includeSubDomains — 社内システムやステージングに http のホストが無いか確認してから 情シス
6 dist-visual/dist/ へのリネーム — 移行完了後の整理タスク。今回は触らない 後日
7 Turnstile の導入 — 問い合わせフォームのスパム対策。無料・検証無制限。Rate Limit だけでは不十分になったら 後日

13チェックリスト

着手前

Step 0(DNS)

Step 1〜5(リポジトリ)

Step 6(切替)

切替後


付録: 検証に使った実測ログ

以下は wrangler 4.124.0(wrangler dev --local)での実測結果。ドキュメントに明記が無かった _redirectshtml_handling の評価順序を確認するために取得した。

# 既定(html_handling: auto-trailing-slash)
/services.html           307 -> /services
/services                200
/news/renewal.html       307 -> /news/renewal
/news/renewal            200
/nope                    404  (404.html の本文つき)

# _redirects の宛先を .html にした場合 → 2ホップ
/service                 301 -> /services.html
  (続けて)               307 -> /services
  (続けて)               200

# _redirects の宛先を拡張子なしにした場合 → 1ホップ
/service                 301 -> /services
  (続けて)               200
/about                   301 -> /company
/topics                  301 -> /news
/topics/dify             301 -> /news     (ワイルドカード /topics/*)
/posts/zsFXXiMM          301 -> /news     (ワイルドカード /posts/*)
/1                       301 -> /

# html_handling: "none" にした場合 → トップが死ぬ
/services.html           200
/services                404
/                        404   ← 採用不可

wrangler deploy --dry-run で §5.1 の設定が正しく解釈されることも確認済み。

Binding                                          Resource
env.INQUIRIES (…)                                KV Namespace
env.INQUIRY_LIMITER (5 requests/60s)             Rate Limit
env.ASSETS                                       Assets

参考