この記事で扱うこと
GitHub ActionsからAmazon EC2上のLaravelアプリケーションへデプロイする際に、次の構成へ移行したときの考え方と実装例を整理します。
- AWSの長期アクセスキーをGitHubに保存せず、OpenID Connect(OIDC)で一時認証する
- GitHub-hosted runnerからEC2へSSH接続せず、AWS Systems Manager Run Commandで処理を実行する
- コマンドの標準出力・標準エラーをCloudWatch Logsへ残す
-
send-commandの受付だけで成功扱いにせず、コマンド終了まで待って結果を判定する
実案件の識別情報を含めないため、リポジトリ名、IAMロール名、EC2インスタンスID、ロググループ名、配置先はすべてダミーです。
対象読者
- GitHub ActionsからEC2へデプロイしたい方
- SSH秘密鍵や長期のAWSアクセスキーをGitHub Actionsで管理したくない方
- Private SubnetのEC2へ踏み台サーバーなしでコマンドを実行したい方
- デプロイ失敗時の出力をCloudWatch Logsで追跡したい方
背景と困っていたこと
EC2上のLaravelアプリケーションをGitHub Actionsから更新するにあたり、当初はSSH接続を前提に考えていました。しかし、GitHub-hosted runnerからPrivate SubnetのEC2へ直接接続するには、踏み台、接続元制御、SSH鍵の配布・ローテーションなどを追加で設計する必要があります。
また、AWS CLIを実行するためにIAMユーザーのアクセスキーをGitHubへ長期間保存すると、漏えい時の影響や定期ローテーションの運用が増えます。
そこで、役割を次のように分けました。
GitHub Actions
└─ OIDCで短期認証情報を取得
└─ SSM SendCommandを実行
└─ EC2上のデプロイスクリプトを実行
└─ stdout / stderrをCloudWatch Logsへ送信
GitHubの公式ドキュメントでも、OIDCを使うことで長期のAWS認証情報をGitHubの保存値として持たずにAWSリソースへアクセスできると説明されています。
前提環境
今回の実装で確認できている前提は次のとおりです。
- GitHub ActionsからAWS IAMロールをOIDCで引き受ける構成を作成済み
- EC2をSystems Managerのマネージドノードとして登録済み
- SSM Run Commandの出力先となるCloudWatch Logsのロググループを作成済み
- EC2上でLaravelのデプロイ処理を実行する想定
OS、PHP、Laravel、AWS CLI、SSM Agentの正確なバージョンは記録から確認できなかったため、公開前に実環境の値を追記する場合は [要確認] です。バージョンを特定できない場合は、無理に記載せず「執筆時点のサポート対象バージョン」とする方が安全です。
調査と設計判断
1. AWS認証は固定キーではなくOIDCを使う
GitHub Actionsのジョブに id-token: write を与えると、ワークフローはGitHubのOIDCトークンを要求できます。これはAWSリソースへの書き込み権限そのものではなく、外部サービスの短期認証情報と交換するためのトークンを取得する権限です。
AWS側ではGitHubのOIDC Providerを信頼し、特定のリポジトリ・ブランチ・Environmentからだけ引き受けられるIAMロールを用意します。
2. EC2への命令はSSM Run Commandを使う
SSM Run Commandなら、GitHub-hosted runnerからEC2のSSHポートへ到達できなくても、AWS API経由でマネージドノードにコマンドを送れます。そのため、デプロイのためだけに踏み台サーバーやSSH秘密鍵をGitHub Actionsへ持たせる必要がありません。
ただし、EC2側には次が必要です。
- SSM Agentが動作していること
- EC2のインスタンスプロファイルにSystems Manager用の権限があること
- SSMの各エンドポイントへ到達できること(インターネット向け経路またはVPC Endpoint)
3. send-commandの戻り値だけでは完了を判定しない
aws ssm send-commandが成功しても、それはコマンドの受付に成功した段階です。EC2上のデプロイ処理が最後まで成功したとは限りません。
そこで、返されたCommand IDを使って aws ssm wait command-executed で終了を待ち、get-command-invocation で最終ステータスを確認します。長い出力はCloudWatch Logsへ送り、GitHub Actions側ではステータスを中心に扱います。
実装例
IAMロールの信頼ポリシー
以下は、GitHub Environment production からのみIAMロールを引き受ける例です。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:example-org/example-repo:environment:production"
}
}
}
]
}
subを repo:example-org/example-repo:* のように広くしすぎると、意図しないブランチやイベントからもロールを引き受けられる可能性があります。可能な限りブランチまたはEnvironmentまで絞ります。
なお、2026年7月15日以降に作成されたGitHubリポジトリでは、OIDCの sub にowner IDとrepository IDを含む形式が既定になっています。既存リポジトリでも設定により形式が異なるため、実際のclaim形式は公開前・適用前にGitHubの最新ドキュメントと対象リポジトリで [要確認] です。
GitHub Actions用IAMロールの権限
まずは対象のSSM DocumentとEC2インスタンスを限定します。次の例では、実際のインスタンスIDへ置き換えてください。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SendDeployCommand",
"Effect": "Allow",
"Action": "ssm:SendCommand",
"Resource": [
"arn:aws:ssm:ap-northeast-1:123456789012:document/AWS-RunShellScript",
"arn:aws:ec2:ap-northeast-1:123456789012:instance/i-0123456789abcdef0"
]
},
{
"Sid": "ReadCommandResult",
"Effect": "Allow",
"Action": [
"ssm:GetCommandInvocation",
"ssm:ListCommandInvocations"
],
"Resource": "*"
}
]
}
複数台へ配布する場合はEC2タグを使った --targets が便利ですが、IAMポリシーも同じ範囲に絞れるかを検証します。最初から ssm:* を許可するのではなく、ワークフローに必要な操作だけを追加します。
GitHub Actionsワークフロー
以下は手動実行できる最小例です。実際にはGitHub Environmentの承認ルールや、テスト完了後だけデプロイジョブを動かす依存関係も設定します。
name: Deploy to EC2
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6.3.0
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ap-northeast-1
role-session-name: deploy-${{ github.run_id }}
mask-aws-account-id: true
- name: Send deploy command and wait
env:
INSTANCE_ID: ${{ vars.EC2_INSTANCE_ID }}
LOG_GROUP_NAME: /example/ssm/deploy
run: |
set -Eeuo pipefail
parameters=$(jq -nc \
--arg sha "$GITHUB_SHA" \
'{commands: ["sudo -u deploy /opt/example/bin/deploy.sh " + $sha]}')
command_id=$(aws ssm send-command \
--document-name AWS-RunShellScript \
--instance-ids "$INSTANCE_ID" \
--comment "Deploy from GitHub Actions" \
--parameters "$parameters" \
--cloud-watch-output-config \
"CloudWatchOutputEnabled=true,CloudWatchLogGroupName=$LOG_GROUP_NAME" \
--query 'Command.CommandId' \
--output text)
echo "SSM command: $command_id"
aws ssm wait command-executed \
--command-id "$command_id" \
--instance-id "$INSTANCE_ID"
status=$(aws ssm get-command-invocation \
--command-id "$command_id" \
--instance-id "$INSTANCE_ID" \
--query 'Status' \
--output text)
echo "SSM status: $status"
test "$status" = "Success"
記事執筆時点の公式READMEでは aws-actions/configure-aws-credentials@v6.3.0 が例示されています。公開時点の最新版と、既存ワークフローで採用しているバージョンは [要確認] です。運用ではメジャーバージョン指定ではなく、commit SHAへ固定する方法も検討します。
EC2側のデプロイスクリプト
GitHub Actions内に長いシェルスクリプトを埋め込まず、EC2側のスクリプトを呼び出す形にしました。これにより、Run Commandへ渡す内容を短く保てます。
#!/usr/bin/env bash
set -Eeuo pipefail
readonly revision="${1:?revision is required}"
readonly app_dir="/var/www/example-app"
cd "$app_dir"
git fetch --prune origin
git checkout --detach "$revision"
composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan queue:restart
この例は構成を示すための最小版です。実運用では、現在のリリースを保持してsymlinkを切り替える方式、失敗時のロールバック、共有ディレクトリ、メンテナンスモード、フロントエンドビルドなどを環境に合わせて設計します。
特に php artisan migrate --force はアプリケーションのロールバックだけでは元に戻せない場合があります。DB変更を同じ自動処理に含めるか、個別承認にするかは別途判断が必要です。
動作確認
構成としては、GitHub Actions → OIDC/IAM → SSM → EC2 → CloudWatch Logsまで作成しました。公開前には、匿名化したサンプルと実環境の差を踏まえて次を再確認します。
- [要確認] 対象のGitHub EnvironmentからだけIAMロールを引き受けられること
- [要確認] 許可していないブランチまたはEnvironmentからの引き受けが拒否されること
- [要確認] SSM AgentがOnlineで、対象EC2がマネージドノードとして表示されること
- [要確認] 正常なrevisionでコマンドの最終ステータスが
Successになること - [要確認] 存在しないrevisionを指定した場合にGitHub Actionsも失敗になること
- [要確認] CloudWatch Logsにstdoutとstderrが保存され、機密値が出力されていないこと
- [要確認] Laravelの疎通、キュー、DB migration、キャッシュ更新に問題がないこと
- [要確認] ロールバック手順が実行できること
Run Commandの出力はCloudWatch Logs上で、概ね CommandID/InstanceID/PluginID/stdout と stderr のストリームに分かれます。標準エラーがない場合はstderrのストリームが作られない点にも注意します。
つまずきやすい点
OIDCでロールを引き受けられない
次を順番に確認します。
- ワークフローに
id-token: writeがあるか - OIDC ProviderのURLが
https://token.actions.githubusercontent.comか - Audienceが
sts.amazonaws.comか - 信頼ポリシーの
subが実際のリポジトリ、ブランチ、Environmentと一致しているか - 新しいimmutable subject claim形式の対象になっていないか
TargetNotConnected になる
GitHub Actions側ではなく、EC2側のSystems Manager接続を確認します。
- SSM Agentの稼働状態
- インスタンスプロファイルの権限
- EC2からSystems Manager関連エンドポイントへの経路
- 対象リージョンとインスタンスIDの組み合わせ
SSMコマンドを送れたのにデプロイ失敗を見逃す
send-commandの終了コードだけでジョブを終えると、EC2上のコマンド完了前に成功扱いになることがあります。Command IDを保存し、waiterと最終ステータス確認を必ず入れます。
また、デプロイスクリプト側でも set -Eeuo pipefail を使い、途中の失敗を後続処理で上書きしないようにします。
CloudWatch Logsに出力されない
--cloud-watch-output-config の指定だけでなく、マネージドノード側がCloudWatch Logsへ書き込む権限を持っているかを確認します。カスタムIAMポリシーを使う場合は、ロググループ・ログストリームの作成とログイベント送信に必要な権限を追加します。
なお、コマンドやアプリケーションが認証情報、環境変数、実データを標準出力へ書けば、その内容もログへ保存されます。set -x の常用や .env の表示は避け、ロググループの保持期間と閲覧権限も設定します。
まとめ
GitHub ActionsからEC2へのデプロイをOIDCとSSM Run Commandで構成すると、次のように責務を分離できます。
- GitHub ActionsはOIDCで短期認証情報を取得する
- IAMロールは特定のリポジトリ・Environmentと必要なSSM操作だけを許可する
- EC2への命令はSSM経由で送り、SSH鍵や踏み台への依存を減らす
- デプロイの詳細はEC2側スクリプトにまとめる
- Command IDで完了を待ち、CloudWatch Logsに実行記録を残す
「SSHを使わない」だけでなく、「どのGitHubコンテキストが、どのEC2に、どのコマンドを送れるか」をIAMで限定し、実行結果を追跡できるところまで含めて設計するのが重要でした。
参考資料
- Configuring OpenID Connect in Amazon Web Services - GitHub Docs(2026-09-25確認)
- aws-actions/configure-aws-credentials(2026-09-25確認)
- Configuring Amazon CloudWatch Logs for Run Command - AWS Systems Manager(2026-09-25確認)
- send-command - AWS CLI Command Reference(2026-09-25確認)
- Configure instance permissions required for Systems Manager(2026-09-25確認)