個人開発で使っているNext.js + Express + React Viteの構成、デプロイまわりがかなり場当たり的になっていたので、GHCRを軸にCI/CDを整理し直すことにした。進捗あれば随時追記していく。
そもそもDockerイメージビルド(GHCR)を使用したデプロイとは
Dockerイメージは、ランタイム(Node.jsなど)、依存パッケージ(node_modules)、ソースコード、ビルド成果物(.nextやdistなど)を1つのファイルにまとめたもの。このイメージさえあれば、どの環境でも同じようにアプリが動く。
GHCRを使ったデプロイでは、GitHub Actionsでこのイメージをビルドし、ghcr.io(GitHubのコンテナレジストリ)にpushする。Lightsail側ではそのイメージをpullしてdocker compose up -dするだけでアプリが起動する。本番サーバー上でのビルドが一切不要になるため、サーバーリソースの消費やビルド失敗のリスクがなくなる。
これまでの構成
ローカル環境
Gitについては、下記の4リポジトリ
express-mysql-docker(親)— docker-compose, initdb, db/.envなどインフラ設定のみ。子リポジトリはgitignoreで除外backend(子)— Express APInext-basic(子)— Next.js SSR ユーザー側フロントエンドvite-react-0206(子)— React Vite 管理画面
Next.jsはnpm run devで直接起動、React Viteも同様に直接起動している。BackendとMySQLだけdocker-composeで動かしていた。つまりローカルではDockerに寄せきれておらず、フロント系は素で動かしている状態。
Lightsail(本番)
/var/www/app/にリポジトリをクローンし、docker-compose.prod.ymlでbackend・Next.js・MySQLをDockerコンテナとして動かしている。React Vite(管理画面)はビルド済みの静的ファイルを/var/www/app/backend/public/に配置し、ホスト側のNginxから配信。Nginxはリバースプロキシとして各サービスの前段に立っている。
何が問題だったか As-Is → To-Be
Next.jsの二重ビルド問題
GitHub Actionsでビルド → scpでファイル転送 → Lightsail上でさらにDocker再ビルド、という流れになっていた。せっかくCIでビルドしているのに、本番側でもう一度ビルドが走る無駄な構成。
またLightsail上でビルドするフローのため、AWSコンソール画面でメトリクスでバーストキャパシティの枯渇を確認しました、、Next.jsのビルド中にメモリ不足で502エラーが発生しており、デプロイが不安定な状態です。

