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して次のジョブで取り出します。
| 段階 | トリガー | 典型的なジョブ | 失敗したときの扱い |
|---|---|---|---|
| CI | pull_request、全ブランチのpush | lint、単体テスト、ビルド、脆弱性スキャン | ブランチ保護のstatus checkでマージをブロック |
| CD(デリバリー) | mainへのpush | イメージのビルドとレジストリへのpush、タグ付け | 成果物が作られないだけで、本番は前の版のまま |
| CD(デプロイ) | mainへのpushの後続、タグ、または手動 | SSHで配布しdocker compose up -d | environmentの承認と、前のイメージタグへ戻す手順が必要 |
ワークフローの基本構文:on・jobs・steps
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 testusesは公開アクション(再利用できるステップ)の呼び出し、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 | ブランチ・タグへのpush | mainのビルド、タグでのリリース | branches / tags / pathsで絞る |
pull_request | PRの作成・更新 | テスト、lint、Terraformのplan | forkからのPRにはSecretsが渡らない |
schedule | cron(UTC基準) | 依存関係の更新確認、夜間の脆弱性スキャン | 混雑時は遅延する。既定ブランチでしか動かない |
workflow_dispatch | 手動実行(UI / CLI / API) | 本番デプロイ、緊急ジョブ | inputsでパラメータを受け取れる |
release | GitHub Releaseの公開 | 成果物の添付、配布 | types: [published]で下書きを除外 |
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)があり、古いものから自動削除されます。
- 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を作る必要がありません。
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=maxmetadata-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で済ませると接続先のなりすましに気づけないからです。
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 に登録 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で確認します。
$ 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に任せるのが実務的な落としどころです。
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の追加や別サーバーへの展開はジョブを足すだけで済みます。
次に読む
- Gitブランチ戦略の選び方:CIを前提にしたブランチ保護とPR運用
- 脆弱性管理の始め方:Trivyで実践する定期スキャン:ビルドしたイメージをCIでスキャンする