🍥

GitHub Actions + Terraform で構築する S3 自動デプロイ環境

に公開

🌱 はじめに

GitHub Actions を使って静的サイトを AWS S3 に自動デプロイする CI/CD 環境を構築しました。本記事では、Terraform で IAM Role を作成し、GitHub Actions の OIDC 認証で安全にデプロイする方法を、実装時の工夫ポイントや検討事項を含めて解説します。

🚀 実現すること

Git にプッシュすると、自動的に S3 にデプロイ → CloudFront キャッシュ無効化を実行するワークフローを作成します。

開発者が develop ブランチに push
  ↓
GitHub Actions が起動
  ↓
OIDC 認証で AWS にアクセス
  ↓
S3 に全ファイルを同期
  ↓
CloudFront キャッシュを無効化
  ↓
デプロイ完了! ✨

前提条件

  • ✅ S3 + CloudFront による静的サイトホスティング環境がすでに構築済み
  • ✅ GitHub リポジトリに静的ファイル(HTML/CSS/JavaScript)が格納されている
  • ✅ Terraform がローカルにインストールされている

🛠️ Terraform で IAM Role を作成

1. OIDC Provider の作成

GitHub Actions が AWS と連携するための OIDC プロバイダーを作成します。

oidc.tf
resource "aws_iam_openid_connect_provider" "github_oidc" {
  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
  
  # GitHub は AWS によって信頼されたプロバイダーのため
  # thumbprint は実質的に無視されるが、API 要件のため設定
  thumbprint_list = ["0000000000000000000000000000000000000000"]
}

2. IAM Role と Policy の作成

GitHub Actions が AssumeRole できる IAM Role を作成します。

iam.tf

# ------------------------------
# Variables
# ------------------------------
variable "aws_account_id" {
  description = "AWS Account ID"
  type        = string
}

variable "s3_bucket_name" {
  description = "S3 Bucket Name for static site"
  type        = string
}

variable "cloudfront_distribution_id" {
  description = "CloudFront Distribution ID (optional)"
  type        = string
  default     = ""
}

variable "github_org" {
  description = "GitHub Organization or Username"
  type        = string
}

variable "github_repo" {
  description = "GitHub Repository Name"
  type        = string
}

variable "github_branch" {
  description = "GitHub Branch Name"
  type        = string
  default     = "develop"
}

variable "environment" {
  description = "Environment (e.g., dev, staging, prod)"
  type        = string
  default     = "dev"
}

# ------------------------------
# Assume Role Policy
# ------------------------------
data "aws_iam_policy_document" "github_actions_assume_role" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github_oidc.arn]
    }

    # GitHub Actions からの認証であることを確認
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    # 特定のリポジトリとブランチからのみ許可
    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${var.github_org}/${var.github_repo}:ref:refs/heads/${var.github_branch}"]
    }
  }
}

# ------------------------------
# IAM Role
# ------------------------------
resource "aws_iam_role" "github_actions_s3_deploy" {
  name               = "github-actions-s3-deploy-${var.environment}"
  assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json
}

# ------------------------------
# S3 + CloudFront Policy
# ------------------------------
data "aws_iam_policy_document" "s3_deploy_policy" {
  # S3 操作権限
  statement {
    sid    = "S3Sync"
    effect = "Allow"
    actions = [
      "s3:PutObject",
      "s3:GetObject",
      "s3:DeleteObject",
      "s3:ListBucket"
    ]
    resources = [
      "arn:aws:s3:::${var.s3_bucket_name}",
      "arn:aws:s3:::${var.s3_bucket_name}/*"
    ]
  }

  # CloudFront キャッシュ無効化権限(オプション)
  dynamic "statement" {
    for_each = var.cloudfront_distribution_id != "" ? [1] : []
    content {
      sid    = "CloudFrontInvalidation"
      effect = "Allow"
      actions = [
        "cloudfront:CreateInvalidation",
        "cloudfront:GetInvalidation"
      ]
      resources = [
        "arn:aws:cloudfront::${var.aws_account_id}:distribution/${var.cloudfront_distribution_id}"
      ]
    }
  }
}

resource "aws_iam_policy" "s3_deploy_policy" {
  name        = "github-actions-s3-deploy-policy-${var.environment}"
  description = "Policy for GitHub Actions to deploy to S3"
  policy      = data.aws_iam_policy_document.s3_deploy_policy.json
}

