🚀 GitHub Actions で SFTP 自動デプロイする

長らく、Githubを利用しているのですが、恥ずかしながら、Github Actionを使った事がなかったため、初めてのGithub Action構築です。

今回の環境では、デプロイ先がSFTPのみの接続方式のため、Githubのリポジトリに変更が生じたら、自動でSFTPによりデプロイ先に転送する仕組みを作りたかったのです。

📝
この記事のホスト名、リポジトリ名、パス、ユーザ名はすべて仮の例です。自分の環境の値に置き換えてください。

私密鍵の中身はリポジトリにも記事にも置きません。GitHub Actions の Secret だけに登録します。

git push したら、GitHub Actions が SFTP で公開ディレクトリへファイルを上げる、という流れを組み立てました。

パスワード認証ではなく SSH 鍵認証です。転送は SFTP のみで、SCP や rsync によるリモート転送は使いません。

この記事で作るもの

flowchart LR
  pushMain["push to main"] --> gha["GitHub Actions"]
  gha --> checkout["checkout"]
  checkout --> prepare["exclude .git and .github"]
  prepare --> sftp["SFTP with SSH key"]
  sftp --> remote["sftp.example.com:/home/deploy/public_html/example-site"]
項目 この記事での例 自分の環境で用意するもの
GitHub リポジトリ your-github-id/example-site デプロイしたいリポジトリ(private 推奨)
ローカル作業ディレクトリ ~/git/example-site そのリポジトリのクローン先
SFTP ホスト sftp.example.com ホスト名、または接続用の IP アドレス
SFTP ユーザ deploy サーバ上のログイン名
リモートパス /home/deploy/public_html/example-site 公開したいディレクトリ
SSH 鍵 ~/.ssh/id_ed25519 パスフレーズなしの私密鍵(公開鍵はサーバへ登録)
GitHub Secret 名 SFTP_SSH_PRIVATE_KEY ワークフローから参照する名前。一致させる

仕様:

  • トリガーは main への push
  • .git.github は転送しない
  • 基本形は上書きアップロード(サーバ側だけに残ったファイルは消さない)
  • 完全同期にする場合は、後述の lftp mirror --delete に切り替える

準備段階

ワークフローを書く前に、次を揃えておきます。ここが抜けていると、あとから Actions のログを見ても原因が切り分けにくくなります。

1. 手元のツール
  • GitHub アカウント
  • gitGitHub CLI (gh)(ログイン済み)
  • SSH クライアント(macOS / Linux なら標準で入っていることが多い)

gh のログイン確認:

gh auth status

Git 操作プロトコルは HTTPS でも SSH でも構いません。Secret の登録には repoworkflow スコープが必要です。

2. SFTP サーバ側

次が使える状態か、自分のマシンから確認します。

  • SFTP(通常はポート 22)でログインできる
  • デプロイ先ディレクトリが存在する
  • そのディレクトリへ書き込める

リモートディレクトリは事前に作っておきます。ワークフローの cd は、無いディレクトリでは失敗します。

ssh [email protected] "mkdir -p /home/deploy/public_html/example-site"

手元から SFTP できるかも確認します。

sftp [email protected]

つながったら cd /home/deploy/public_html/example-sitels で、パスと権限を見ておきます。

GitHub Actions の runner は GitHub 側の outbound IP から接続します。サーバのファイアウォールや tcpwrapper で接続元を絞っている場合は、Actions からの SSH を許可する必要があります。

3. SSH 鍵ペア

パスフレーズなしの ED25519 鍵を用意します。既存のデプロイ用鍵があればそれを使い、無ければ新規作成します。

ssh-keygen -t ed25519 -C "github-actions-example-site" -f ~/.ssh/id_ed25519 -N ""
  • 私密鍵: ~/.ssh/id_ed25519(GitHub Secret に登録する。リポジトリには置かない)
  • 公開鍵: ~/.ssh/id_ed25519.pub(サーバの authorized_keys に追加する)

公開鍵をサーバへ登録する例:

ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]

またはサーバ上で:

mkdir -p ~/.ssh
chmod 700 ~/.ssh
cat >> ~/.ssh/authorized_keys << 'EOF'
(id_ed25519.pub の1行を貼る)
EOF
chmod 600 ~/.ssh/authorized_keys

登録後、パスワードを聞かれずに入れることを確認します。

ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes [email protected] true

この記事のワークフローは、パスフレーズ付き私密鍵には対応していません。使うならパスフレーズなしのデプロイ専用鍵にしてください。

4. GitHub リポジトリ

ローカルにプロジェクトがある場合:

cd ~/git/example-site
git init -b main
gh repo create example-site --private --source=. --remote=origin

