Next.jsをCloudRunでstandaloneモードで動作させようとしたら無駄にハマってしまった件
概要
タイトルの通り、Next.jsをCloudRunなどのコンテナ環境で動作させるためにGitHubActionsを用いて、CI/CDパイプラインを構築してデプロイしようとした際にハマってしまった際のお話です。
対象読者
- Next.jsをある程度開発して大体のことは理解している方
- CloudRunの使い方がわかる方
- GitHubActionsの使い方がわかる方
- Next.jsをstandaloneモードでGitHubActionsを使用してデプロイしようとしている方
事象
処理の流れは以下です。
- GitHubActions上でビルド
- ビルド成果物をコンテナにコピーしてコンテナをビルド
- gcloudコマンドからOIDC経由でCloudRunにデプロイ ← ❌
- CloudRun起動 ← ❌
ただし、静的ファイルをCloudflare R2から配信したいのでNext.jsビルド①とR2アップロード②、コンテナビルド③のジョブを分けるようにする(②、③は needs: ① とする構成)にしていました。
また、ジョブ間でNext.jsビルドの成果物を共有するためにGitHubActionsのアーティファクトにアップロード/ダウンロードして使用していた
デプロイパイプライン

デプロイパイプライン
原因
デプロイパイプラインの以下のstepで、include-hidden-files: trueにしないと隠しファイルである.nextが除外されていたため。
また、v3までは含まれていたがv4から挙動が変わったっぽいです。
- name: Upload standalone files as artifact
uses: actions/upload-artifact@v4
with:
name: next-standalone
path: frontend/.next/standalone
retention-days: 1
include-hidden-files: true # 指定しないと隠しファイルが除外されるようになっていた
解決法
actions/upload-artifact@v4を使用するstepで、include-hidden-files: trueを指定するようにする。
actions/upload-artifactのv3とv4の違い
actions/upload-artifact v3 → v4 の主な違い
以下、claudeによるまとめ
1. アーティファクト名がワークフロー内で一意である必要がある(破壊的変更)
v4ではアーティファクトの作成がimmutable(不変)になったため、同じ名前のアーティファクトに対して複数回アップロードすることができなくなりました。アップロードを分割するか、名前を変える必要があります。
v3では同じ名前で複数jobからアップロードできていたのに対し、v4では同名のアーティファクトが既に存在する場合 (409) Conflict エラーが発生します。特にmatrix buildで注意が必要です。
なお、v4では overwrite: true を指定することで上書きは可能です。
2. アップロード速度の大幅改善
アップロード速度が大幅に向上しており、最悪ケースのシナリオで90%以上の改善が見られます。
3. アーティファクトIDがすぐに取得できる
アップロード完了後すぐにアーティファクトIDが返され、UIおよびREST APIで即座に利用可能になります。v3ではワークフローの実行が完了するまでIDが利用できませんでした。
4. 隠しファイル(dotfiles)がデフォルトで除外される(v4.4以降)
v4.4以降では隠しファイルがデフォルトで除外されます。隠しファイルをアップロードしたい場合は include-hidden-files: true を指定する必要があります。
5. 1ジョブあたりのアーティファクト数に上限
ワークフロー実行内の個々のジョブごとに、アーティファクト数の上限が500に設定されました。
6. actions/upload-artifact/merge サブアクションの追加
新しいサブアクション actions/upload-artifact/merge が追加されました。これにより、複数のアーティファクトを1つにまとめることができます(v3で同名アップロードで実現していたパターンの代替手段)。
7. GitHub Enterprise Server(GHES)では非対応
upload-artifact@v4以降はGitHub Enterprise Server(GHES)では現時点でサポートされていません。GHESを使用している場合はv3(Node 16)またはv3-node20(Node 20)を使用する必要があります。
8. v3のサポート終了
2025年1月30日以降、GitHub ActionsユーザーはActions v3の upload-artifact および download-artifact を使用できなくなりました。できるだけ早くv4に移行する必要があります。
参考URL
- 公式README(v4ブランチ): https://github.com/actions/upload-artifact/tree/v4
- v4.0.0 リリースノート: https://github.com/actions/upload-artifact/releases/tag/v4.0.0
- 移行ガイド (MIGRATION.md): https://github.com/actions/upload-artifact/blob/main/docs/MIGRATION.md
- v3非推奨アナウンス: https://github.blog/changelog/2024-04-16-deprecation-notice-v3-of-the-artifact-actions/
- Deprecation Discussion: https://github.com/orgs/community/discussions/142581
まとめ
.nextは、.(ドット)から始まる隠しファイルなのをずっと意識してなかったので気付くのに時間がかかってしまいました。この記事を執筆した理由もおそらく次やるときに忘れて同じ失敗をしそうだなと感じたからです。
また、pnpmを使用しているときにstandaloneモードでビルドするとError: Cannot find module 'styled-jsx/style'で起動できないこともあるらしく、それを疑ったりしていましたが本当の原因は違いました。ただ、Error: Cannot find module 'styled-jsx/style'のエラーも以下のようにすれば解決できるらしく勉強にはなりました。
node-linker=hoisted
参考
CDパイプライン
name: Deploy to Cloud Run for Frontend
on:
workflow_dispatch:
push:
tags:
- 'v*'
env:
SERVICE_NAME: tavinikkiy-agent-frontend
GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
GCP_REGION: asia-northeast1
GCP_REPOSITORY: frontend
jobs:
build:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: pnpm/action-setup@v2
with:
version: 10.12.1
node-version: 20
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v1
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- uses: actions/cache@v3
id: node_modules_cache_id
env:
cache-name: cache-node-modules
with:
path: '**/node_modules'
key: ${{ runner.os }}-setup-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('frontend/.npmrc') }}
- if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
name: Install dependencies
run: |
cd frontend
pnpm install
- if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
name: Save node_modules cache
uses: actions/cache/save@v3
with:
path: '**/node_modules'
key: ${{ steps.node_modules_cache_id.outputs.cache-primary-key }}
- uses: actions/cache@v3
name: Cache Next.js build
with:
path: |
${{ github.workspace }}/frontend/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('frontend/pnpm-lock.yaml') }}-${{ hashFiles('frontend/.npmrc') }}
restore-keys: |
${{ runner.os }}-nextjs-
- name: Next.js build
run: |
cd frontend
pnpm run build
env:
NEXT_PUBLIC_FIREBASE_APIKEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_APIKEY }}
NEXT_PUBLIC_FIREBASE_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTHDOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECTID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECTID }}
NEXT_PUBLIC_FIREBASE_APPID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APPID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENTID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENTID }}
NEXT_PUBLIC_FIREBASE_STORAGEBUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGEBUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGINGSENDERID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGINGSENDERID }}
NEXT_PUBLIC_API_BASE_URL: https://api-agent.tavinikkiy.com
R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESSKEY }}
R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRETKEY }}
R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
R2_BUCKET_NAME: ${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}
- name: Upload static files as artifact
uses: actions/upload-artifact@v4
with:
name: next-static
path: frontend/.next/static
retention-days: 1
- name: Upload standalone files as artifact
uses: actions/upload-artifact@v4
with:
name: next-standalone
path: frontend/.next/standalone
retention-days: 1
include-hidden-files: true
- name: Upload assets file as artifact
uses: actions/upload-artifact@v4
with:
name: next-assets
path: frontend/public
retention-days: 1
docker-build:
runs-on: ubuntu-latest
needs: build
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v3
- name: Download static files artifact
uses: actions/download-artifact@v4
with:
name: next-static
path: frontend/.next/static
- name: Download standalone files artifact
uses: actions/download-artifact@v4
with:
name: next-standalone
path: frontend/.next/standalone
- name: Download assets file artifact
uses: actions/download-artifact@v4
with:
name: next-assets
path: frontend/public
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v1
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- name: Configure docker for artifact registry
run: |
gcloud auth configure-docker asia-northeast1-docker.pkg.dev
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./frontend
platforms: linux/amd64
push: true
target: deploy
tags: ${{ env.IMAGE }}
cache-from: type=gha
cache-to: type=gha,mode=max
env:
IMAGE: asia-northeast1-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.GCP_REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}
upload-static:
runs-on: ubuntu-latest
needs: build
steps:
- name: Download static files artifact
uses: actions/download-artifact@v4
with:
name: next-static
path: .next/static
- name: Download assets file artifact
uses: actions/download-artifact@v4
with:
name: next-assets
path: public
- name: Install AWS CLI
run: |
pip install awscli --quiet
- name: Upload static files to R2
run: |
aws s3 sync .next/static s3://${{ secrets.CLOUDFLARE_R2_STATIC_ASSET_BUCKET_NAME }}/_next/static \
--endpoint-url https://${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com \
--cache-control "public, max-age=31536000, immutable" \
--delete && \
aws s3 sync public s3://${{ secrets.CLOUDFLARE_R2_STATIC_ASSET_BUCKET_NAME }}/ \
--endpoint-url https://${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com \
--cache-control "public, max-age=31536000, immutable" \
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESSKEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRETKEY }}
AWS_DEFAULT_REGION: auto
deploy:
runs-on: ubuntu-latest
needs: docker-build
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: pnpm/action-setup@v2
with:
version: 10.12.1
node-version: 20
- uses: google-github-actions/auth@v1
with:
workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.SERVICE_ACCOUNT }}
- name: Deploy to Cloud Run
run: |
gcloud run deploy ${{ env.SERVICE_NAME }} \
--image ${{ env.IMAGE }} \
--project ${{ env.GCP_PROJECT_ID }} \
--region ${{ env.GCP_REGION }} \
--platform=managed \
--allow-unauthenticated \
--service-account=${{ secrets.SERVICE_ACCOUNT }} \
--quiet \
--set-env-vars "NEXT_PUBLIC_FIREBASE_APIKEY=${{ secrets.NEXT_PUBLIC_FIREBASE_APIKEY }}" \
--set-env-vars "NEXT_PUBLIC_FIREBASE_DOMAIN=${{ secrets.NEXT_PUBLIC_FIREBASE_AUTHDOMAIN }}" \
--set-env-vars "NEXT_PUBLIC_FIREBASE_PROJECTID=${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECTID }}" \
--set-env-vars "NEXT_PUBLIC_FIREBASE_APPID=${{ secrets.NEXT_PUBLIC_FIREBASE_APPID }}" \
--set-env-vars "NEXT_PUBLIC_FIREBASE_MEASUREMENTID=${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENTID }}" \
--set-env-vars "NEXT_PUBLIC_FIREBASE_STORAGEBUCKET=${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGEBUCKET }}" \
--set-env-vars "NEXT_PUBLIC_FIREBASE_MESSAGINGSENDERID=${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGINGSENDERID }}" \
--set-env-vars "NEXT_PUBLIC_API_BASE_URL=https://api-agent.tavinikkiy.com" \
--set-env-vars "R2_ACCESS_KEY_ID=${{ secrets.CLOUDFLARE_R2_ACCESSKEY }}" \
--set-env-vars "R2_SECRET_ACCESS_KEY=${{ secrets.CLOUDFLARE_R2_SECRETKEY }}" \
--set-env-vars "R2_ACCOUNT_ID=${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}" \
--set-env-vars "R2_BUCKET_NAME=${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}"
env:
IMAGE: asia-northeast1-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.GCP_REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}
Dockerfile
# --------------------------------------------------
# Production stage
FROM node:20-alpine AS deploy
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --chown=nextjs:nodejs .next/standalone ./
COPY --chown=nextjs:nodejs .next/static ./.next/static
COPY --chown=nextjs:nodejs public ./public
USER nextjs
EXPOSE 8080
ENV PORT=8080
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Discussion