resource "aws_iam_role_policy_attachment" "attach_s3_deploy_policy" {
  role       = aws_iam_role.github_actions_s3_deploy.name
  policy_arn = aws_iam_policy.s3_deploy_policy.arn
}

# ------------------------------
# Outputs
# ------------------------------
output "github_actions_role_arn" {
  description = "IAM Role ARN for GitHub Actions"
  value       = aws_iam_role.github_actions_s3_deploy.arn
}

terraform apply 後、出力された github_actions_role_arn を控えます。この ARN を次のステップで GitHub Actions ワークフローに設定します。

🫧 GitHub Actions ワークフローの作成

1. ディレクトリ構成

.github/
└── workflows/
    ├── reusable-deploy.yml  # 再利用可能なワークフロー
    └── dev.yml              # Dev 環境用トリガー

2. 再利用可能なワークフロー

このワークフローは Dev、Production など複数の環境で再利用できるように設計しています。

.github/workflows/reusable-deploy.yml
name: "S3 Static Site Deployment"

on:
  workflow_call:  # 他のワークフローから呼び出し可能にする
    inputs:
      aws_region:
        required: true
        type: string
        description: AWS Region
      role_arn:
        required: true
        type: string
        description: IAM Role ARN to assume via OIDC
      s3_bucket_name:
        required: true
        type: string
        description: Target S3 Bucket Name
      build_directory:
        required: true
        type: string
        description: Directory containing static assets (e.g., 'dist')
      cf_distribution_id:
        required: false  # CloudFront を使わない環境にも対応
        type: string
        description: CloudFront Distribution ID for invalidation (optional)

permissions:
  id-token: write  # OIDC トークンを取得するために必須
  contents: read   # リポジトリのコードを読み取るために必要

