
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がすでに起動中ならプロセスを増やさず skipdocker 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台に寄せず、コストとリスクの境界線を意識して切り分ける、というのが今のところの現実解だと感じています。