すでにリポジトリがある場合は、origin が GitHub を向いていることだけ確認します。

git remote -v

private でも Actions は使えます。Settings → Actions → General で、ワークフローが無効になっていないことを確認します。

5. GitHub Secret に私密鍵を登録する

私密鍵はファイルとしてコミットしません。リポジトリの Secret だけに置きます。

CLI:

gh secret set SFTP_SSH_PRIVATE_KEY --repo your-github-id/example-site < ~/.ssh/id_ed25519
gh secret list --repo your-github-id/example-site

Web UI:

  1. リポジトリ → Settings → Secrets and variables → Actions
  2. New repository secret
  3. Name は SFTP_SSH_PRIVATE_KEY(ワークフローと同じ名前)
  4. Secret には私密鍵の全文を貼る
    • -----BEGIN OPENSSH PRIVATE KEY----- から -----END OPENSSH PRIVATE KEY----- まで
    • 末尾の改行も残す

登録後に値は再表示できません。名前と更新日時だけ確認できます。

6. 準備のチェックリスト
手元から sftp [email protected] できる
リモートディレクトリが存在し、書き込める
公開鍵がサーバの authorized_keys にある
パスフレーズなしで SSH 鍵ログインできる
GitHub リポジトリがある
Secret SFTP_SSH_PRIVATE_KEY を登録した
リポジトリで Actions が有効

ここまで終わってから、ワークフローを置きます。

実装

ワークフローファイル

リポジトリに .github/workflows/sftp-deploy.yml を置きます。

ホスト・ユーザ・パスは環境変数に書いてあります。Secret は私密鍵だけです。

私密鍵は env: 経由でファイル化します。GitHub Secret の改行を保ったまま runner 上の sftp に渡すためです。

name: SFTP Deploy

on:
  push:
    branches:
      - main

jobs:
  sftpDeploy:
    name: Deploy files via SFTP
    runs-on: ubuntu-latest
    env:
      remoteHost: "sftp.example.com"
      remoteUser: "deploy"
      remotePath: "/home/deploy/public_html/example-site"
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Prepare files excluding git metadata
        id: prepareFiles
        run: |
          deployDir="${{ runner.temp }}/sftp-deploy"
          mkdir -p "${deployDir}"
          rsync -a --exclude '.git/' --exclude '.github/' ./ "${deployDir}/"
          fileCount=$(find "${deployDir}" -mindepth 1 | wc -l | tr -d ' ')
          echo "fileCount=${fileCount}"
          if [ "${fileCount}" -eq 0 ]; then
            echo "hasFiles=false" >> "${GITHUB_OUTPUT}"
            echo "転送対象が空のため、SFTP をスキップします。"
          else
            echo "hasFiles=true" >> "${GITHUB_OUTPUT}"
          fi

      - name: Deploy via SFTP
        if: steps.prepareFiles.outputs.hasFiles == 'true'
        env:
          SFTP_SSH_PRIVATE_KEY: ${{ secrets.SFTP_SSH_PRIVATE_KEY }}
        run: |
          set -euo pipefail
          set +x

          if [ -z "${SFTP_SSH_PRIVATE_KEY}" ]; then
            echo "SFTP_SSH_PRIVATE_KEY が空です。GitHub Secret を確認してください。" >&2
            exit 1
          fi

          install -d -m 700 "${HOME}/.ssh"
          keyFile="${HOME}/.ssh/id_ed25519"
          printf '%s\n' "${SFTP_SSH_PRIVATE_KEY}" | tr -d '\r' > "${keyFile}"
          chmod 600 "${keyFile}"

          echo "私密鍵の指紋を確認します(中身は出力しません)。"
          ssh-keygen -l -f "${keyFile}"

          ssh-keyscan -H "${remoteHost}" >> "${HOME}/.ssh/known_hosts"

          deployDir="${RUNNER_TEMP}/sftp-deploy"
          batchFile="${RUNNER_TEMP}/sftp-batch"
          {
            echo "cd ${remotePath}"
            (
              cd "${deployDir}"
              find . -mindepth 1 -maxdepth 1 | sed 's|^\./||'
            ) | while IFS= read -r entryName; do
              echo "put -r ${deployDir}/${entryName}"
            done
            echo "bye"
          } > "${batchFile}"

          echo "SFTP バッチ内容:"
          cat "${batchFile}"

          sftp -b "${batchFile}" \
            -i "${keyFile}" \
            -o IdentitiesOnly=yes \
            -o ConnectTimeout=10 \
            "${remoteUser}@${remoteHost}"

remoteHost / remoteUser / remotePath だけ、自分の値に変えてください。

コミットして有効化する
cd ~/git/example-site
git add .github/workflows/sftp-deploy.yml
git commit -m "Add GitHub Actions workflow to deploy via SFTP on main."
git push -u origin main