jobs:
  deploy:
    name: Deploy to S3
    runs-on: ubuntu-latest
    timeout-minutes: 10  # デプロイが長時間かかる場合は早期に失敗
    env:
      # 環境変数として定義することで、スクリプト内で簡潔に参照可能
      AWS_REGION: ${{ inputs.aws_region }}
      S3_BUCKET: ${{ inputs.s3_bucket_name }}
      BUILD_DIR: ${{ inputs.build_directory }}
      CF_DIST_ID: ${{ inputs.cf_distribution_id }}
    
    steps:
      # ----------------------------------------------------
      # Step 1: Preparation
      # ----------------------------------------------------
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Generate AWS Session Name
        id: session
        run: |
          # リポジトリ名から organization/user 名を除去
          repo="${GITHUB_REPOSITORY#"${GITHUB_REPOSITORY_OWNER}"/}"
          # "リポジトリ名-実行ID-試行回数" の形式でセッション名を生成
          echo "name=${repo}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "${GITHUB_OUTPUT}"

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ inputs.role_arn }}  # OIDC で IAM Role を引き受ける
          aws-region: ${{ inputs.aws_region }}
          role-session-name: ${{ steps.session.outputs.name }}  # 追跡可能なセッション名

      # ----------------------------------------------------
      # Step 2: S3 Sync
      # ----------------------------------------------------
      - name: Sync files to S3
        id: s3_sync  # 後続ステップで outputs を参照するために ID を設定
        run: |
          echo "=========================================="
          echo ">> Starting S3 Sync"
          echo "=========================================="
          echo "Target: s3://${S3_BUCKET}"
          echo "Source: ${BUILD_DIR}"
          echo ""
          
          # S3 同期を実行してログを保存
          # --delete: S3 側にあってローカルにないファイルを削除
          # --no-progress: 進捗表示を無効化
          # --no-cli-pager: ページャーを無効化
          # tee: 標準出力とファイルの両方に出力
          aws s3 sync "${BUILD_DIR}" "s3://${S3_BUCKET}" \
            --delete \
            --no-progress \
            --no-cli-pager \
            2>&1 | tee /tmp/s3-sync.log
          
          # 統計情報をカウント(Summary 表示用)
          UPLOADED_COUNT=$(grep -c "^upload:" /tmp/s3-sync.log 2>/dev/null || echo "0")
          DELETED_COUNT=$(grep -c "^delete:" /tmp/s3-sync.log 2>/dev/null || echo "0")
          
          # 改行や空白を削除(GITHUB_OUTPUT のフォーマットエラーを防ぐ)
          UPLOADED_COUNT=$(echo "${UPLOADED_COUNT}" | tr -d '\n\r ')
          DELETED_COUNT=$(echo "${DELETED_COUNT}" | tr -d '\n\r ')
          
          # GitHub Actions の outputs に保存(後続ステップで使用)
          echo "uploaded=${UPLOADED_COUNT}" >> "${GITHUB_OUTPUT}"
          echo "deleted=${DELETED_COUNT}" >> "${GITHUB_OUTPUT}"
          
          # 削除されたファイルのログを出力(視認性向上のため色付け)
          if [ "${DELETED_COUNT}" -gt 0 ]; then
            RED='\033[0;31m'
            YELLOW='\033[1;33m'
            BOLD='\033[1m'
            NC='\033[0m'
            
            echo ""
            echo -e "${YELLOW}=========================================="
            echo -e "${BOLD}>> Deleted Files (${DELETED_COUNT})${NC}"
            echo -e "${YELLOW}==========================================${NC}"
            grep "^delete:" /tmp/s3-sync.log | while IFS= read -r line; do
              # S3 URL からパスのみ抽出(見やすくするため)
              file_path=$(echo "$line" | sed 's/^delete: s3:\/\/[^\/]*\///')
              echo -e "${RED}[-] ${file_path}${NC}"
            done
            echo -e "${YELLOW}==========================================${NC}"
          fi
          
          echo ""
          echo "[OK] Sync completed! (Uploaded: ${UPLOADED_COUNT}, Deleted: ${DELETED_COUNT})"

      # ----------------------------------------------------
      # Step 3: CloudFront Cache Invalidation (Optional)
      # ----------------------------------------------------
      - name: Invalidate CloudFront Cache
        if: env.CF_DIST_ID != ''  # CloudFront が設定されている場合のみ実行
        run: |
          echo ""
          echo "=========================================="
          echo ">> CloudFront Cache Invalidation"
          echo "=========================================="
          echo "Distribution ID: ${CF_DIST_ID}"
          echo ""
          
          # CloudFront のキャッシュを全て無効化
          aws cloudfront create-invalidation \
            --distribution-id "${CF_DIST_ID}" \
            --paths "/*"
          
          echo ""
          echo "[OK] Cache invalidation completed!"

      # ----------------------------------------------------
      # Step 4: Generate Deployment Summary
      # ----------------------------------------------------
      - name: Generate Deployment Summary
        if: always()  # 前のステップが失敗しても必ず実行
        run: |
          # 前のステップから統計情報を取得
          UPLOADED="${{ steps.s3_sync.outputs.uploaded }}"
          DELETED="${{ steps.s3_sync.outputs.deleted }}"
          
          # デフォルト値(S3 Sync がスキップされた場合の安全策)
          UPLOADED="${UPLOADED:-0}"
          DELETED="${DELETED:-0}"
          
          # GitHub Actions の Job Summary を生成
          # GitHub UI の Summary タブに表示され、デプロイ結果を一目で確認できる
          {
            echo "## Deployment Summary"
            echo ""
            echo "| Item | Value |"
            echo "|------|-------|"
            echo "| **S3 Bucket** | \`${S3_BUCKET}\` |"
            echo "| **Build Directory** | \`${BUILD_DIR}\` |"
            echo "| **Session Name** | \`${{ steps.session.outputs.name }}\` |"  # 追跡用
            echo "| **Uploaded** | ${UPLOADED} files |"
            echo "| **Deleted** | ${DELETED} files |"
            
            # CloudFront の情報(設定されている場合のみ)
            if [ -n "${CF_DIST_ID}" ]; then
              echo "| **CloudFront** | \`${CF_DIST_ID}\` (cache invalidated) |"
            fi
            
            echo ""
            # ステータスメッセージ
            echo "> **Status:** Deployment completed successfully"
          } >> "$GITHUB_STEP_SUMMARY"

ワークフローで工夫したポイント

  1. パラメータ化 📦

    • 環境固有の値(S3 Bucket、IAM Role など)を inputs で外部から注入
    • CloudFront は optional にすることで、CDN なしの環境にも対応
  2. 統計情報の可視化 📊

    • aws s3 sync の結果を解析し、アップロード・削除ファイル数を取得
    • GitHub Actions の Job Summary に見やすい表形式で表示
    • 削除されたファイルは別途色付きで表示
  3. エラーハンドリング 🛡️

    • if: always() で Summary を必ず表示
    • デフォルト値(${UPLOADED:-0})でステップスキップ時も安全
  4. 追跡可能性 🔍

    • AWS Session Name にリポジトリ名、実行 ID、試行回数を含める
    • CloudTrail で後から特定のデプロイを追跡可能

