WordPress テーマの更新を毎回 FTP でアップロードするのをやめたくて、GitHub Actions で自動デプロイを組もうとしました。ところが Connection closed、直したと思ったら今度は Connection timed out。鍵が悪いのかと何度も作り直して、まる1日使いました。
原因は鍵ではありませんでした。エックスサーバーの「SSH国外IPアクセス制限」が、GitHub のサーバーからの接続をブロックしていただけだったんです。
この記事では、そのとき調べたことと、最終的に落ち着いた構成をまとめておきます。こんな人に向けて書きました。
- エックスサーバーで WordPress を運用していて、FTP 手動更新をやめたい
- GitHub Actions の SSH デプロイが
Connection timed outやConnection closedで止まっている - 「国外アクセス制限をOFFにしましょう」という解説を読んだけれど、セキュリティを下げるのが引っかかっている
- CI/CD の専門家ではないので、できるだけ短い手順で終わらせたい
先に結論
- 原因:鍵ではなく、エックスサーバーの「SSH国外IPアクセス制限」。GitHub Actions は海外のサーバーで動くため弾かれる
- 解決:制限はONのまま、国内IPである自分のパソコンから rsync で送り、
pre-pushフックでgit pushに連動させる - 注意:
rsync -aをそのまま使うと、サイトの画像やCSSが 403 で表示されなくなることがある
【まず確認】あなたのケースはこれ?(30秒)
先に、当てはまるかどうかだけ確かめてみてください。次の3つが揃っていれば、この記事の手順がそのまま使えます。
- 自分のパソコンからは、同じ鍵で SSH 接続できる(まだ試していないなら、まずこれを試してください。これが最短の切り分けです)
- GitHub Actions のログに出るエラーが
Connection timed outまたはConnection closed - サーバーパネル →「サーバー」→「SSH設定」を開き、「国外アクセス制限」が ON になっている
3つとも揃っていれば、原因はほぼIPによるブロックです。
当てはまらない場合は、別の原因を疑ったほうが早いです。「自分のパソコンからも接続できない」なら SSH 設定や公開鍵の登録が済んでいない可能性、「Permission denied 系」なら鍵か Secrets への貼り付け方(一部だけコピー・末尾の改行落ち)を先に見直してみてください。ここでは扱いません。
【原因】エックスサーバーの「国外IPアクセス制限」
エックスサーバーには、SSH 接続を日本国内のIPアドレスからだけに限定する設定があります。海外からの不正アクセスを減らすための機能で、2024年7月に告知され、サーバーごとに順次実施されています。
サーバーパネルの 「サーバー」→「SSH設定」 を開くと、「国外アクセス制限」 という項目があります。ここが ON になっていれば対象です。

一方、GitHub Actions の標準的な実行環境(ホストランナー)は海外のサーバー上で動きます。Actions からつなごうとすると、海外からのアクセスとして扱われて弾かれるわけです。
(自前のマシンをランナーにする「セルフホストランナー」なら国内からになりますが、その分の用意と運用が増えます。)
鍵が正しくても関係ありません。そもそもドアの前まで届いていないので、いくら作り直しても変わらないわけです。

