この記事でできること
GitHub Actions と GitHub Container Registry(GHCR)で、VPS への自動デプロイを作ります。main ブランチに push すると、GitHub Actions のランナーで Docker イメージをビルドして GHCR に push し、VPS ではそのイメージを pull して入れ替えるだけです。私の環境では、ビルドのキャッシュが使えるときは push してから約1分でサイトに反映されます。題材は VPS で公開している Next.js のポートフォリオサイトです。
VPS のメモリに余裕がなく、VPS の上ではビルドしたくない人に向けて書いています。GitHub Actions と Docker Compose の基本は知っている前提です。
前提と環境
2026年10月に構築したときの環境です。
| 項目 | 内容 |
|---|---|
| VPS | Ubuntu 26.04 LTS、x86_64、メモリ 7.7GB |
| VPS のソフトウェア | Docker と Docker Compose、nginx(リバースプロキシ)、certbot(HTTPS) |
| アプリ | Next.js 16.4(output: "standalone")、イメージのベースは node:24-alpine
|
| 手元 | Apple Silicon の Mac |
| Actions |
actions/checkout@v4、docker/setup-buildx-action@v3、docker/login-action@v3、docker/build-push-action@v6、appleboy/ssh-action@v1
|
VPS では、ほかのサービス(ゲームサーバーなど)がメモリを約4GB 使っていて、空きは約3GB です。Next.js のビルドでほかのサービスに影響を出したくないので、VPS ではビルドしないことにしました。
nginx は portfolio.example.com へのリクエストを 127.0.0.1:3000 のコンテナへ流すように設定してあります。nginx と HTTPS の設定は、この記事では扱いません。
記事の中の名前は次のように置き換えています。
- ドメイン:
example.com - GitHub のアカウント:
takibito - VPS への SSH:
~/.ssh/configにHost vpsとして書いてある前提でssh vps
構成
git push(main)
│
▼
GitHub Actions
├ build-and-push:linux/amd64 でビルドし、GHCR に push
│ ghcr.io/takibito/portfolio:latest と :<コミットの SHA>
▼
└ deploy:SSH で VPS に入り、docker compose pull && docker compose up -d
│
▼
https://portfolio.example.com に反映
ジョブは build-and-push と deploy の2つに分けています。deploy には needs: build-and-push を書き、ビルドと push が成功したときだけ動くようにしています。
自動化する前の手動デプロイ
自動化する前は、Mac でイメージをビルドし、docker save の出力を ssh で VPS の docker load に流し込んでいました。Mac は Apple Silicon(arm64)で VPS は x86_64 なので、--platform linux/amd64 を付けてビルドします。
docker buildx build --platform linux/amd64 -t portfolio:latest --load .
docker save portfolio:latest | gzip | ssh vps 'gunzip | docker load'
ssh vps 'cd ~/portfolio && docker compose up -d && docker image prune -f'
この方法ならレジストリがなくても公開できますが、更新のたびに Mac でビルドしてイメージを送る必要がありました。この3行を GitHub Actions に置き換えます。手動の方法は、Actions が止まったときのために今も使えるようにしてあります。
手順
1. デプロイ用の SSH 鍵を作って VPS に登録する
GitHub Actions から VPS に入るための専用の鍵を作ります。普段使っている鍵と分けておくと、デプロイ用の鍵だけを消したり作り直したりできます。
ssh-keygen -t ed25519 -f ~/.ssh/portfolio_deploy -C "gha-deploy portfolio" -N ""
ssh vps 'cat >> ~/.ssh/authorized_keys' < ~/.ssh/portfolio_deploy.pub
pbcopy < ~/.ssh/portfolio_deploy # 秘密鍵をクリップボードへ(次の手順で Secrets に貼る)
sshd_config の AuthorizedKeysFile で鍵のファイルを変えている場合は、そのファイルに追記します。
2. GitHub の Secrets を登録する
リポジトリの Settings → Secrets and variables → Actions で、次の3つを登録します。
| Secret 名 | 値 |
|---|---|
SSH_HOST |
VPS のホスト名 |
SSH_USER |
VPS で docker を実行できるユーザー |
SSH_KEY |
手順1で作った秘密鍵の中身(-----BEGIN の行から -----END の行まで) |
GHCR に push するためのトークンは登録しません。ワークフローに自動で発行される GITHUB_TOKEN に packages: write の権限を与えて使います1。
3. VPS の docker-compose.yml を GHCR のイメージに切り替える
手動デプロイのときは、docker load したイメージ portfolio:latest を使っていました。docker-compose.yml を書き換えて、GHCR のイメージを pull するようにします。
services:
portfolio:
image: ghcr.io/takibito/portfolio:latest
pull_policy: always
ports:
- "127.0.0.1:3000:3000"
restart: unless-stopped
mem_limit: 512m
pull_policy: always にすると、Compose は起動のたびにレジストリからイメージを pull します2。mem_limit はコンテナが使えるメモリの上限で、ほかのサービスのメモリを圧迫しないように 512MB にしています。ポートは 127.0.0.1 にだけ公開し、外からは nginx を通して届くようにしています。
この時点では GHCR にまだイメージがないので、docker compose up はしません。
4. ワークフローを追加して push する
.github/workflows/deploy.yml を作ります。
name: Deploy portfolio
on:
push:
branches: [main]
workflow_dispatch: # 手動実行も可能にする
concurrency:
group: deploy-portfolio
cancel-in-progress: true # 連続 push 時は最新だけデプロイ
env:
IMAGE: ghcr.io/takibito/portfolio
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # GHCR へ push するため
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd ~/portfolio
docker compose pull
docker compose up -d
docker image prune -f
-
concurrencyのcancel-in-progress: trueは同じグループで実行中のワークフローを取り消す設定です3。続けて push したときは、最後の push だけがデプロイされます。 - イメージには
latestとコミットの SHA の2つのタグを付けます。SHA のタグはあとで特定の版に戻すときに使います。 -
cache-toのtype=ghaでビルドのキャッシュを GitHub Actions のキャッシュに保存し、cache-fromで次のビルドのときに読み込みます4。 - イメージ名は小文字で書きます。イメージ名に使えるのは英小文字・数字・区切り文字だけです5。
2026年10月にこのワークフローを動かしたときは、警告が出ました6。actions/checkout@v4、docker/setup-buildx-action@v3、docker/login-action@v3、docker/build-push-action@v6 の4つが Node.js 20 向けのため、Node.js 24 で動かしたという内容です。ジョブは成功しましたが、これから使う場合は、各アクションの新しいメジャーバージョンを確かめてください。
Next.js の Dockerfile は output: "standalone" の出力7を使う3段のマルチステージビルドにしています。
# syntax=docker/dockerfile:1
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:24-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
ワークフローは push した時点で動くので、Secrets と VPS 側の準備が済んでから push します。
git add .github/workflows/deploy.yml
git commit -m "ci: GitHub Actions による自動デプロイを追加"
git push -u origin main
5. GHCR のパッケージの公開範囲を確かめる
初めての push で、GHCR にパッケージ portfolio ができます。GitHub のドキュメントには、初めて公開したパッケージは Private になり、リポジトリと紐付いていても公開範囲は引き継がれないと書かれています8。
ただ、私の場合は公開範囲の設定を変えていないのに、パッケージは最初から Public で、ログインしなくても pull できる状態でした。個人アカウントの公開リポジトリから GITHUB_TOKEN で push した場合です。ドキュメントと違う結果なので、初めて push したあとは、パッケージのページで公開範囲を確かめてください。
Private だった場合は、Public にすれば VPS から匿名で pull できるので1、VPS で docker login をしなくて済みます。公開範囲は GitHub のプロフィールの Packages → portfolio → Package settings → Change visibility で変えられます。一度 Public にしたパッケージは Private に戻せないので8、公開してよいイメージかを先に確かめてください。
Private のままにする場合は、read:packages だけを付けたアクセストークンを作り、VPS で一度だけ docker login ghcr.io をしておきます。
6. 動作確認
push したあとは、次の3点を確かめます。
- GitHub の Actions タブで、
build-and-pushとdeployの2つのジョブが成功している - VPS で
docker compose psを実行すると、コンテナが動いている -
curl -I https://portfolio.example.comが 200 を返す
私の環境では、ワークフローを追加した最初の push は、ビルドのキャッシュがないので2分50秒かかりました。そのあと小さな変更を1つ push すると、キャッシュが使えて1分8秒で終わり、サイトにも反映されました(上の画面)。これ以降は main に push するだけで公開されます。
最初の push の前に確かめること
ワークフローのファイルをコミットしたか
最初の push では、ワークフローが動きませんでした。.github/ などのファイルをコミットしておらず、GitHub には Create Next App の最初のコミットしか届いていなかったのが原因です。git status で未コミットのファイルが残っていないかを確かめてから push してください。
空の public/ が Git に入っているか
Dockerfile の最後の段では COPY --from=builder /app/public ./public で public/ をコピーします。私は Create Next App が置いた SVG を消していたので、public/ は空でした。Git は空のフォルダを記録しないため、そのまま push すると、ランナーで checkout したソースには public/ がなく、この COPY が失敗します。Mac でビルドしていたときは手元に空の public/ があったので、問題になりませんでした。ワークフローを push する前に気づき、public/.gitkeep を追加しました。
deploy ジョブの終了ステータス
deploy のスクリプトは、途中の cd や docker compose pull が失敗しても、最後の docker image prune -f が成功すればジョブ全体が成功になります。シェルのスクリプトの終了ステータスは、最後に実行したコマンドのステータスになるためです。appleboy/ssh-action の README でも、途中で止めたいときはスクリプトの先頭に set -e を書くよう案内されています9。
script: |
set -e
cd ~/portfolio
docker compose pull
docker compose up -d
docker image prune -f
私のワークフローには入れていません。Actions が成功しているのにサイトが変わらないときは、ここを疑うようにしています。
特定の版に戻す
latest のほかにコミットごとの SHA のタグが残るので、VPS で特定の版に戻せます。docker-compose.yml の image を ghcr.io/takibito/portfolio:<戻したいコミットの SHA> に書き換えて、docker compose up -d を実行します。
この状態で次に push しても、docker compose pull は SHA のタグを pull するので、サイトは戻した版のままです。問題を直したコミットを push したあと、image を latest に戻して docker compose pull と docker compose up -d を実行します。ずっと戻しておきたいときは、問題のあるコミットを git revert して push し、正しい版をビルドし直すのが安全です。
さらに安全にするなら
- デプロイ専用のユーザーを作って docker グループに入れ、
SSH_USERをそのユーザーにする -
authorized_keysでデプロイ用の鍵の行頭にfrom=やcommand=を付け、鍵の用途を絞る(GitHub Actions の IP の範囲は広いので、from=の運用には手間がかかります) -
latestを追うのをやめ、SHA のタグを指定してデプロイする(どの版が動いているかが分かりやすくなります)
-
GitHub Docs「Working with the Container registry」 https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry ↩ ↩2
-
Docker Docs「Define services in Docker Compose」(
pull_policy、mem_limit) https://docs.docker.com/reference/compose-file/services/ ↩ -
GitHub Docs「Control workflow concurrency」 https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency ↩
-
Docker Docs「Cache management with GitHub Actions」 https://docs.docker.com/build/ci/github-actions/cache/ ↩
-
distribution/reference の文法の定義(
path-component、alpha-numeric) https://github.com/distribution/reference/blob/main/reference.go ↩ -
GitHub Changelog「Deprecation of Node 20 on GitHub Actions runners」 https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/ ↩
-
Next.js Docs「output」 https://nextjs.org/docs/app/api-reference/config/next-config-js/output ↩
-
GitHub Docs「Configuring a package's access control and visibility」 https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility ↩ ↩2
-
appleboy/ssh-action の README https://github.com/appleboy/ssh-action ↩