Next.jsの環境変数について
NEXT_PUBLIC_API_BASE_URLはGitHub Secretsにあったが、Next.jsのapi.tsではprocess.env.INTERNAL_API_URL(SSR用、docker-compose内部通信)とprocess.env.NEXT_PUBLIC_API_BASE_URL(クライアント用)の2つを使っている。INTERNAL_API_URLはdocker-compose.prod.ymlのenvironmentで渡すのでGHCR化しても問題ない。NEXT_PUBLIC_系はビルド時にインライン化されるのでDockerビルド時にARGで渡す必要がある。
Backend:Lightsailでビルドが走る
Lightsail上でgit pullしてからdocker buildしている。ビルドがサーバー上で走るので、CI/CDとしては中途半端。サーバーのリソースも食うし、ビルドの再現性も担保しにくい。
React Vite:同じくサーバー上でビルド
Lightsail上でgit pull → npm run build。これも本番サーバーでビルドしているので同じ問題。
共通しているのは「ビルドをどこでやるか」が統一されていないこと。GitHub Actionsがあるのに活かしきれていなかった。
現状はViteのvite.config.tsでoutDir: '../backend/public'としてbackendリポジトリにビルド出力しているが、新構成ではadminは独立したnginxコンテナになるので、このbuild出力先は変更が必要。ホスト側Nginxの/admin/のルーティングも、staticファイルのaliasからコンテナへのproxy_passに変わる。
3つのリポジトリは別々?それとも1つのモノレポ?
As-Is
親リポジトリはnext-basic/, vite-react-0206/, backend/をgitignoreしていて、それぞれが独立したリポジトリ。
親リポジトリにはdocker-compose.yml, initdb/, db/.envなどの共通設定だけがある。
To-Be 推奨する構成変更
親リポジトリ(express-mysql-docker)は不要にできる。各リポジトリが自分のDockerイメージをGHCRにpushし、Lightsail側にはdocker-compose.prod.ymlとNginx設定だけ置く。
[GitHub]
backend repo → ghcr.io/あなた/backend:latest
next-basic repo → ghcr.io/あなた/nextjs:latest
vite-react repo → ghcr.io/あなた/admin:latest (nginx+静的ファイル)
[Lightsail]
/var/www/app/
docker-compose.prod.yml ← これだけ管理
initdb/ ← 初期化SQL(既存のまま)
db/.env ← MySQL認証情報
backend/.env ← Backend認証情報
Nginx (ホスト側) → 今とほぼ同じ
docker compose pull && docker compose up -d ← デプロイはこれだけGHCRを使った新しい構成
方針はシンプルで、GitHub Actionsでイメージをビルドしてghcr.ioにpush → Lightsailではpullしてdocker compose up -dするだけにする。
GitHub Actions(ビルド & push)
→ ghcr.io/<user>/backend:latest
→ ghcr.io/<user>/nextjs:latest
→ ghcr.io/<user>/admin:latest(nginx + 静的ファイル)
Lightsail(pull & run)
docker compose -f docker-compose.prod.yml up -d
+ Nginx(ホスト側、既存のまま)
これでLightsail上ではビルドが一切走らなくなる。デプロイはdocker compose pull && docker compose up -dだけ。
┌─────────────────────────────────────────────────────────┐
│ ローカルPC │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Next.js │ │ React Vite │ │
│ │ npm run dev │ │ npm run dev │ │
│ │ :3000 │ │ :5173 │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ │ http://localhost:8888/api │
│ │ │ │
│ ┌──────┴─────────────────┴──────────────────────────┐ │
│ │ docker-compose.yml │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Backend │ │ MySQL │ │ │
│ │ │ Express ├──────┤ :3306 │ │ │
│ │ │ :8888 │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Lightsail │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Nginx (ホスト) │ │
│ │ :443 (SSL) / :80 → 301 redirect │ │
│ │ │ │
│ │ / → proxy_pass http://localhost:3000 (nextjs) │ │
│ │ /api/ → proxy_pass http://localhost:8888 (backend) │ │
│ │ /admin/ → proxy_pass http://localhost:8080 (admin) │ │
│ │ /uploads/ → alias /var/www/app/backend/uploads/ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ┌──────┴──────────────┴──────────────┴───────────────────────┐ │
│ │ docker-compose.prod.yml │ │
│ │ │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────┐ ┌────────────┐ │ │
│ │ │ nextjs │ │ backend │ │ admin │ │ mysql │ │ │
│ │ │ :3000 │ │ :8888 │ │ :8080 │ │ :3306 │ │ │
│ │ │ │ │ │ │ nginx │ │ │ │ │
│ │ │ ghcr.io/ │ │ ghcr.io/ │ │ ghcr.io/│ │ mysql: │ │ │
│ │ │ idw-coder/│ │ idw-coder/│ │ idw- │ │ 8.4.0 │ │ │
│ │ │ nextjs │ │ backend │ │ coder/ │ │ │ │ │
│ │ │ │ │ │ │ admin │ │ │ │ │
│ │ └────────────┘ └─────┬──────┘ └────────┘ └─────┬──────┘ │ │
│ │ │ │ │ │
│ │ └──────────────────────────┘ │ │
│ │ mysql:3306 (内部通信) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ volumes: │
│ mysql_data (既存データ維持) │
│ /var/www/app/backend/uploads/ (バインドマウント) │
└─────────────────────────────────────────────────────────────────┘
┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ GitHub │ │ GitHub Actions │ │ Lightsail │
│ │ │ │ │ │
│ git push ├────►│ 1. docker build ├────►│ 3. docker │
│ to main │ │ 2. push to ghcr.io │ │ compose │
│ │ │ │ SSH │ pull │
│ │ │ ├────►│ 4. docker │
│ │ │ │ │ compose │
│ │ │ │ │ up -d │
└──────────────┘ └──────────────────────┘ └──────────────┘
各リポジトリが独立してこのフローを持つ:
backend → ghcr.io/idw-coder/backend:latest
next-basic → ghcr.io/idw-coder/nextjs:latest
vite-react → ghcr.io/idw-coder/admin:latest
GitHub
├── backend (Express API)
│ ├── Dockerfile
│ └── .github/workflows/deploy.yml
│
├── next-basic (Next.js ユーザー向けフロント)
│ ├── Dockerfile
│ └── .github/workflows/deploy.yml
│
├── vite-react-0206 (React Vite 管理画面)
│ ├── Dockerfile
│ ├── nginx.conf
│ └── .github/workflows/deploy.yml
│
└── express-mysql-docker (インフラ設定のみ ※デプロイには使わない)
├── docker-compose.yml (ローカル開発用)
├── docker-compose.prod.yml (本番用 → Lightsailに配置)
├── initdb/
└── db/.env
参考
https://github.com/growilabs/growi-unique-ogp/blob/master/.github/workflows/release.yml
GHCR以降手順
GitHub リポジトリの設定
| 作業 | 場所 |
|---|---|
| パッケージ公開権限の確認 | リポジトリ Settings → Actions → General → Workflow permissions |
| packages: write 権限の付与 | ↑ 「Read and write permissions」にチェック |
GITHUB_TOKEN に packages: write 権限がないと、GHCRへのpushが失敗します。deploy.yml 内で permissions を明示的に書く方法もあります
PAT(Personal Access Token)の作成
- read:packages(イメージのpullに必要)
Lightsail インスタンス上での初回セットアップ
echo "<PAT>" | docker login ghcr.io -u <GitHubユーザー名> --password-stdin成功すると Login Succeeded と表示されます。
この作業は初回のみで、ログイン情報は ~/.docker/config.json に保存されるため、以降のデプロイでは不要です。
CI/CD関連ファイルを編集しプッシュ
next.jsのディレクトリ/.github/workflows/deploy.yml
name: Deploy Next.js to Lightsail via GHCR
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
build-args: |
NEXT_PUBLIC_API_BASE_URL=${{ secrets.NEXT_PUBLIC_API_BASE_URL }}
NEXT_PUBLIC_ADSENSE_CLIENT_ID=${{ secrets.NEXT_PUBLIC_ADSENSE_CLIENT_ID }}
NEXT_PUBLIC_ADSENSE_SLOT=${{ secrets.NEXT_PUBLIC_ADSENSE_SLOT }}
NEXT_PUBLIC_GOOGLE_AUTH_URL=${{ secrets.NEXT_PUBLIC_GOOGLE_AUTH_URL }}
- name: Deploy to Lightsail
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.LIGHTSAIL_HOST }}
username: ${{ secrets.LIGHTSAIL_USER }}
key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
script: |
cd /var/www/app
docker compose -f docker-compose.prod.yml pull nextjs
docker compose -f docker-compose.prod.yml up -d nextjs
docker image prune -f
/docker-compose.prod.yml
name: Deploy Next.js to Lightsail via GHCR
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
build-args: |
NEXT_PUBLIC_API_BASE_URL=${{ secrets.NEXT_PUBLIC_API_BASE_URL }}
NEXT_PUBLIC_ADSENSE_CLIENT_ID=${{ secrets.NEXT_PUBLIC_ADSENSE_CLIENT_ID }}
NEXT_PUBLIC_ADSENSE_SLOT=${{ secrets.NEXT_PUBLIC_ADSENSE_SLOT }}
NEXT_PUBLIC_GOOGLE_AUTH_URL=${{ secrets.NEXT_PUBLIC_GOOGLE_AUTH_URL }}
- name: Deploy to Lightsail
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.LIGHTSAIL_HOST }}
username: ${{ secrets.LIGHTSAIL_USER }}
key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
script: |
cd /var/www/app
docker compose -f docker-compose.prod.yml pull nextjs
docker compose -f docker-compose.prod.yml up -d nextjs
docker image prune -f
lightsail上のNext.jsのディレクトリはGit管理していましたが、もはやGHCRからイメージをpullしてコンテナ起動なので不要になりましたので削除
rm -rf /var/www/app/next.jsのディレクトリ
MySQLのデータ移行
既存のdocker volumeをそのまま使えば問題ないはず。コンテナを差し替えてもvolumeは維持される。
今後の予定
全体のステップ一覧
- backend — Dockerfile修正 + GitHub Actions workflow作成 既存のDockerfileはマルチステージでdev/prodを分けているが、GHCR用には本番ビルドだけのシンプルなものに書き換える。GitHub Actionsでイメージをビルドしてghcr.ioにpush、その後SSHでLightsailに入って
docker compose pull backend && up -dするワークフローを作る。backendにTypeScriptのビルドステップがある前提で進めるので、もし純JSなら教えてほしい。 - next-basic — Dockerfile修正 + GitHub Actions workflow作成 既存のDockerfileも同様にprod専用に書き換える。
NEXT_PUBLIC_系の環境変数はビルド時にインライン化されるので、DockerfileでARGとして受け取りGitHub Actionsのbuild-argsで渡す構成にする。INTERNAL_API_URLは実行時にdocker-composeのenvironmentから渡すので変更不要。 - vite-react-0206 — Dockerfile新規作成 + vite.config.ts修正 + GitHub Actions workflow作成 node:alpineでビルドしてnginx:alpineに静的ファイルをコピーする2ステージDockerfileを新規作成する。
vite.config.tsのoutDir: '../backend/public'はコンテナ内では意味がないのでdistに変更する。admin画面は/admin/パスで配信するのでnginx設定ファイルも同梱する。 - Lightsail側 docker-compose.prod.yml — GHCR pull構成に書き換え 現在は
build:でローカルビルドしているが、image: ghcr.io/idw-coder/xxx:latestに置き換えてpullするだけの構成にする。adminコンテナを新たに追加し、MySQLとbackendは既存のvolume・env_file設定をそのまま維持する。Next.jsのINTERNAL_API_URL=http://backend:8888もenvironmentに残す。 - Lightsail側 Nginx設定 —
/admin/のルーティング変更 現在はalias /var/www/app/backend/public/でホスト上の静的ファイルを直接配信しているが、adminがコンテナになるのでproxy_passに変更する。adminコンテナは適当なポート(例: 8080)を公開し、Nginxからproxy_pass http://localhost:8080/admin/で転送する。他のlocation(/api/,/,/uploads/)は変更なし。 - Lightsail側でGHCRへのログイン設定 Lightsailからghcr.ioのプライベートイメージをpullするために、GitHub Personal Access Token(read:packages権限)を作成してサーバー上で
docker login ghcr.ioを一度実行する。もしくはリポジトリをpublicにすればログイン不要。認証情報は~/.docker/config.jsonに保存されるので一度やればOK。 - 動作確認・切り替え 既存のコンテナを
docker compose downで停止し(volumeは削除しない)、新しいdocker-compose.prod.ymlでdocker compose up -dする。MySQLのvolumeはmysql_dataという名前付きvolumeなので、composeファイルで同じ名前を使えばデータはそのまま引き継がれる。URL・パス構成が変わっていないことを確認して完了。
1〜3は独立して進められる。4と5はまとめてやれる。6は1コマンド。7は最後に一気にやる。
構成が固まり次第、GitHub Actionsのワークフロー作成から着手する。進捗があれば追記予定。
最終更新:2025年2月