初回の push で Action が走ります。転送対象が .github だけのときは、準備ステップが空判定して SFTP をスキップします。サイトのファイルを main に載せて push すると、実際の転送が走ります。

確認:

gh run list --repo your-github-id/example-site

またはリポジトリの Actions タブです。

仕組み

  1. main への push でジョブが起動する
  2. actions/checkout がリポジトリを runner に置く
  3. ローカルの rsync.git.github を除いたコピーを作る(これは runner 内の準備。サーバへの転送ではない)
  4. Secret を環境変数から鍵ファイルへ書き出す
  5. ssh-keygen -l で鍵が読めることだけ確認する(指紋は出る。中身は出さない)
  6. ssh-keyscan でホスト鍵を known_hosts に入れる
  7. 各ファイル・ディレクトリに対する put -r のバッチを作り、sftp -b で上書きする

完全同期にする場合

前述の sftp -bput -r は、リポジトリにあるファイルを上書きするだけです。リポジトリから消したファイルはサーバに残ります。

リポジトリの内容と公開ディレクトリを一致させるには、ローカルに無いリモートファイルを消す完全同期が必要です。

サーバが SFTP のみでもできる理由

rsync --delete はリモートでコマンド実行が必要なので、SFTP のみのサーバでは使えません。

lftpsftp:// は GitHub Actions 側(クライアント)で動き、サーバには SFTP プロトコルだけを話します。サーバに lftp もシェルも rsync も不要です。

方法 クライアント サーバに必要なもの
sftp -bput -r runner 標準の sftp SFTP のみで可(上書きのみ)
lftp mirror --delete runner に lftp を入れる SFTP のみで可(完全同期)
rsync --delete runner の rsync リモートでの SSH コマンド実行 + rsync が必要 → 不可

完全同期で追加する準備は、その SFTP ユーザにデプロイ先での 削除権限 があることだけです。

ワークフローの変更点

鍵の渡し方や .git / .github の除外はそのまま使い、転送部分を lftp に切り替えます。

ubuntu-latest には lftp が入っていないので、ジョブ内でインストールします。

- name: Install lftp
  if: steps.prepareFiles.outputs.hasFiles == 'true'
  run: sudo apt-get update && sudo apt-get install -y lftp

Deploy ステップの末尾を、sftp -b から次に変えます。

sftp:connect-program は引用符で囲みます。囲まないとコマンドが ssh だけになり、鍵が渡りません。

あわせて open -u ユーザ, のようにパスワードを空にします。これがないと lftp がパスワード入力待ちになり、鍵認証に進みません。

deployDir="${RUNNER_TEMP}/sftp-deploy"
echo "lftp で ${deployDir}/ を ${remotePath}/ へ完全同期します。"

lftpScript="${RUNNER_TEMP}/lftp-script"
cat > "${lftpScript}" <<EOF
set sftp:auto-confirm yes
set net:timeout 10
set sftp:connect-program "ssh -a -x -i ${keyFile} -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=10"
open -u ${remoteUser}, sftp://${remoteHost}
mirror --reverse --delete --verbose ${deployDir}/ ${remotePath}/
bye
EOF

lftp -f "${lftpScript}"

--reverse はローカル → リモート、--delete はローカルに無いリモートファイルの削除です。末尾の / は、ディレクトリの中身を同期するために残します。

転送対象が空のときは、今までどおり同期しません。--delete 付きの空ディレクトリ同期は、リモートを全削除してしまうためです。

注意

--delete は、サーバ側にだけあるファイルも消します。

  • サーバで手置きした設定
  • アプリが書き出すログやアップロード
  • リポジトリに入れていない .htaccess

残したいものがあるならリポジトリ側に含めるか、mirror--exclude で外します。

リモートパスは公開ディレクトリ本体を指してください。親ディレクトリを指すと、関係ないファイルまで消えます。

この実装の範囲

  • 転送対象が空なら SFTP は実行しない(リモート全削除を防ぐ)
  • 基本形は上書きのみ。完全同期にするなら lftp mirror --delete を使う
  • パスフレーズ付き私密鍵は使わない
  • リモートディレクトリは事前作成が必要

まとめ

準備は「サーバに公開鍵とディレクトリ」「GitHub にリポジトリと Secret」の2系統です。

ワークフロー側は、私密鍵を環境変数経由でファイル化し、Ubuntu runner 上で SFTP する形です。

上書きだけなら sftp -b、リポジトリとサーバを一致させるなら lftp mirror --delete にします。どちらもサーバは SFTP のみで動きます。

main にコンテンツを push すれば、あとは Actions が公開ディレクトリへ反映します。