3. Dev 環境用トリガー

.github/workflows/dev.yml
name: "Deploy to S3"

on:
  push:
    branches:
      - develop

jobs:
  dev:
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      aws_region: 'ap-northeast-1'
      role_arn: 'arn:aws:iam::123456789012:role/github-actions-s3-deploy-dev'
      s3_bucket_name: 'my-static-website-bucket'
      build_directory: 'dist'
      cf_distribution_id: 'E1A2B3C4D5E6F7'  # オプション

✅ 動作確認

静的サイトのファイルを変更して develop ブランチにプッシュすると、GitHub の Actions タブで以下のように表示されます。

GitHub Actions の Job Summary には以下のような表が表示されます。

Item Value
S3 Bucket my-static-website-bucket
Build Directory dist
Session Name your-repo-123456789-1
Uploaded 150 files
Deleted 1 files
CloudFront E1A2B3C4D5E6F7 (cache invalidated)

Status: Deployment completed successfully

⚠️ 実装時の検討ポイント

1. デプロイ最適化の検討

気づいた挙動

GitHub Actions で 2 回目以降のデプロイでも、aws s3 sync のログに全ファイルが upload: と表示されることがわかりました。

upload: dist/index.html to s3://...
upload: dist/css/style.css to s3://...
upload: dist/js/app.js to s3://...
...(全ファイル)

原因

aws s3 sync はデフォルトで サイズと最終更新日時 を比較します。GitHub Actions の ephemeral 環境では以下の理由で全ファイルが再アップロードされます。

  1. 毎回クリーンな環境: ローカルに前回のデータがない
  2. タイムスタンプの不一致:
    • ローカルファイル: Git checkout 時のタイムスタンプ(通常は現在時刻)
    • S3 ファイル: 前回アップロード時のタイムスタンプ
    • → 常に「ローカルの方が新しい」と判断される

結果、内容が変わっていなくても全ファイルが再アップロードされてしまいます。

検討した選択肢

「更新したファイルのみアップロードできないか?」と考え、以下のような選択肢を検討しましたが、いずれも不採用としました。

解決策 概要 不採用の理由
--size-only ファイルサイズのみで比較 サイズが同じでも内容が変わっている場合を検出できない
--exact-timestamps タイムスタンプを厳密に比較 Git checkout 時のタイムスタンプと S3 のアップロード時刻が常に異なる
Git commit hash 管理 S3 に .deploy-manifest.txt を保存 静的サイト用バケットに管理ファイルを配置したくない
GitHub Actions Cache 前回デプロイ情報をキャッシュ 保持期間が 7 日間で実用性に欠ける
aws s3api で事前比較 ETag を手動で比較 実装が複雑で、aws s3 sync が内部で同じことをしている

採用した解決策: シンプルさを優先

aws s3 syncが数秒で完了していたため、複雑な差分管理の仕組みを導入するよりも、シンプルさと確実性を優先して全ファイルアップロードの方針にしました。

2. ワークフロー品質チェックのすすめ

GitHub Actions ワークフローを書く際、YAML 構文エラーやシェルスクリプトのエラーに遭遇することがあります。コミット前にチェックすることで、開発効率が向上します。

推奨ツール

1. actionlint 🔍

GitHub Actions ワークフローの構文チェックツールです。

# インストール(macOS)
brew install actionlint

# ワークフローファイルをチェック
actionlint .github/workflows/*.yml

検出できるエラー例

  • YAML 構文エラー(例: 角括弧のクォート忘れ name: [Dev]name: "[Dev]"
  • 未定義の inputs や secrets の参照
  • シェルスクリプトの静的解析(shellcheck と連携)

2. shellcheck 🛡️

シェルスクリプトの静的解析ツールです。actionlint を実行すると、内部的に shellcheck を呼び出してワークフロー内の run: セクションもチェックします。

# インストール(macOS)
brew install shellcheck

# actionlint を実行すると shellcheck も自動で動作
actionlint .github/workflows/dev.yml

📝 さいごに

この記事が、GitHub Actions + terraform の安全な CI/CD 環境構築の参考になれば幸いです! 🎉

えみり〜でした|ωΦ)ฅ

🔗 参考リンク

Discussion