
GitHub Actions は便利ですが、プライベートリポジトリで CI を回し続けると無料枠を超えた瞬間から分単位の従量課金が積み上がります。Next.js のビルドや Playwright の E2E のように1回数分かかるジョブを頻繁に流す個人開発・小規模チームでは、月末の請求にひやりとした方も多いのではないでしょうか。
そこで現実的な選択肢になるのが、**手元で常時稼働している Mac 1台を CI サーバーに仕立てる**構成です。この記事では、[Woodpecker CI](https://woodpecker-ci.org/) + Cloudflare Tunnel + launchd を組み合わせて、`git push → 自動CI` が回る環境をゼロから組む手順を、実運用で踏んだ落とし穴込みで紹介します。
## なぜ Woodpecker なのか
セルフホスト CI には Jenkins / Drone / GitLab CI などいくつか候補がありますが、個人〜小規模では Woodpecker が扱いやすい選択肢です。
- Server / Agent の2プロセスだけで成立し、構成がシンプル
- ジョブの各ステップを **Docker コンテナとして実行**するため、CI 環境を YAML で完結できる
- GitHub OAuth 連携でリポジトリ一覧・Webhook 登録まで自動化される
GitHub Actions の `.github/workflows/*.yml` に近い書き味の `.woodpecker.yml` をリポジトリに置くだけで動くので、移行コストも低めです。
## アーキテクチャ:Server / Agent / Tunnel の3層
まず全体像を押さえます。登場人物は3つです。
- **Woodpecker Server**:WebUI と REST API を提供する司令塔。SQLite にパイプライン履歴を永続化します。
- **Woodpecker Agent**:Server から gRPC でジョブを受け取り、実際にビルドを実行する作業員。ホストの Docker socket を使って各ステップをコンテナ起動します。
- **Cloudflare Tunnel**:自宅ネットワークにグローバル IP を割り当てずに、GitHub.com からの Webhook を受け取るための公開経路。
ここで重要なのが、**Server を `127.0.0.1` にだけバインドし、外部公開は Cloudflare Tunnel 経由に限定する**点です。
- Server の WebUI / API はホスト側 `127.0.0.1:9663`(コンテナ内 `8000`)にバインド。LAN からの直接到達もできません。
- Agent 向けの gRPC(`9000`)は **Docker network 内に閉じてホストには一切公開しません**。Agent は同じ compose 内にいるので `woodpecker-server:9000` で名前解決できます。
- 外から到達するのは Cloudflare Tunnel が貼った `https://ci.example.dev` 経由のみ。
家庭用回線でポート開放やダイナミック DNS を設定する必要がなく、攻撃面を最小化できるのがこの設計の狙いです。
## docker-compose の最小構成
Server と Agent を compose で定義します。実際の設定から要点だけ抜粋します(リソース上限やボリューム許可リストは省略)。
```yaml
name: woodpecker
services:
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3
restart: unless-stopped
ports:
# ホスト 9663 → コンテナ 8000(127.0.0.1 にだけバインド)
- "127.0.0.1:9663:8000"
# gRPC (9000) は同一 compose 内の Agent からしか使わないので公開しない
volumes:
- woodpecker-server-data:/var/lib/woodpecker/ # SQLite DB(削除厳禁)
environment:
- WOODPECKER_OPEN=false
# 外部公開URL。未設定なら localhost にフォールバック
- WOODPECKER_HOST=${WOODPECKER_HOST:-http://localhost:9663}
- WOODPECKER_GITHUB=true
- WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}
- WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
- WOODPECKER_ADMIN=${WOODPECKER_ADMIN}
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
restart: unless-stopped
depends_on:
- woodpecker-server
volumes:
- /var/run/docker.sock:/var/run/docker.sock # ホストの Docker でステップを実行
environment:
- WOODPECKER_SERVER=woodpecker-server:9000 # Docker network 内で解決
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
volumes:
woodpecker-server-data:
```
ポイントを補足します。
- ポート `9663` は電話のキーパッドで "WOOD" を表したもの。`8000` は競合しやすいので避けています。お好みのポートで構いません。
- Forge(連携先)は **GitHub OAuth App** を使います。GitHub App ではない点に注意。Woodpecker は GitHub App のトークン更新方式に未対応なので、OAuth App で登録します。
- `WOODPECKER_AGENT_SECRET` は `openssl rand -hex 32` で生成し、`.env` 経由で Server と Agent の両方に注入します。
- SQLite を保存する `woodpecker-server-data` ボリュームは**消すと全履歴が飛びます**。`docker compose down -v` は使わないでください。
ひとつ重大な信頼境界があります。Agent はホストの `/var/run/docker.sock` を握っているため、**Activate したリポジトリの `.woodpecker.yml` は実質ホスト root 権限で任意コマンドを実行できます**。WebUI から有効化するのは自分が管理する信頼できるリポジトリだけに限り、フォーク PR の自動実行は許可しないでください。
## Cloudflare Tunnel で公開する:localhost のままだとハマる
ここが本記事のいちばんの勘所です。
GitHub から `git push` で CI をトリガするには、GitHub.com が **Webhook を投げ込める公開 URL** を Woodpecker が持っている必要があります。`cloudflared` を使えば、自宅 Mac の `localhost:9663` を `https://ci.example.dev` として安全に公開できます。
```bash
# Cloudflare 側で Tunnel を作成・DNS を割り当てたうえで、ホストで起動
cloudflared tunnel run my-ci-tunnel
```
そして `.env` に公開 URL を設定します。
```bash
echo "WOODPECKER_HOST=https://ci.example.dev" >> .env
chmod 600 .env
docker compose up -d --force-recreate woodpecker-server
```
**`WOODPECKER_HOST` が `localhost` のままリポジトリを Activate すると、GitHub が Webhook 作成を拒否します。**
```
422 Validation Failed
url is not supported because it isn't reachable over the public Internet
```
GitHub は「インターネットから到達できない URL」への Webhook 登録を弾くため、`localhost:9663` を Webhook の宛先として登録しようとした瞬間に 422 で reject されます。OAuth ログインや WebUI からの手動実行は localhost でも動いてしまうので、「ログインはできたのに push で動かない」という分かりにくい形でハマります。**リポジトリを Activate する前に、必ず `.env` で公開 URL を設定する**のが鉄則です。
逆に、Tunnel を止めて完全ローカル運用に戻したいときは **`.env` から `WOODPECKER_HOST` を消すだけ**です。compose のデフォルト(`http://localhost:9663`)にフォールバックします。ただしその場合、`git push` / PR トリガの Webhook は届かなくなり、WebUI からの手動実行のみになる点は理解しておきましょう。
## 起動スクリプトを1コマンドにまとめる
`cloudflared`(ホスト上の常駐プロセス)と `docker compose`(Server / Agent)は別々のライフサイクルを持ちます。毎回2系統を手で起動・確認するのは面倒なので、ラッパースクリプトにまとめます。
```bash
./scripts/start-all.sh # cloudflared を nohup 起動 + docker compose up -d + 到達性確認
./scripts/stop-all.sh # docker compose down + cloudflared に SIGTERM
./scripts/status-all.sh # cloudflared の pid / compose ps / localhost & tunnel の疎通
```
設計上こだわったのは**冪等性**です。`start-all.sh` は「すでに動いているものは skip」する作りにしてあります。
- `cloudflared` がすでに起動中ならプロセスを増やさず skip
- `docker compose up -d` は実行中のサービスはそのまま
- 起動後に `http://localhost:9663/` への到達性を最大30秒待ってから完了とする
これで「いま動いているか分からないから一度止めて起動し直す」という事故を防げます。`status-all.sh` を叩けば、`cloudflared` のプロセス・`compose ps`・localhost と Tunnel 両方の疎通が一望できるので、トラブル時の切り分けが速くなります。
## ハマりどころまとめ
実運用で踏んだ落とし穴を3つだけ共有します。
1. **localhost のまま Activate して 422**:前述のとおり。Activate 前に `WOODPECKER_HOST` を公開 URL にする。順番を間違えると「ログインできるのに push で動かない」状態になります。
2. **ローカル base image は `pull: false` 必須**:CI で使う共通イメージをローカルビルドして `ci.example.dev` 上で参照する場合、`.woodpecker.yml` の step に `pull: false` を付けないと Docker Hub に探しに行って `ImageNotFound` で落ちます。
3. **Mac 1台が単一障害点(SPOF)**:自宅 Mac の電源断・再起動・Tunnel 停止がそのまま CI 全停止になります。本番デプロイなど落ちると困るジョブは、GitHub Actions 側に `workflow_dispatch` だけ残しておき、緊急時に手動で流せる退路を確保しておくと安心です。
最後に、Mac のログイン時に Woodpecker と Tunnel を**自動起動**させたいところですが、これは `launchd` の plist を使います。再起動のたびに手で `start-all.sh` を叩かなくて済むようになる仕組みですが、plist の変数置換や冪等な install スクリプトの設計には固有のコツがあるので、別記事で詳しく扱う予定です。ここでは「launchd で常駐させてログイン時に自動復帰できる」とだけ覚えておいてください。
## 現役実装者の視点
そもそもなぜクラウド CI ではなく自前にしたのか、という話からすると、最初のきっかけは正直に言えばコストでした。個人開発で Next.js のビルドや Playwright を回していると、無料枠を超えた分の従量課金がじわじわ効いてきます。ただ、実際に組んでみて一番ありがたかったのはコストよりも「ビルド環境を手元で握れる」ことのほうでした。Docker イメージのキャッシュが手元に残るのでビルドが速いですし、メモリや並列数も自分で決められます。常時稼働している Mac がすでに手元にあるなら、その遊んでいる時間を CI に使うだけ、という発想で気軽に始められたのも自分には合っていました。
正直なところ、1台運用には心許なさがあります。組み終わったあとも「これ、本当に勝手に動き続けてくれるのか」という不安はしばらく消えませんでした。実際に手応えが変わったのは、`git push` してしばらくしたら Discord に CI 結果が飛んでくる、という流れが何度か当たり前に回ったあたりからです。逆に想定外だったのは、最初に詰まったのが Woodpecker 本体ではなく公開経路のほうだった点です。ログインも WebUI からの手動実行も普通にできてしまうので「動いている」と思い込んでいたのに、`push` だけが一向にトリガされず、原因が `WOODPECKER_HOST` を `localhost` のまま Activate していたことだと分かるまでにそこそこ時間を溶かしました。本文に書いた 422 はまさにこのときの実体験です。launchd での常駐も、plist に環境変数を直書きしてうまく置換されずに小さくハマったので、install スクリプト側で変数を埋め込む形に作り直しました。
なので、どんな人に勧めるかははっきりしています。CI が多少落ちても自分で気づいて直せる個人開発者や、ビルドが重くてクラウドの課金が気になり始めた小規模チームには素直に勧められます。一方で、本番デプロイをこの 1台に全部背負わせるのはお勧めしません。Mac の電源断や再起動、Tunnel の停止がそのまま CI 全停止になる SPOF はごまかしようがないので、私自身は「落ちても困らない CI は Mac、止まると困る本番デプロイは GitHub Actions に `workflow_dispatch` の退路を残す」というハイブリッドに落ち着きました。全部を 1台に寄せず、コストとリスクの境界線を意識して切り分ける、というのが今のところの現実解だと感じています。

GitHub Actions は便利ですが、プライベートリポジトリで CI を回し続けると無料枠を超えた瞬間から分単位の従量課金が積み上がります。Next.js のビルドや Playwright の E2E のように1回数分かかるジョブを頻繁に流す個人開発・小規模チームでは、月末の請求にひやりとした方も多いのではないでしょうか。
そこで現実的な選択肢になるのが、手元で常時稼働している Mac 1台を CI サーバーに仕立てる構成です。この記事では、Woodpecker CI + Cloudflare Tunnel + launchd を組み合わせて、git push → 自動CI が回る環境をゼロから組む手順を、実運用で踏んだ落とし穴込みで紹介します。
なぜ Woodpecker なのか
セルフホスト CI には Jenkins / Drone / GitLab CI などいくつか候補がありますが、個人〜小規模では Woodpecker が扱いやすい選択肢です。
- Server / Agent の2プロセスだけで成立し、構成がシンプル
- ジョブの各ステップを Docker コンテナとして実行するため、CI 環境を YAML で完結できる
- GitHub OAuth 連携でリポジトリ一覧・Webhook 登録まで自動化される
GitHub Actions の .github/workflows/*.yml に近い書き味の .woodpecker.yml をリポジトリに置くだけで動くので、移行コストも低めです。
アーキテクチャ:Server / Agent / Tunnel の3層
まず全体像を押さえます。登場人物は3つです。
- Woodpecker Server:WebUI と REST API を提供する司令塔。SQLite にパイプライン履歴を永続化します。
- Woodpecker Agent:Server から gRPC でジョブを受け取り、実際にビルドを実行する作業員。ホストの Docker socket を使って各ステップをコンテナ起動します。
- Cloudflare Tunnel:自宅ネットワークにグローバル IP を割り当てずに、GitHub.com からの Webhook を受け取るための公開経路。
ここで重要なのが、Server を 127.0.0.1 にだけバインドし、外部公開は Cloudflare Tunnel 経由に限定する点です。
- Server の WebUI / API はホスト側
127.0.0.1:9663(コンテナ内 8000)にバインド。LAN からの直接到達もできません。
- Agent 向けの gRPC(
9000)は Docker network 内に閉じてホストには一切公開しません。Agent は同じ compose 内にいるので woodpecker-server:9000 で名前解決できます。
- 外から到達するのは Cloudflare Tunnel が貼った
https://ci.example.dev 経由のみ。
家庭用回線でポート開放やダイナミック DNS を設定する必要がなく、攻撃面を最小化できるのがこの設計の狙いです。
docker-compose の最小構成
Server と Agent を compose で定義します。実際の設定から要点だけ抜粋します(リソース上限やボリューム許可リストは省略)。
name: woodpecker
services:
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3
restart: unless-stopped
ports:
# ホスト 9663 → コンテナ 8000(127.0.0.1 にだけバインド)
- "127.0.0.1:9663:8000"
# gRPC (9000) は同一 compose 内の Agent からしか使わないので公開しない
volumes:
- woodpecker-server-data:/var/lib/woodpecker/ # SQLite DB(削除厳禁)
environment:
- WOODPECKER_OPEN=false
# 外部公開URL。未設定なら localhost にフォールバック
- WOODPECKER_HOST=${WOODPECKER_HOST:-http://localhost:9663}
- WOODPECKER_GITHUB=true
- WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT}
- WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET}
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
- WOODPECKER_ADMIN=${WOODPECKER_ADMIN}
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
restart: unless-stopped
depends_on:
- woodpecker-server
volumes:
- /var/run/docker.sock:/var/run/docker.sock # ホストの Docker でステップを実行
environment:
- WOODPECKER_SERVER=woodpecker-server:9000 # Docker network 内で解決
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
volumes:
woodpecker-server-data:
ポイントを補足します。
- ポート
9663 は電話のキーパッドで "WOOD" を表したもの。8000 は競合しやすいので避けています。お好みのポートで構いません。
- Forge(連携先)は GitHub OAuth App を使います。GitHub App ではない点に注意。Woodpecker は GitHub App のトークン更新方式に未対応なので、OAuth App で登録します。
WOODPECKER_AGENT_SECRET は openssl rand -hex 32 で生成し、.env 経由で Server と Agent の両方に注入します。
- SQLite を保存する
woodpecker-server-data ボリュームは消すと全履歴が飛びます。docker compose down -v は使わないでください。
ひとつ重大な信頼境界があります。Agent はホストの /var/run/docker.sock を握っているため、Activate したリポジトリの .woodpecker.yml は実質ホスト root 権限で任意コマンドを実行できます。WebUI から有効化するのは自分が管理する信頼できるリポジトリだけに限り、フォーク PR の自動実行は許可しないでください。
Cloudflare Tunnel で公開する:localhost のままだとハマる
ここが本記事のいちばんの勘所です。
GitHub から git push で CI をトリガするには、GitHub.com が Webhook を投げ込める公開 URL を Woodpecker が持っている必要があります。cloudflared を使えば、自宅 Mac の localhost:9663 を https://ci.example.dev として安全に公開できます。
# Cloudflare 側で Tunnel を作成・DNS を割り当てたうえで、ホストで起動
cloudflared tunnel run my-ci-tunnel
そして .env に公開 URL を設定します。
echo "WOODPECKER_HOST=https://ci.example.dev" >> .env
chmod 600 .env
docker compose up -d --force-recreate woodpecker-server
WOODPECKER_HOST が localhost のままリポジトリを Activate すると、GitHub が Webhook 作成を拒否します。
422 Validation Failed
url is not supported because it isn't reachable over the public Internet
GitHub は「インターネットから到達できない URL」への Webhook 登録を弾くため、localhost:9663 を Webhook の宛先として登録しようとした瞬間に 422 で reject されます。OAuth ログインや WebUI からの手動実行は localhost でも動いてしまうので、「ログインはできたのに push で動かない」という分かりにくい形でハマります。リポジトリを Activate する前に、必ず .env で公開 URL を設定するのが鉄則です。
逆に、Tunnel を止めて完全ローカル運用に戻したいときは .env から WOODPECKER_HOST を消すだけです。compose のデフォルト(http://localhost:9663)にフォールバックします。ただしその場合、git push / PR トリガの Webhook は届かなくなり、WebUI からの手動実行のみになる点は理解しておきましょう。
起動スクリプトを1コマンドにまとめる
cloudflared(ホスト上の常駐プロセス)と docker compose(Server / Agent)は別々のライフサイクルを持ちます。毎回2系統を手で起動・確認するのは面倒なので、ラッパースクリプトにまとめます。
./scripts/start-all.sh # cloudflared を nohup 起動 + docker compose up -d + 到達性確認
./scripts/stop-all.sh # docker compose down + cloudflared に SIGTERM
./scripts/status-all.sh # cloudflared の pid / compose ps / localhost & tunnel の疎通
設計上こだわったのは冪等性です。start-all.sh は「すでに動いているものは skip」する作りにしてあります。
cloudflared がすでに起動中ならプロセスを増やさず skip
docker compose up -d は実行中のサービスはそのまま
- 起動後に
http://localhost:9663/ への到達性を最大30秒待ってから完了とする
これで「いま動いているか分からないから一度止めて起動し直す」という事故を防げます。status-all.sh を叩けば、cloudflared のプロセス・compose ps・localhost と Tunnel 両方の疎通が一望できるので、トラブル時の切り分けが速くなります。
ハマりどころまとめ
実運用で踏んだ落とし穴を3つだけ共有します。
- localhost のまま Activate して 422:前述のとおり。Activate 前に
WOODPECKER_HOST を公開 URL にする。順番を間違えると「ログインできるのに push で動かない」状態になります。
- ローカル base image は
pull: false 必須:CI で使う共通イメージをローカルビルドして ci.example.dev 上で参照する場合、.woodpecker.yml の step に pull: false を付けないと Docker Hub に探しに行って ImageNotFound で落ちます。
- Mac 1台が単一障害点(SPOF):自宅 Mac の電源断・再起動・Tunnel 停止がそのまま CI 全停止になります。本番デプロイなど落ちると困るジョブは、GitHub Actions 側に
workflow_dispatch だけ残しておき、緊急時に手動で流せる退路を確保しておくと安心です。
最後に、Mac のログイン時に Woodpecker と Tunnel を自動起動させたいところですが、これは launchd の plist を使います。再起動のたびに手で start-all.sh を叩かなくて済むようになる仕組みですが、plist の変数置換や冪等な install スクリプトの設計には固有のコツがあるので、別記事で詳しく扱う予定です。ここでは「launchd で常駐させてログイン時に自動復帰できる」とだけ覚えておいてください。
現役実装者の視点
そもそもなぜクラウド CI ではなく自前にしたのか、という話からすると、最初のきっかけは正直に言えばコストでした。個人開発で Next.js のビルドや Playwright を回していると、無料枠を超えた分の従量課金がじわじわ効いてきます。ただ、実際に組んでみて一番ありがたかったのはコストよりも「ビルド環境を手元で握れる」ことのほうでした。Docker イメージのキャッシュが手元に残るのでビルドが速いですし、メモリや並列数も自分で決められます。常時稼働している Mac がすでに手元にあるなら、その遊んでいる時間を CI に使うだけ、という発想で気軽に始められたのも自分には合っていました。
正直なところ、1台運用には心許なさがあります。組み終わったあとも「これ、本当に勝手に動き続けてくれるのか」という不安はしばらく消えませんでした。実際に手応えが変わったのは、git push してしばらくしたら Discord に CI 結果が飛んでくる、という流れが何度か当たり前に回ったあたりからです。逆に想定外だったのは、最初に詰まったのが Woodpecker 本体ではなく公開経路のほうだった点です。ログインも WebUI からの手動実行も普通にできてしまうので「動いている」と思い込んでいたのに、push だけが一向にトリガされず、原因が WOODPECKER_HOST を localhost のまま Activate していたことだと分かるまでにそこそこ時間を溶かしました。本文に書いた 422 はまさにこのときの実体験です。launchd での常駐も、plist に環境変数を直書きしてうまく置換されずに小さくハマったので、install スクリプト側で変数を埋め込む形に作り直しました。
なので、どんな人に勧めるかははっきりしています。CI が多少落ちても自分で気づいて直せる個人開発者や、ビルドが重くてクラウドの課金が気になり始めた小規模チームには素直に勧められます。一方で、本番デプロイをこの 1台に全部背負わせるのはお勧めしません。Mac の電源断や再起動、Tunnel の停止がそのまま CI 全停止になる SPOF はごまかしようがないので、私自身は「落ちても困らない CI は Mac、止まると困る本番デプロイは GitHub Actions に workflow_dispatch の退路を残す」というハイブリッドに落ち着きました。全部を 1台に寄せず、コストとリスクの境界線を意識して切り分ける、というのが今のところの現実解だと感じています。