切り分けの決め手は「同じ鍵で、ローカルからは通る」
確信を持てたのは、次の2つを並べたときでした。
- 自分のパソコン(国内)から、その鍵で SSH → つながる
- GitHub Actions(海外)から、まったく同じ鍵で SSH → つながらない
鍵は同じで、違うのは接続元だけ。ここまで揃えば、鍵ではなく接続元の問題だと判断できます。今まさに詰まっているなら、この比較を最初にやってみてください。
Connection closed と Connection timed out は意味が違う
地味ですが、切り分けではここがいちばん効きました。
| エラー | 意味 | 疑うところ |
|---|---|---|
Connection closed |
接続はできたが、途中でサーバーに切られた | 鍵・認証方式まわり |
Connection timed out |
そもそも返事が返ってこない(到達していない) | ネットワーク・IP制限・ポート |
ただし、closed だからといって鍵の問題とは限りません。 自分がまさにそうでした。最初は closed で、鍵の方式を疑って設定を足していったところ、エラーが Connection timed out に変わりました。「認証で断られている」から「そもそも届いていない」に変わったわけです。ここで「鍵の話じゃないかもしれない」と気づけました。
どちらのエラーでも、まず「自分のパソコンからは通るか」を先に見る。切り分けの優先順位は、エラーの文言より接続元の比較です。
この制限は「アカウント単位」の設定です
国外IPアクセス制限はサーバーアカウントごとの設定です。
なので「前の案件では Actions が普通に動いたのに」が起こり得ます。そのアカウントは「国外アクセス制限」が OFF だっただけかもしれません。案件ごとに設定が違う前提で見ておくと安心です。
多くの記事は「制限をOFFにしよう」と言います。自分はしませんでした
検索すると、たいてい「『SSH設定』の『国外アクセス制限』をOFFにしてください」という解説が出てきます。技術的にはそのとおりで、OFFにすれば Actions からつながります。
ただ、自分はこれを選びませんでした。
この制限をOFFにするのは、SSH の入り口を世界中に開けることだからです。自動デプロイを動かしたいのは、あくまで自分の都合。その都合で、ずっと開いている入り口をひとつ広げてしまうことになります。
このブログでも、WordPress のユーザー名が漏れる穴をふさぐ話や、XMLRPC・REST API を無効化すべきかどうかを書いてきました。どちらも「使っていない入り口は閉じておく」という同じ考え方です。その自分が便利さのために SSH を全世界に開けるのは、さすがに筋が通りません。
もちろん OFF が妥当な場面もあります。海外のメンバーと作業する、Actions 上でビルドまで完結させたい、といったケースです。正解・不正解ではなくトレードオフなので、環境に合わせて選んでもらえればと思います。
自分の場合は「制限は残したい。でも自動化はしたい」だったので、別の方法を探しました。
【解決手順】ローカルから push 連動でデプロイする
考え方はシンプルです。海外のIPで戦うのをやめて、国内のIP(=自分のパソコン)から直接サーバーへ送る。それを git push に連動させます。操作は git push の1回だけで、GitHub と本番が同時に最新になる。これがゴールです。

⚠ 先に1点だけ
この手順の rsync から --no-perms を外さないでください。外すと手元のファイル権限がそのままサーバーへコピーされ、サイトの画像やCSSが 403 で表示されなくなることがあります。理由は記事の後半で説明します。
【STEP1】本番のテーマの場所を、推測せずに確かめる
送り先のパスを確認します。ここは推測しないでください。エックスサーバーは同じサーバーに複数アカウントが同居することがあり、パスの形が案件ごとに違います。
ssh -i ~/.ssh/deploy_key -p 10022 user@svXXXX.xserver.jp
'ls ~/example.com/public_html/wp-content/themes/'
-p 10022… エックスサーバーの SSH ポートですuser@svXXXX.xserver.jp… サーバーIDとホスト名に置き換えてくださいexample.com… 対象ドメインのフォルダ名です
ホスト名・ユーザー名・接続ポートは、さきほどの「SSH設定」画面の 「SSHソフト設定」タブにまとまっています。

