本文へ移動
購読
GUIDE#devtools更新 2026.09.24

GitHub Actionsで始めるCI/CD:テスト・ビルド・デプロイ自動化を基礎から

ワークフロー構文、トリガー、キャッシュとマトリクス、Secrets、GHCRへのDockerビルド、SSH+rsyncでのデプロイ、SHA固定と権限最小化までを実例で解説します。

level
初級
time
約30分
stack
GitHub Actions / Docker / SSH
author
@ryoma_t
// 前提条件
Gitの基本操作とGitHubアカウント / Dockerの基礎(docker build・runが使えること)

CI/CDとは、コードの変更ごとにテストとビルドを自動実行して壊れていないことを確かめる継続的インテグレーション(CI)と、その成果物をステージングや本番へ自動で配布する継続的デリバリー/デプロイ(CD)を合わせた仕組みです。GitHub Actionsはこの一連の処理をリポジトリ内のYAMLファイルで定義し、GitHubが用意した仮想マシン(ランナー)で実行します。

前提はGitHubのリポジトリと、SSHで入れるデプロイ先のLinuxサーバー1台です。題材にはDockerで動くWebアプリを使い、テスト → イメージのビルドとプッシュ → SSHでのデプロイという流れを1本のワークフローに組み立てます。

この記事でわかること

  • ワークフローの構成要素(on / jobs / steps / runs-on)と、ジョブごとにランナーが分かれる意味
  • permissions:ブロック、concurrency、timeout-minutes、actions/cacheのキー設計
  • Secretsの登録とログのマスク、forkからのPRにはSecretsが渡らないという制約
  • GHCRへのDockerイメージのビルド・プッシュと、SSH + rsyncによるデプロイジョブの書き方
  • アクションをコミットSHAで固定する理由と、料金・無料枠の確認のしかた

CI/CDとは:GitHub Actionsで何が自動化できるか

