• Cloudflare Pagesのデプロイ履歴が溜まっていく
  • ダッシュボードから1件ずつ消すのが面倒
  • 自動で消したいが、間違って本番を消すのが怖い

Cloudflare Pagesは、pushするたびにデプロイが1件ずつ増えていきます。

プレビュー環境も含めるので、運用しているとすぐ数十件、数百件になります。

これを自動で片付けるNode.jsスクリプトを作りました。

ただし、単純に「作成日時が新しい順に1件だけ残す」という実装にはしていません。

そのやり方だと、条件によっては本番のデプロイを消してしまうことがあるからです。

目次

デプロイ履歴が溜まる理由

Cloudflare Pagesは、Gitリポジトリと連携すると、pushのたびに新しいデプロイを作ります。

mainブランチへのpushだけでなく、プルリクエストごとのプレビューデプロイも別カウントです。

個人開発でも、ちょっとした修正を何度もpushしていると、履歴はあっという間に積み上がります。

プロジェクトが複数あれば、そのぶん増えます。

1〜数件なら手動でも削除できる

削除したいデプロイが数件程度なら、ダッシュボードやCLIから手動で消せます。

Cloudflareダッシュボードから削除する手順は次のとおりです。

  1. Workers & Pagesから対象のPagesプロジェクトを開く
  2. 「デプロイ」タブを開き、削除したいデプロイの右側にある3点リーダーをクリックする
  3. 「デプロイを削除」を選択して削除を実行する

Wrangler CLIを使う場合は次のコマンドで1件ずつ削除できます。

npx wrangler pages deployment delete <DEPLOYMENT_ID> --project-name <PROJECT_NAME>

デプロイ数が100件を超えるような環境では、1件ずつの手動削除は現実的ではありません。

さらにCloudflare Pagesには、デプロイが100件を超えるとプロジェクト自体を削除できないことがある既知の問題もあります。

大量のデプロイを整理するなら、APIで一括削除するスクリプトを使います。

作成日時が新しいデプロイを残すと本番デプロイが消える

本番デプロイより新しいプレビューを日付だけで残すと本番を削除してしまう流れの図解

古いデプロイを消すスクリプトを書くとき、最初に思いつくのは「作成日時でソートして、一番新しい1件だけ残す」というロジックです。

一見するとこれで十分に見えますが、Cloudflare Pagesではプレビューデプロイが本番デプロイより後に作られることがあります。

たとえば、mainにマージしたあとでプルリクエストのブランチにpushすると、そちらの方が作成日時は新しくなります。

このとき単純な日付ソートで「最新の1件」を残すと、残るのはプレビューデプロイの方です。

本番で実際に配信されているデプロイの方が、削除対象に回ってしまいます。

この事故を避けるために、Cloudflare Pages APIが返すcanonical_deploymentを使いました。

これはプロジェクトごとに「本番として扱われているデプロイ」を指すフィールドです。

作成日時ではなく、このcanonical_deploymentを優先して「残す1件」を決めれば、プレビューの方が新しくても本番側を誤って消すことはありません。

スクリプトの全体像

Cloudflare Pagesの古いデプロイを全件取得、本番基準で判定、dry-run確認、削除する順番の図解

処理の流れは次の通りです。

  1. .envからCF_API_TOKENとCF_ACCOUNT_IDを読み込む
  2. Cloudflare APIから対象プロジェクトの一覧を取得する
  3. プロジェクトごとに、デプロイ履歴をページネーションも含めて全件取得する
  4. canonical_deployment(なければlatest_deployment、それもなければ作成日時が最新のもの)を「残す1件」として判定する
  5. それ以外のデプロイをDELETEリクエストで削除する

--dry-runを付ければ削除は実行せず、対象一覧の表示だけで止まります。

--project=プロジェクト名を付ければ、特定のプロジェクトだけに絞り込めます。

スクリプトの完全なコード

Node.jsの標準機能だけで動作するため、外部パッケージの追加インストールは不要です。

cleanup-cloudflare-pages-deployments.jsとして保存して実行できます。

const fs = require("fs");
const path = require("path");

function loadEnvFile(envPath) {
  if (!fs.existsSync(envPath)) return;
  const content = fs.readFileSync(envPath, "utf8");
  for (const line of content.split("\n")) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith("#")) continue;
    const eq = trimmed.indexOf("=");
    if (eq === -1) continue;
    const key = trimmed.slice(0, eq).trim();
    let value = trimmed.slice(eq + 1).trim();
    if (
      (value.startsWith('"') && value.endsWith('"')) ||
      (value.startsWith("'") && value.endsWith("'"))
    ) {
      value = value.slice(1, -1);
    }
    if (!(key in process.env)) process.env[key] = value;
  }
}