テーマの一覧が表示されれば、パスは合っています。これを次のステップで使います。
【STEP2】deploy.sh を置く
リポジトリのルートに deploy.sh を作ります。やることは「rsync で送る」「権限を整える」「配信できているか確かめる」の3つだけです。
ファイルをコピーするコマンドです。FTPソフトの「アップロード」を、画面ではなくコマンドでやるイメージですね。ありがたいのは変わったファイルだけを見分けて送ってくれるところで、毎回まるごと送らずに済みます。通信は SSH に載るので暗号化もされます。macOS なら最初から入っているので、インストールは不要です。
#!/bin/sh
set -e
PROD_KEY="$HOME/.ssh/deploy_key"
PROD_HOST="user@svXXXX.xserver.jp"
PROD_PORT="10022"
REMOTE_PATH="/home/user/example.com/public_html/wp-content/themes/my-theme/"
VERIFY_URL="https://example.com/wp-content/themes/my-theme/style.css"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"; cd "$SCRIPT_DIR"
# --no-perms : 手元のファイル権限をサーバーへ持ち込まない(403事故の防止)
rsync -avz --human-readable --no-perms
--exclude='.DS_Store' --exclude='.git'
-e "ssh -i $PROD_KEY -p $PROD_PORT"
./my-theme/ "$PROD_HOST:$REMOTE_PATH"
# サーバー側で権限を Web の標準(ディレクトリ755・ファイル644)に揃える
ssh -i "$PROD_KEY" -p "$PROD_PORT" "$PROD_HOST"
"find '$REMOTE_PATH' -type d -exec chmod 755 {} + ; find '$REMOTE_PATH' -type f -exec chmod 644 {} +"
# 公開URLで実際に配信できているか確認する
code=$(curl -so /dev/null -w '%{http_code}' "$VERIFY_URL" || echo 000)
[ "$code" = "200" ] && echo "✅ 配信OK($code)" || echo "⚠️ 静的ファイルが HTTP $code です。権限かパスを確認してください"
上の値は全部ダミーなので、鍵の名前・サーバーID・ドメイン・テーマ名は自分の環境に置き換えてくださいね。
./my-theme/ はリポジトリ内のテーマフォルダです。ルートがそのままテーマの中身なら ./ に変えてください(末尾のスラッシュは付けたままに)。
–no-perms は消さないでください。これが無いと手元のフォルダの権限がサーバーへ複製され、サイトの画像やCSSが表示されなくなることがあります。理由は後半で説明しますね。
もうひとつ、REMOTE_PATH は必ずテーマのディレクトリまで指定してください。chmod は指定パスの中身すべてに効くので、public_html 直下などを指定するとサイト全体の権限を書き換えてしまいます。
--delete(サーバー側の余計なファイルを消すオプション)は、あえて付けていません。本番にしか無いファイルを巻き込む事故が怖いので。完全に同期したいときだけ、中身を確認してから足す形がおすすめです。
作ったら実行できるようにします。
chmod +x deploy.sh
この時点で ./deploy.sh を叩けば、もう手動デプロイはできる状態です。
【STEP3】git push に連動させる(pre-push フック)
毎回 ./deploy.sh を打ってもいいのですが、それだと「push したけどデプロイし忘れた」が起こります。そこで git push に連動させます。
決まったタイミングで自動的に走る、自作スクリプトの置き場です。コミットの前、push の前、といったタイミングがあらかじめ用意されています。これを使えば「push したら勝手にデプロイも走る」が作れます。追加のツールは要りません。
今回使う pre-push は名前のとおり push の直前に走ります。.git/hooks/pre-push を作って、次の内容を書きます。
#!/bin/sh
push_main=0
while read _l _ls rref _rs; do case "$rref" in refs/heads/main) push_main=1;; esac; done
[ "$push_main" = "0" ] && exit 0
echo "▶ main を push → 本番へデプロイします..."
if sh "$(git rev-parse --show-toplevel)/deploy.sh"; then exit 0
else echo "❌ デプロイに失敗したので push を中止しました。飛ばしたい場合: git push --no-verify"; exit 1; fi
chmod +x .git/hooks/pre-push
やっているのは「push 先が main なら deploy.sh を実行し、失敗したら push ごと中止する」です。
自分のリポジトリのブランチ名に合わせてください。 上の例は main ですが、master なら refs/heads/master に読み替えます。ここを決め打ちにすると、いつまでもフックが動かず悩むことになるので要注意です。
デプロイ失敗で push も止まるのは、わざとです。GitHub と本番が食い違ったままにならないようにしています。push だけ通したいときは git push --no-verify で回避できます。
なお最後の ⚠️ の疎通確認は警告だけで push は止めません(転送自体は成功しているので)。ここでも止めたいなら、deploy.sh の最終行を exit 1 にすればOKです。
【STEP4】毎回のパスフレーズ入力をなくす
鍵にパスフレーズを設定していると、デプロイのたびに入力を求められます。地味に面倒ですし、エディタの Git 機能から push したときなど、入力欄が出せずに失敗することもあります。
macOS なら、次のコマンドで一度解錠しておけば済みます。
ssh-add --apple-use-keychain ~/.ssh/deploy_key
パスフレーズを消すのではなく、Keychain に預けて自動で使ってもらう形ですね。鍵は守られたままなので、パスフレーズを外すよりこちらがおすすめです。
なお再起動後も効き続けるかは ~/.ssh/config の設定次第です。また聞かれるようなら、もう一度実行するか ~/.ssh/config に書いておくと楽になります。
動作確認
テーマのファイルを1行だけ書き換えて、git push してみてください。
git add .
git commit -m "test: デプロイ確認"
git push origin main
うまくいけば push の途中でデプロイのログが流れ、最後に ✅ 配信OK(200) が出ます。あとは本番サイトで反映を確認してください。
⚠️ 静的ファイルが HTTP 403 です と出た場合は、権限の問題です。 次の章で説明します。
なぜ --no-perms が要るのか
STEP2 でしれっと付けた --no-perms の話です。ここを飛ばすと、サイトの画像やCSSが一斉に表示されなくなることがあります。
rsync でよく使う -a(アーカイブモード)には、権限をそのままコピーする指定が含まれています。手元のフォルダは「自分だけが読み書きできればいい」状態になっていることがあり、その権限が本番へ複製されると、Webサーバーがファイルを読めず、画像やCSSが 403 になります。
やっかいなのは、ページ自体は普通に表示されてしまうことです。多くのレンタルサーバーのように PHP が所有者の権限で動く構成では、HTMLは生成でき、真っ白にはなりません。さらにキャッシュ系プラグインがCSSやJSを別の場所から配信していると、「画像だけ欠けている」ように見えることもあります。これが厄介で、原因が権限だと気づくまでけっこう時間がかかりました。
対策は deploy.sh の3点セットで足ります。手元の権限を持ち込まない(--no-perms)/送ったあと権限を整える(chmod)/配信できているか確かめる(curl)。最初からスクリプトに入れておけば、人間が気をつけなくても防げます。ここは仕組みに任せてしまいましょう。
実際にこの状態を踏んだときの見え方や、403 と 404 の切り分け方、復旧の手順は、別記事にまとめています。今まさに画像が消えている場合は、こちらをどうぞ。
デプロイした直後に、サイトの画像だけが表示されなくなった。ファイルはちゃんと上がっているのに、なぜか出ない。 もし今その…
【遠回りの記録】鍵を疑って1日使った話
ここから先は解決までの経緯です。急いでいるなら読み飛ばしてもらって大丈夫。同じところで詰まったときの参考にどうぞ。
そもそも、なぜ FTP をやめたかったのか
自分の別サイトで、自作テーマを使っています。更新のたびに FTP ソフトを開いて、変更したファイルを選んでアップロード。これがとにかく事故るんです。上げ忘れる。古いファイルを上げる。ローカルと本番が知らないうちに食い違う。
やりたかったのは「コミットして push したら、本番も同じ状態になっている」という、それだけです。
【つまずき①】Actions はパスフレーズを入力できない
最初は素直に GitHub Actions で組もうとしました。前例も多いし、push をきっかけに動かせるので相性がいいはず……と思っていたんです。
そこで引っかかったのが鍵です。自分の鍵にはパスフレーズを設定していたのですが、Actions は自動で走るので、入力する相手がいません。パスフレーズ無しのコピーを作り、そちらを GitHub Secrets に登録しました。
cp ~/.ssh/deploy_key ~/.ssh/deploy_key_nopass
ssh-keygen -p -N "" -f ~/.ssh/deploy_key_nopass
先にコピーを作るのが大事です。ssh-keygen -p は指定したファイルのパスフレーズを書き換えるので、コピーせず元の鍵を指定すると普段使っている鍵からパスフレーズが外れてしまいます。
公開鍵は同じままなので、サーバー側の登録し直しは不要です。
【つまずき②】Connection closed で、鍵の方式を疑った
次に出たのが Connection closed でした。
調べると、最近の OpenSSH では古い方式のRSA鍵(RSA + SHA-1)が既定で無効になっている、という話が出てきます。自分の鍵もRSAだったので、古い方式を許可する設定と診断ログを足しました。
結果から言うと、これは本命ではありませんでした。ただ無駄でもなく、この変更のあとエラーが Connection timed out に変わり、切り分けの入口になりました。切り分け自体は Claude に相談しながら進めていて、1日で抜けられたのはそのおかげでもあります。
ちなみに、これから鍵を作るなら ed25519 形式にしておくと、こういう「古い方式だから弾かれる」問題を避けやすくなります。
つまずきポイントとTips
実際に運用してみて引っかかったところを並べておきます。
pre-pushフックは Git の管理対象外です。.git/hooks/にあるので、クローンし直すと消えます。「新しい環境で push したのにデプロイされない」ときは、まず再設置を疑ってみてください。README に設置コマンドを残しておくと安心です。- 本番のパスは必ず実測する。 推測で書いて存在しないディレクトリにファイルを撒くと、後片付けが面倒です。
- 秘密鍵はリポジトリに入れない。 プライベートでも同じです。鍵は手元の
~/.ssh/だけに。 --deleteは既定でOFFのままに。 本番にしか無いファイル(アップロードされた画像など)を巻き込む可能性があります。- ブランチ名を決め打ちしない。
mainかmasterか、確認してからフックを書きます。 - 初回はアクセスの少ない時間帯に。 何が起きるか読めないので、戻せる手段(バックアップ)を用意してから実行するのが安全です。UpdraftPlus でバックアップを取る方法も書いています。
GitHub Actions とローカル、どちらを選ぶ?
判断材料を整理しておきます。
| GitHub Actions | ローカルから push 連動 | |
|---|---|---|
| 国外アクセス制限 | OFFにする必要がある | ONのままでよい |
| 実行される場所 | GitHub のサーバー(通常は海外) | 自分のパソコン(国内) |
| 自分のパソコンの電源 | 落ちていても動く | 起動していないと動かない |
| ビルド処理 | 組み込みやすい | 手元の環境に依存する |
| 初期設定 | Secrets・ワークフロー | deploy.sh とフック |
ざっくり、こうなります。
- 国外IP制限を維持したい(多くの場合こちら)→ ローカルから push 連動
- 制限をOFFにしてもよく、Actions 上でビルドまで完結させたい → Actions
自分は前者でした。ただローカル方式にも弱点があって、自分のパソコンが動いていないとデプロイされません。複数人での運用や外出先からの更新だと、ここが効いてきます。
まとめ
- 原因は鍵ではなく、エックスサーバーの「SSH国外IPアクセス制限」。 GitHub Actions は海外のサーバーで動くため、接続の時点で弾かれていた
- 切り分けの決め手は「同じ鍵で、自分のパソコンからは通る」。 鍵ではなく接続元の問題だと判断できる
- 解決策は「制限をOFFにする」だけではない。 国内IPである自分のパソコンから rsync で送り、
pre-pushフックでgit pushに連動させれば、制限を残したまま自動化できる --no-permsと権限の正規化は必ず入れる。 これが無いと、サイトの画像やCSSが 403 で表示されなくなることがある
「CI/CD らしいこと」はクラウドで組むものだと思い込みがちですが、今回のようにサーバー側の事情でクラウドから入れないことは普通にあります。そういうときは、実行する場所を手元に移すだけであっさり解決することもあります。「push したら本番も同じ状態になる」という結果さえ得られれば、どこで動いていても構わないわけです。
結果として、セキュリティ設定をひとつも緩めずに FTP 手動更新から抜けられました。同じところで止まっている人の遠回りが、少しでも短くなればうれしいです。
WordPress サイトの管理運用や、こうしたデプロイまわりの自動化・改善のお手伝いもしています。「今の運用のどこを直せばいいか分からない」くらいの段階でも大丈夫です。お問い合わせから気軽にどうぞ。相談だけでもかまいません。