GitHub Actionsの単位は「ワークフロー」(.github/workflows/*.yml)です。イベント(on)をきっかけに1つ以上のジョブ(jobs)が走り、各ジョブはステップ(steps)を順に実行します。ジョブごとに新しいランナー(既定はubuntu-latestの仮想マシン)が割り当てられ、ジョブ間でファイルは共有されません。共有したいものはactions/upload-artifactで成果物として渡すか、コンテナレジストリにpushして次のジョブで取り出します。

段階トリガー典型的なジョブ失敗したときの扱い
CIpull_request、全ブランチのpushlint、単体テスト、ビルド、脆弱性スキャンブランチ保護のstatus checkでマージをブロック
CD(デリバリー)mainへのpushイメージのビルドとレジストリへのpush、タグ付け成果物が作られないだけで、本番は前の版のまま
CD(デプロイ)mainへのpushの後続、タグ、または手動SSHで配布しdocker compose up -denvironmentの承認と、前のイメージタグへ戻す手順が必要

ワークフローの基本構文:on・jobs・steps

.github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test

usesは公開アクション(再利用できるステップ)の呼び出し、runはシェルコマンドです。actions/checkoutを置かないとリポジトリの中身はランナーに存在しません。permissions: contents: readは、ワークフローに自動発行されるGITHUB_TOKENの権限を読み取りだけに絞る宣言で、後述するセキュリティの土台になります。concurrencyのcancel-in-progress: trueは同じブランチへ続けてpushしたとき古い実行を中止する設定、timeout-minutesは既定が360分(6時間)なので、ハングしたジョブが課金枠を食い潰さないように必ず短く指定します。

執筆時点でactions/checkoutやactions/setup-nodeのメジャーバージョンは更新が続いているため、@v4の部分は各アクションのREADMEで最新のメジャーを確認してください。

トリガーの種類と使い分け

イベント発火条件主な用途注意点
pushブランチ・タグへのpushmainのビルド、タグでのリリースbranches / tags / pathsで絞る
pull_requestPRの作成・更新テスト、lint、TerraformのplanforkからのPRにはSecretsが渡らない
schedulecron(UTC基準)依存関係の更新確認、夜間の脆弱性スキャン混雑時は遅延する。既定ブランチでしか動かない
workflow_dispatch手動実行(UI / CLI / API)本番デプロイ、緊急ジョブinputsでパラメータを受け取れる
releaseGitHub Releaseの公開成果物の添付、配布types: [published]で下書きを除外
on: の書き方
on:
  push:
    branches: [main]
    paths:
      - "src/**"
      - "Dockerfile"
      - ".github/workflows/**"
  workflow_dispatch:
    inputs:
      target:
        description: "deploy target"
        type: choice
        options: [staging, production]
        default: staging
  schedule:
    - cron: "0 18 * * 1-5"   # UTC 18:00 = JST 翌03:00。曜日もUTC基準

pathsフィルタは、READMEだけを直したpushでビルドとデプロイが走るのを防ぎます。ただしワークフローファイル自体をpathsに含めておかないと、CIの修正がCIで検証されないという抜けが起きます。

キャッシュとマトリクス

setup-nodeのcache: npmのように、setup系アクションにはパッケージマネージャーのキャッシュが組み込まれています。それ以外はactions/cacheを使い、keyにロックファイルのハッシュを含めることで「依存関係が変わったときだけ作り直す」動きになります。restore-keysは前方一致のフォールバックで、完全一致がなくても直近のキャッシュから復元して差分だけ取得させます。キャッシュにはリポジトリごとの容量上限(執筆時点で10GB)があり、古いものから自動削除されます。

cache と matrix
      - uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: pip-${{ runner.os }}-${{ hashFiles('requirements*.txt') }}
          restore-keys: |
            pip-${{ runner.os }}-

  test-matrix:
    strategy:
      fail-fast: false
      matrix:
        node: [20, 22]
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}

matrixは組み合わせの数だけジョブを並列に作ります(上の例は4ジョブ)。fail-fast: falseにしないと、1つ失敗した時点で残りがキャンセルされ、どの組み合わせが壊れているのか分からなくなります。WindowsとmacOSのランナーはLinuxより実行時間の消費倍率が高いので、本当に必要な組み合わせだけに絞ります。

シークレットの扱い

APIキーやSSH秘密鍵は、リポジトリの Settings → Secrets and variables → Actions に登録し、${{ secrets.NAME }}で参照します。値はログ上で***にマスクされますが、base64に変換したり分割して出力したりすると素通りします。機密でない設定値はvars(Variables)に置くと、ログで読めて変更の追跡もしやすくなります。本番用のSecretsはリポジトリ直下ではなくenvironment(例: production)に紐づけ、required reviewersを設定すると、mainにマージできる人とデプロイを承認できる人を分けられます。

forkからのpull_requestではSecretsが渡されず、GITHUB_TOKENも読み取り専用になります。外部の人がPRの中でecho ${{ secrets.X }}を仕込むのを防ぐ仕様です。これを回避しようとpull_request_targetを使い、そのなかでPR側のコードをcheckoutして実行するのは、任意のコードにSecretsを渡す典型的な事故パターンなので避けてください。

Dockerイメージをビルドしてプッシュする

レジストリはGitHub Container Registry(ghcr.io)がもっとも手軽です。同じリポジトリのGITHUB_TOKENにpackages: writeを与えればログインでき、別のSecretsを作る必要がありません。

.github/workflows/ci.yml(build ジョブ)
  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=ref,event=branch
            type=semver,pattern={{version}}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

metadata-actionはイベントに応じたタグを生成します。type=shaでコミットごとに一意なタグ(sha-3f2a1c9のような形)が付くので、デプロイ先で「どのコミットが動いているか」を追えます。push:の条件式により、PRではビルドだけ行ってレジストリを汚しません。cache-from/to: type=ghaはビルドレイヤーをActionsのキャッシュに保存する設定で、Dockerfileに変更のない層の再ビルドを省きます。

サーバーへデプロイする:SSH + rsyncの例

デプロイ先には専用ユーザー(例: deploy)を作り、ランナー用のED25519鍵を登録します。秘密鍵はDEPLOY_SSH_KEY、ssh-keyscanで取得したホスト鍵はDEPLOY_KNOWN_HOSTSとして、productionのenvironment secretsに置きます。known_hostsを固定するのは、StrictHostKeyChecking=noで済ませると接続先のなりすましに気づけないからです。

terminal(デプロイ先サーバー)
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG docker deploy
sudo install -d -o deploy -g deploy -m 750 /srv/app
# 手元で鍵を作り、公開鍵を /home/deploy/.ssh/authorized_keys に追加する
ssh-keygen -t ed25519 -f deploy_key -C "github-actions" -N ""
ssh-keyscan -H server.example.com    # この出力を DEPLOY_KNOWN_HOSTS に登録
.github/workflows/ci.yml(deploy ジョブ)
  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh && chmod 700 ~/.ssh
          printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
      - name: Sync compose files
        run: rsync -az --delete ./deploy/ deploy@${{ secrets.DEPLOY_HOST }}:/srv/app/
      - name: Pull and restart
        run: |
          ssh deploy@${{ secrets.DEPLOY_HOST }} \
            "cd /srv/app && docker compose pull && docker compose up -d --remove-orphans"

needs: buildとif: github.ref == 'refs/heads/main'で「テストとビルドが通ったmainだけ」を配布し、environment: productionで承認ゲートを挟みます。この形はGitHubのランナーからサーバーへSSHで届くこと、つまりサーバーがインターネットから到達可能であることが前提です。自宅サーバーや社内LANのように外から届かない場合は、サーバー側にセルフホストランナーを置くか、サーバー側で定期的にdocker compose pullする「pull型」に切り替えます。compose.yaml側の書き方はDocker Compose入門を参照してください。

動作確認

実行状況はリポジトリのActionsタブか、GitHub CLI(gh)で追えます。デプロイ後はサーバー側で「どのコミットのイメージが動いているか」をdocker compose imagesで確認します。

terminal
$ gh run list --workflow CI --limit 2
STATUS  TITLE             WORKFLOW  BRANCH  EVENT  ID           ELAPSED  AGE
✓       add deploy job    CI        main    push   17734512345  3m12s    6m
✓       fix cache key     CI        main    push   17734498765  2m58s    1h

$ gh run view 17734512345 --log-failed     # 失敗時はここで原因を読む

$ ssh [email protected] "cd /srv/app && docker compose images app"
CONTAINER    REPOSITORY              TAG           IMAGE ID       SIZE
app-app-1    ghcr.io/example/app     sha-3f2a1c9   9d1e0c8b2a7f   142MB

つまずきポイント

症状・エラーメッセージ原因対処
Error: Process completed with exit code 1.だけで詳細がない複数行のrunはbash -e相当で動き、最初に失敗したコマンドで止まる。本当のエラーはその数行上にあるgh run view --log-failedで失敗ステップのログ全体を読む
Permission denied (publickey).Secretを登録したenvironmentとジョブのenvironment:が不一致、または秘密鍵の末尾改行が欠けているSecretの登録場所を確認し、printf '%s\n'で書き出す。サーバー側の~/.sshは700、authorized_keysは600
Host key verification failed.DEPLOY_KNOWN_HOSTSが空か、ssh-keyscanしたホスト名と接続に使うホスト名(IPと名前)が違う接続に使うのと同じホスト名でssh-keyscan -Hし直す
denied: permission_denied: write_packageジョブにpackages: writeがない、または同名パッケージが別リポジトリに紐づいているpermissions:を追加。パッケージ設定のManage Actions accessで当該リポジトリを許可

セキュリティ:権限の最小化とアクションのバージョン固定

リポジトリ設定の Actions → General → Workflow permissions を「Read repository contents and packages permissions」(読み取り専用)にし、書き込みが必要なジョブだけpermissions:で個別に与えます。uses: some/action@v4のようなタグ指定は、タグが後から別のコミットへ付け替えられる可能性があるため、サプライチェーン攻撃の入口になります。@<40桁のコミットSHA> # v4.2.2の形でSHAに固定し、更新はDependabotに任せるのが実務的な落としどころです。

.github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

クラウドへのデプロイでは、長期有効なアクセスキーをSecretsに置く代わりに、OIDC(OpenID Connect)でランナーが短命トークンを取得する方式が使えます。AWSならaws-actions/configure-aws-credentialsにrole-to-assumeを渡すだけで、ジョブ側にid-token: write権限が必要です。具体例はTerraformでAWSをコード管理する記事のCI節で扱います。

セルフホストランナーは、そのマシン上でリポジトリのコードをそのまま実行します。パブリックリポジトリで使うと、誰かのPRに含まれるコードが自宅サーバーや社内マシンで動くことになるため、GitHub自身がプライベートリポジトリ限定を推奨しています。

料金と無料枠の考え方

執筆時点では、パブリックリポジトリでのGitHubホストランナー(標準サイズ)の利用は無料、プライベートリポジトリはプランごとに月間の実行時間とストレージの無料枠があり、超過分は分単位の従量課金です。ランナーのOSによって消費倍率が異なり、macOSがもっとも高く、次いでWindows、Linuxが基準です。具体的な分数・単価・倍率は変更されるため、GitHub Docsの「GitHub Actionsの課金」ページで確認してください。

枠を守る設定は4つです。concurrencyで古い実行を中止する、pathsフィルタでドキュメントだけの変更を除外する、timeout-minutesでハングを止める、actions/upload-artifactのretention-daysを短くしてストレージ課金を抑える。自宅サーバーやオンプレのマシンをセルフホストランナーにすれば実行時間の課金は発生しませんが、前述のとおりプライベートリポジトリ限定で使います。

よくある質問

GitHub Actionsは無料で使えますか?

執筆時点では、パブリックリポジトリなら標準のGitHubホストランナーを無料で使えます。プライベートリポジトリはプランごとの月間無料枠の範囲内なら追加費用なしで、超過分は実行時間(分)とストレージに対する従量課金です。最新の枠と単価はGitHubの料金ページで確認してください。

CIとCDの違いは?

CI(継続的インテグレーション)は変更のたびにテスト・ビルドを自動実行して「壊れていないこと」を確認する工程、CD(継続的デリバリー/デプロイ)はその成果物をレジストリやサーバーへ自動で届ける工程です。この記事のワークフローでは、testジョブがCI、buildとdeployジョブがCDにあたります。

セルフホストランナーは必要?

デプロイ先がインターネットから到達できない(自宅LAN、社内ネットワーク)場合、GPUや特定ハードウェアが必要な場合、実行時間の課金を避けたい場合に検討します。それ以外はGitHubホストランナーで十分で、運用の手間(OS更新、ランナーの更新、隔離)を負わずに済みます。

まとめ

ワークフローはpermissions: contents: read、concurrency、timeout-minutesの3点を最初から入れておき、テスト → ビルド → デプロイをneedsで直列に繋ぎます。イメージはtype=shaタグでコミットと紐づけ、デプロイはenvironmentの承認と固定したknown_hostsで守ります。アクションはSHA固定 + Dependabot、Secretsはenvironment単位、forkのPRには渡さない。この土台があれば、CIの追加や別サーバーへの展開はジョブを足すだけで済みます。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。