loadEnvFile(path.resolve(process.cwd(), ".env"));

const token = process.env.CF_API_TOKEN;
const accountId = process.env.CF_ACCOUNT_ID;

const args = process.argv.slice(2);
const isDryRun = args.includes("--dry-run");
const projectArg = args.find((arg) => arg.startsWith("--project="));
const targetProject = projectArg ? projectArg.split("=")[1] : null;

async function cfFetch(apiPath, options = {}) {
  const url = `https://api.cloudflare.com/client/v4${apiPath}`;
  const res = await fetch(url, {
    ...options,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      ...options.headers,
    },
  });
  const data = await res.json();
  if (!data.success) {
    const msg = data.errors?.map((e) => e.message).join(", ") || res.statusText;
    throw new Error(`APIエラー (${res.status}): ${msg}`);
  }
  return data;
}

async function listAllPages(apiPath) {
  const results = [];
  let page = 1;
  while (true) {
    const sep = apiPath.includes("?") ? "&" : "?";
    const url = page === 1 ? apiPath : `${apiPath}${sep}page=${page}`;
    const body = await cfFetch(url);
    results.push(...body.result);
    const info = body.result_info;
    if (!info || !info.total_pages || page >= info.total_pages) break;
    page++;
  }
  return results;
}

async function main() {
  if (!token || !accountId) {
    console.error(".envにCF_API_TOKENとCF_ACCOUNT_IDを設定してください。");
    process.exit(1);
  }

  console.log(isDryRun ? "--- DRY RUNモード(削除は行いません)---" : "--- 削除実行モード ---");

  let projects = await listAllPages(`/accounts/${accountId}/pages/projects`);
  if (targetProject) {
    projects = projects.filter((p) => p.name === targetProject);
    if (projects.length === 0) {
      console.log(`プロジェクト "${targetProject}" が見つかりませんでした。`);
      return;
    }
  }

  for (const project of projects) {
    console.log(`\nプロジェクト: ${project.name}`);
    const deployments = await listAllPages(
      `/accounts/${accountId}/pages/projects/${project.name}/deployments`
    );

    if (deployments.length <= 1) {
      console.log(`デプロイ件数が${deployments.length}件のためスキップします。`);
      continue;
    }

    const keepId =
      project.canonical_deployment?.id ??
      project.latest_deployment?.id ??
      [...deployments].sort((a, b) => new Date(b.created_on) - new Date(a.created_on))[0]?.id;

    const toDelete = deployments.filter((d) => d.id !== keepId);
    console.log(`総デプロイ数: ${deployments.length}件 / 残す本番デプロイ: ${keepId} / 削除対象: ${toDelete.length}件`);

    for (const d of toDelete) {
      const created = d.created_on ? new Date(d.created_on).toISOString() : "不明";
      const branch = d.deployment_trigger?.metadata?.branch || "不明ブランチ";
      if (isDryRun) {
        console.log(`  [DRY-RUN削除対象] ID: ${d.id} (${created}, branch: ${branch})`);
      } else {
        try {
          await cfFetch(
            `/accounts/${accountId}/pages/projects/${project.name}/deployments/${d.id}`,
            { method: "DELETE" }
          );
          console.log(`  [削除成功] ID: ${d.id} (${created})`);
        } catch (err) {
          console.error(`  [削除失敗] ID: ${d.id}: ${err.message}`);
        }
      }
    }
  }

  console.log("\n処理が完了しました。");
}

main().catch((err) => {
  console.error("予期せぬエラーが発生しました:", err);
  process.exit(1);
});

コードのポイント

コードの中で挙動を左右するのは、次の3か所です。

  • .envの読み込み
  • ページネーション込みのAPI取得
  • 残す1件の判定ロジック

削除する対象を決めるのは3つ目なので、自分用に書き換えるときはそこから読んでください。

.envの読み込み

外部ライブラリを使わず、.envを自前でパースしています。

KEY=VALUE形式の行を1行ずつ読み、クォートで囲まれていれば剥がしてprocess.envに入れる処理です。

すでに環境変数としてCF_API_TOKENなどが設定済みの場合は上書きしません。

ページネーション込みのAPI取得

Cloudflare APIはデフォルトでページ単位のレスポンスを返す仕様です。

履歴が数十件から数百件ある場合、1回のリクエストでは全件を取得しきれないため、複数回に分けて取得するページネーションの処理が欠かせません。

result_info.total_pagesを見ながら最後のページまで回収します。

プロジェクト一覧とデプロイ一覧の取得は、どちらもこの共通関数でまかなう設計です。

