はじめに
さくらのVPSで動かしているバックエンド(Docker)を、GitHub Actions の手動トリガー(workflow_dispatch)でデプロイできるCD環境にしました。
コンテナイメージは GHCR(GitHub Container Registry) に push、VPS には SSH + Docker Compose で反映します。
前回のフロントエンド記事に続き、バックエンドもCD構築できたので、構成と手順をまとめます。
環境
モノレポ構成
Go + Gin
フロントエンドは別途CD構築済
背景・動機
「一から最小構成でCDを組みたい」という動機で、フロントエンド/バックエンドともにさくらVPSへデプロイできる仕組みを用意しました。
前回はフロントエンドをCD化 → デプロイまで確認。今回は同じ思想でバックエンドをGitHub ActionsでCD化しています。
実例・やってみたこと
実際に作成したworkflowは以下になります。必要に応じてプロジェクト名・レジストリ名など置き換えてください)
name: Deploy Backend (manual, GHCR)
on:
workflow_dispatch:
inputs:
ref:
description: 'Git ref to build (branch/tag/SHA)'
default: 'main'
required: true
image_tag:
description: 'Image tag to deploy (empty = current commit SHA)'
default: ''
required: false
dry_run:
description: 'dry run(no pull/up)'
type: boolean
default: false
env:
IMAGE_NAME: ghcr.io/${{ secrets.GHCR_USERNAME }}/device-platform-backend
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Compute tag
id: tag
run: |
t="${{ inputs.image_tag }}"
[ -z "$t" ] && t="${GITHUB_SHA}"
echo "value=$t" >> $GITHUB_OUTPUT
- name: Build & push backend image
uses: docker/build-push-action@v6
with:
context: ./backend
file: ./backend/Dockerfile.prod
push: true
platforms: linux/amd64
tags: |
${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.value }}
${{ env.IMAGE_NAME }}:latest
deploy:
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: Start ssh-agent (load deploy key)
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.VPS_SSH_KEY }}
# ====== (deploy用) リポジトリをチェックアウト:SCPで配布するため ======
- name: Checkout (for deploy)
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
sparse-checkout: |
backend/compose.prod.yml
sparse-checkout-cone-mode: false
# ====== compose.prod.yml をVPSへ毎回配布 ======
- name: Push compose to VPS
if: ${{ !inputs.dry_run }}
run: |
set -euo pipefail
scp -o StrictHostKeyChecking=accept-new backend/compose.prod.yml \
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:/tmp/compose.prod.yml.new
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} '
set -euo pipefail
mkdir -p /opt/myapp/backend
if [ -f /opt/myapp/backend/compose.prod.yml ]; then
cp /opt/myapp/backend/compose.prod.yml /opt/myapp/backend/compose.prod.yml.bak.$(date +%Y%m%d%H%M%S)
fi
mv /tmp/compose.prod.yml.new /opt/myapp/backend/compose.prod.yml
'
# # ===== Dry-run(計画表示のみ・サーバ状態は変更しない) =====
- name: Plan (dry-run)
if: ${{ inputs.dry_run }}
env:
TAG: ${{ inputs.image_tag || github.sha }}
run: |
set -Eeuo pipefail
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} '
set -Eeuo pipefail
cd /opt/myapp/backend
echo "[plan] services:"; docker compose -f compose.prod.yml config --services
echo "[plan] image to use:"
IMAGE_TAG='"$TAG"' docker compose -f compose.prod.yml config \
| awk "/image:/ {print \" \" \$2}" | sort -u
echo "[plan] verify GHCR manifest"
echo "***" | docker login ghcr.io -u "'"${{ secrets.GHCR_USERNAME }}"'" --password-stdin >/dev/null 2>&1 || true
if docker manifest inspect "'"${{ env.IMAGE_NAME }}:$TAG"'" >/dev/null 2>&1; then
echo "[ok] exists: $TAG"
else
echo "[error] not found: "'"${{ env.IMAGE_NAME }}:$TAG"'" ; exit 1
fi
echo "[plan] current app container:"
docker compose -f compose.prod.yml ps app || true
echo "[plan] would run:"
echo " IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml run --rm migrator"
echo " IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml pull app"
echo " IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml up -d app"
'
# ===== 実デプロイ =====
- name: Deploy on VPS (migrate, pull & up)
if: ${{ !inputs.dry_run }}
env:
TAG: ${{ inputs.image_tag || github.sha }}
run: |
set -euo pipefail
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} '
set -Eeuo pipefail
cd /opt/myapp/backend
# compose に migrator があるか
if ! docker compose -f compose.prod.yml config --services | grep -qx migrator; then
echo "[ERROR] migrator service not found in compose.prod.yml"; exit 1
fi
# GHCR login (pull用)
echo "'"${{ secrets.GHCR_TOKEN }}"'" | docker login ghcr.io -u "'"${{ secrets.GHCR_USERNAME }}"'" --password-stdin
# 1) マイグレーション(冪等)
IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml run --rm migrator
# 2) イメージpull(appのみ)
IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml pull app
# 3) 切替
IMAGE_TAG='"$TAG"' docker compose --env-file ./.env.prod -f compose.prod.yml up -d app
'
- name: Readiness check
if: ${{ !inputs.dry_run }}
run: |
set -e
for i in $(seq 1 30); do
if ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"curl -fsS http://127.0.0.1:3000/api/v1/health | grep -qi '^ok$'"; then
echo "ready"; exit 0
fi
sleep 2
done
echo "App not ready after deploy"
ssh -o StrictHostKeyChecking=accept-new ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"cd /opt/myapp/backend && docker compose -f compose.prod.yml logs --tail=120 app || true"
exit 1
compose.ymlは以下になります。(実際のファイル名は、workflowに合わせています)
name: device_platform
services:
app:
image: ghcr.io/tomitahisaki/device-platform-backend:${IMAGE_TAG:-stable}
env_file: [ ./.env.prod ]
environment:
APP_ENV: production
DB_URL: postgres://${DB_USER}:${DB_PASSWORD}@db:${DB_PORT:-5432}/${DB_NAME}?sslmode=disable
ports: ["127.0.0.1:3000:3000"]
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
env_file: [ ./.env.prod ]
volumes:
- pgdata:/var/lib/postgresql/data
expose: ["5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${DB_USER:-postgres} -h 127.0.0.1 -p $${DB_PORT:-5432} || exit 1"]
interval: 5s
timeout: 5s
retries: 30
start_period: 20s
restart: unless-stopped
migrator:
# app と同じイメージ(中に /app/migrations と migrate バイナリが入っている)
image: ghcr.io/tomitahisaki/device-platform-backend:${IMAGE_TAG:-stable}
depends_on:
db:
condition: service_started
env_file: [ ./.env.prod ]
# 実行コマンドをcommandにおくと、配列として読み取られ、エラーになるためentrypointに追記
entrypoint:
- /bin/sh
- -ceu
- |-
DB_URL="postgres://$${DB_USER}:$${DB_PASSWORD}@db:$${DB_PORT:-5432}/$${DB_NAME}?sslmode=disable"
echo "[migrator] which migrate: $(command -v migrate)"
echo "[migrator] list /app/migrations:"; ls -1 /app/migrations
exec migrate -verbose -path=/app/migrations -database "$$DB_URL" up
volumes:
pgdata:
コードを貼り付けても、一体何をしているか分かりづらいので、順を追って行きたいと思います。
build-and-pushでやっていること
- GHCRにログイン
- QEMU/Buildxセットアップして、異なるアーキテクチャ(例:amd64/arm64)をビルド可能にする土台づくり
- タグ設定 image_tag 未指定なら GITHUB_SHA を採用
- ./backend の Dockerfile.prod でビルド
- IMAGE_NAME:
と :latest を GHCR に push
用語説明 * GHCRとは、GitHub Container Registryです。GitHubが提供するコンテナイメージの保管する場となります。ghcr.io/ユーザー/リポジトリになる。 * QEMUとは、CPUエミュレータ。Buildxと合わせて、ホストと異なるアーキテクチャのイメージをビルド可能します。 * docker/build-push-actionとは、GitHub ActionsでDockerビルド&レジストリpushを行えるアクション。
deployでやっていること
- ssh-agentでVPSへ接続
- compose.ymlをVPSに配布
- deployする
- ssh接続
- IMAGE_TAG指定で、migratorの実行
- appのイメージを新タグで取得し、コンテナ切り替え
この構成できをつけること
- .env.prod はVPSに配置(Git管理しない)
compose.prod.yml から読み込まれるため、/opt/myapp/backend/.env.prod を用意(権限は 600 推奨)
- VPSからGHCRにpullできる状態に
ログイン状態の破損でハマりやすい。いったんログアウト→再ログインが手早いです。(結構、理解に時間がかかりました...)
docker logout ghcr.io || true echo "<GHCR_PAT>" | docker login ghcr.io -u <YOUR_GH_USER> --password-stdin
学び・気づき
まずは、どのようにCDを作るべきかを迷っていましたが、このような方法があることを知らなかったことが学びです。
- イメージを作って、配布する→VPS側でイメージをpullして、起動する
Go + gin + golang-migrateを使用しているのですが、DBとの接続に苦戦してしまい、今回の趣旨とは異なるところで沼ってしまいました。(Railsならサクッと終わらせられる自信が何処かにある)
まとめ
GHCR を使った最小構成のCDで、さくらVPSへ安全にデプロイできるようになりました。
手間はかかりましたが、GitHub Actions がグリーンになる瞬間はやっぱり嬉しいですね。
どなたかの参考になれば幸いです。最後までお読みいただきありがとうございました。