残す1件の判定ロジック

優先順位はcanonical_deployment(本番として配信中のデプロイ)→ latest_deployment(直近に作成されたデプロイ)→ 作成日時ソートの順です。

canonical_deploymentが取れる限りは、日付に関係なく本番デプロイを残す判定になります。

canonical_deploymentが取れない場合は、直近に作成されたlatest_deploymentを残すので、日付ソートと同じ弱点が残ります。--dry-runの出力で「残す本番デプロイ」のIDが、ダッシュボードの本番デプロイと一致しているかを見てから実行してください。

使い方

Cloudflare Pages公式ドキュメントの設定画面

まずはCloudflareダッシュボードでAPIトークンを発行します。

右上のアイコンから「マイ プロフィール」>「APIトークン」を開き、「トークンを作成」をクリックしてください。

トークンには次の権限を設定します。

項目設定値
権限の種類アカウント
対象リソースCloudflare Pages
アクセス権編集

次にプロジェクトの作業フォルダに.envを用意してください。

CF_API_TOKEN=xxxxxxxx
CF_ACCOUNT_ID=xxxxxxxx

アカウントIDはCloudflareダッシュボードのURL末尾、またはWorkers & Pages画面右側の概要欄から取得できます。

いきなり本番実行はせず、まずは削除を行わずにシミュレーション結果だけを表示する--dry-runで対象を確認してください。

node cleanup-cloudflare-pages-deployments.js --dry-run

表示された削除対象に問題がなければ、--dry-runを外して実行します。

node cleanup-cloudflare-pages-deployments.js

最初は--projectで1つのプロジェクトに絞って試してください。

node cleanup-cloudflare-pages-deployments.js --project=my-site --dry-run

安全に使うための設計ポイント

このスクリプトは、削除という取り消せない操作を扱います。

そのため、いくつか安全側に倒す作りにしています。

  • --dry-runで先に対象を確認できるようにする
  • 日付ではなくcanonical_deploymentで「残す1件」を判定する
  • デプロイが1件以下のプロジェクトはスキップする
  • 削除に失敗しても、そのデプロイだけログを出して処理を続ける

特に最後の「1件失敗しても止めない」は、プロジェクト数が多いほど差が出ます。

途中で1件エラーが出るたびに全体が止まると、結局手動でやり直す羽目になります。

よくある質問

作成日時でソートして最新を残すだけではダメですか?

プレビューデプロイが本番デプロイより後に作られることがあるため、単純な日付ソートだと本番デプロイの方を削除対象にしてしまうことがあります。

Cloudflare Pages APIが返す`canonical_deployment`を使えば、日付に関係なく本番側を残せます。

誤って必要なデプロイを消してしまうことはありませんか?

`--dry-run`オプションを付ければ、削除は実行せず対象一覧だけを表示できます。

本番実行の前に、必ず`--dry-run`で対象を確認してから消すようにしてください。

複数のCloudflare Pagesプロジェクトをまとめて掃除できますか?

オプションなしで実行すると、アカウント内の全プロジェクトが対象になります。

`--project=プロジェクト名`を付ければ、特定の1プロジェクトだけに絞り込めます。

デプロイが多すぎてPagesプロジェクトを削除できない場合はどうすればよいですか?

Cloudflare Pagesでは、デプロイ数が100件を超えるとプロジェクトを削除できないことがあり、Cloudflareも既知の問題として挙げています。

本スクリプトで古いデプロイを削除して件数を減らせば、ダッシュボードからプロジェクト本体も削除できるようになります。

まとめ:Cloudflare Pagesの古いデプロイ削除は日付ではなく本番デプロイを基準に残す

Cloudflare Pagesのデプロイ履歴は、運用しているだけで自然に溜まります。

自動で片付けるスクリプト自体はそれほど複雑ではありませんが、「残す1件」の決め方だけは注意が必要です。

作成日時ではなくcanonical_deploymentで本番デプロイを判定すれば、プレビューの方が新しく見える状況でも誤削除を避けられます。

削除のような取り消せない処理を書くときは、まず--dry-runで対象を確認できる作りにしておくと、本番で実行する前に誤りに気づけます。

まずは--dry-runで自分のプロジェクトの削除対象を確認してみてください。

Cloudflare Pagesのビルド回数やファイル数の上限が気になる場合は、こちらの記事にまとめています。

関連記事Cloudflare Pagesの無料枠で個人ブログは運用できる?制限と注意点を解説Cloudflare Pagesの無料枠で個人ブログをどこまで運用できるのか。月500回のビルド、静的リクエスト無制限、帯域幅無制限、Functions利用時の注意点です。