Loading
Portfolio
Rayden Li
CI/CD · Docker · Google Cloud Platform · Next.js
This guide builds a production Docker image for an existing Next.js application, stores it in Artifact Registry, and deploys it to Cloud Run through Cloud Build.
Next.js source -> Cloud Build -> Docker image -> Artifact Registry -> Cloud Run -> HTTPS URL
The example uses Next.js standalone output because it copies only the files required at runtime into .next/standalone. Next.js also generates a minimal server.js; public and .next/static must be copied separately. Next.js standalone output documentation
next.config.js — enables standalone output.Dockerfile — builds and runs the app as a non-root user..dockerignore — keeps local artifacts and secrets out of the build context.cloudbuild.yaml — builds, pushes, and deploys the image.You need:
package.json, package-lock.json, and a working npm run build script.Current Next.js releases require Node.js 20.9 or newer. This tutorial pins the Node.js 24.13 slim image used by the official Next.js Docker example at the time of verification. Review and update the pinned LTS version during normal dependency maintenance. Next.js system requirements · official Next.js Docker example
Run all file and build commands from the Next.js project root.
gcloud init authenticates the CLI and creates or updates its active configuration. Google Cloud CLI initialization
gcloud init
Choose one region for Cloud Build, Artifact Registry, and Cloud Run. Keeping them together makes the configuration easier to reason about and avoids unnecessary cross-region transfer.
export PROJECT_ID="your-google-cloud-project-id"
export REGION="us-central1"
export REPOSITORY="nextjs-images"
export SERVICE_NAME="nextjs-app"
export BUILD_SA="cloud-build-nextjs"
export RUNTIME_SA="cloud-run-nextjs"
export BUILD_SA_EMAIL="${BUILD_SA}@${PROJECT_ID}.iam.gserviceaccount.com"
export RUNTIME_SA_EMAIL="${RUNTIME_SA}@${PROJECT_ID}.iam.gserviceaccount.com"
export DEPLOYER_ACCOUNT="$(gcloud config get-value account)"
gcloud config set project "$PROJECT_ID"
gcloud config set run/region "$REGION"
Use the same PROJECT_ID, REGION, REPOSITORY, and SERVICE_NAME values throughout the guide.
Cloud Build's Cloud Run deployment guide requires Cloud Build, Cloud Run, Artifact Registry, and Resource Manager. This tutorial also enables IAM for the dedicated service accounts. Deploying to Cloud Run using Cloud Build
gcloud services enable \
cloudbuild.googleapis.com \
run.googleapis.com \
artifactregistry.googleapis.com \
cloudresourcemanager.googleapis.com \
iam.googleapis.com
Create or update next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig
If your project already has a Next.js configuration, add only output: 'standalone' and preserve its other settings. If it uses ESM, put the equivalent object in next.config.mjs and use export default nextConfig.
Standalone output is not compatible with a custom Next.js server without additional work; it generates its own minimal server.js. Next.js custom server guidance
Create Dockerfile:
# syntax=docker/dockerfile:1
ARG NODE_VERSION=24.13.0-slim
FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN mkdir -p public && npm run build
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=8080
ENV HOSTNAME=0.0.0.0
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 8080
CMD ["node", "server.js"]
This is an npm-only example to keep the deployment small. Adapt the dependency command if the project uses pnpm, Yarn, or Bun.
Why these details matter:
npm ci uses the committed lockfile for a reproducible dependency install.node user.0.0.0.0 and the injected PORT; port 8080 is the default. Cloud Run container runtime contractPORT and HOSTNAME for its generated standalone server. Next.js standalone server settingsmkdir -p public keeps the image build working for applications that do not have a public directory.
.dockerignoreCreate .dockerignore:
node_modules
.next
.git
.gitignore
.DS_Store
npm-debug.log*
.env*
!.env.example
README.md
cloudbuild.yaml
Ignoring .env* prevents local credentials from entering the Docker build context. Next.js recommends keeping environment files out of version control and exposing only intentionally public values with the NEXT_PUBLIC_ prefix. Next.js production checklist
This step is optional but catches application and Docker errors before a cloud build.
docker build --tag "${SERVICE_NAME}:local" .
docker run --rm --publish 8080:8080 "${SERVICE_NAME}:local"
Open http://localhost:8080 or, in another terminal, run:
curl --fail --show-error http://localhost:8080
Stop the container with Ctrl+C.
Artifact Registry image names use the form REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/IMAGE:TAG. Artifact Registry repository and image names
gcloud artifacts repositories create "$REPOSITORY" \
--project="$PROJECT_ID" \
--location="$REGION" \
--repository-format=docker \
--description="Next.js Cloud Run images"
Verify it:
gcloud artifacts repositories describe "$REPOSITORY" \
--project="$PROJECT_ID" \
--location="$REGION"
The repository must exist before Cloud Build can push to it. Create an Artifact Registry repository
Use one identity for the build/deploy pipeline and another for the running application:
gcloud iam service-accounts create "$BUILD_SA" \
--project="$PROJECT_ID" \
--display-name="Next.js Cloud Build deployer"
gcloud iam service-accounts create "$RUNTIME_SA" \
--project="$PROJECT_ID" \
--display-name="Next.js Cloud Run runtime"
Do not create or download service-account keys. Cloud Build and Cloud Run attach these identities directly.
Run these commands as a project IAM administrator. Google's Cloud Build-to-Cloud-Run guide lists Cloud Run Admin, Storage Admin, Artifact Registry Writer, Logs Writer, and Cloud Build Editor for the build identity. Required Cloud Build deployment roles
for ROLE in \
roles/run.admin \
roles/storage.admin \
roles/artifactregistry.writer \
roles/logging.logWriter \
roles/cloudbuild.builds.editor
do
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="serviceAccount:${BUILD_SA_EMAIL}" \
--role="$ROLE"
done
Allow the build identity to attach the runtime identity to the Cloud Run service:
gcloud iam service-accounts add-iam-policy-binding "$RUNTIME_SA_EMAIL" \
--project="$PROJECT_ID" \
--member="serviceAccount:${BUILD_SA_EMAIL}" \
--role="roles/iam.serviceAccountUser"
The Service Account User role supplies iam.serviceAccounts.actAs, which is required to attach a service account to a resource. Service account permissions
Allow your signed-in account to submit builds and use the build identity:
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
--member="user:${DEPLOYER_ACCOUNT}" \
--role="roles/cloudbuild.builds.editor"
gcloud iam service-accounts add-iam-policy-binding "$BUILD_SA_EMAIL" \
--project="$PROJECT_ID" \
--member="user:${DEPLOYER_ACCOUNT}" \
--role="roles/iam.serviceAccountUser"
If DEPLOYER_ACCOUNT is a group, workforce identity, or service account rather than a Google user, use the matching IAM member type instead of user:.
Create cloudbuild.yaml:
steps:
- id: build
name: gcr.io/cloud-builders/docker
args:
- build
- --tag
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_SERVICE_NAME}:$BUILD_ID
- .
- id: push
name: gcr.io/cloud-builders/docker
args:
- push
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_SERVICE_NAME}:$BUILD_ID
- id: deploy
name: gcr.io/google.com/cloudsdktool/cloud-sdk
entrypoint: gcloud
args:
- run
- deploy
- ${_SERVICE_NAME}
- --image
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_SERVICE_NAME}:$BUILD_ID
- --project
- $PROJECT_ID
- --region
- ${_REGION}
- --service-account
- cloud-run-nextjs@$PROJECT_ID.iam.gserviceaccount.com
- --no-invoker-iam-check
- --quiet
images:
- ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_SERVICE_NAME}:$BUILD_ID
substitutions:
_REGION: us-central1
_REPOSITORY: nextjs-images
_SERVICE_NAME: nextjs-app
options:
logging: CLOUD_LOGGING_ONLY
Cloud Build provides $PROJECT_ID and $BUILD_ID automatically. User substitutions must begin with an underscore, so this file uses ${_REGION}, ${_REPOSITORY}, and ${_SERVICE_NAME}. Cloud Build substitutions
The explicit push step uploads the image before the deploy step. The images entry records the image in the build results. This follows Google's documented build, push, and deploy pattern. Cloud Build container images · Cloud Build to Cloud Run
CLOUD_LOGGING_ONLY is required here because the next step runs the build with a user-managed service account; user-managed build identities cannot use a Google-owned build log bucket. User-managed Cloud Build service accounts
If you changed the runtime service-account name, update cloud-run-nextjs@$PROJECT_ID.iam.gserviceaccount.com in the YAML.
Submit the current directory to Cloud Build and override the example substitutions with your chosen values:
gcloud builds submit . \
--project="$PROJECT_ID" \
--region="$REGION" \
--config=cloudbuild.yaml \
--service-account="projects/${PROJECT_ID}/serviceAccounts/${BUILD_SA_EMAIL}" \
--substitutions="_REGION=${REGION},_REPOSITORY=${REPOSITORY},_SERVICE_NAME=${SERVICE_NAME}"
The build performs three operations in order:
REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/SERVICE_NAME:BUILD_ID.Cloud Run revisions are immutable. When a tag is deployed, Cloud Run resolves it to a digest so that revision continues serving the same image. Deploying Cloud Run container images
Get the stable service URL:
export SERVICE_URL="$(gcloud run services describe "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--format='value(status.url)')"
echo "$SERVICE_URL"
curl --fail --show-error --location "$SERVICE_URL"
A Cloud Run service keeps its stable run.app URL as new revisions are deployed. Managing Cloud Run services
Check which image and runtime identity are active:
gcloud run services describe "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--format='yaml(status.url,status.latestReadyRevisionName,spec.template.spec.serviceAccountName,spec.template.spec.containers[0].image)'
Cloud Run services are private by default. For a private first deployment, remove --no-invoker-iam-check from cloudbuild.yaml. To make the service private after it has already been public, remove that flag from the pipeline and re-enable the check explicitly:
gcloud run services update "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--invoker-iam-check
gcloud run services add-iam-policy-binding "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--member="user:${DEPLOYER_ACCOUNT}" \
--role="roles/run.invoker"
Test the private service with an identity token:
curl --fail --show-error \
--header "Authorization: Bearer $(gcloud auth print-identity-token)" \
"$SERVICE_URL"
For a public service, Google currently recommends disabling the Invoker IAM check with --no-invoker-iam-check. Cloud Run authentication overview · public access · testing private services
Commit and test the application change, then run the same build command:
gcloud builds submit . \
--project="$PROJECT_ID" \
--region="$REGION" \
--config=cloudbuild.yaml \
--service-account="projects/${PROJECT_ID}/serviceAccounts/${BUILD_SA_EMAIL}" \
--substitutions="_REGION=${REGION},_REPOSITORY=${REPOSITORY},_SERVICE_NAME=${SERVICE_NAME}"
Each build gets a different $BUILD_ID, so the pipeline never overwrites a latest tag. Cloud Run creates a new revision and sends traffic to it after deployment succeeds.
To roll back, list the revisions and direct all traffic to a known-good one:
gcloud run revisions list \
--service="$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION"
gcloud run services update-traffic "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--to-revisions="REVISION_NAME=100"
Replace REVISION_NAME before running the second command. Cloud Run supports rollback and percentage-based traffic migration between revisions. Cloud Run rollbacks and traffic migration
Use --update-env-vars for non-sensitive server-side configuration:
gcloud run services update "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--update-env-vars="APP_ENV=production"
Avoid --set-env-vars unless you intend to replace the entire existing set; Google documents that it removes variables not included in the new list. Cloud Run environment variables
NEXT_PUBLIC_* values are different: Next.js embeds them into browser JavaScript during next build, so changing them at Cloud Run runtime does not change an already built client bundle. Supply those non-secret values during the Docker build when your application needs them. Never put secrets in a NEXT_PUBLIC_* variable. Next.js environment variables
Use Secret Manager for API keys, database passwords, and other credentials. Google recommends Secret Manager rather than plain Cloud Run environment variables for sensitive values. Cloud Run secrets
gcloud services enable secretmanager.googleapis.com
gcloud secrets create API_KEY \
--project="$PROJECT_ID" \
--replication-policy=automatic
# Type the secret value, then press Ctrl+D. It is not stored in shell history.
gcloud secrets versions add API_KEY \
--project="$PROJECT_ID" \
--data-file=-
gcloud secrets add-iam-policy-binding API_KEY \
--project="$PROJECT_ID" \
--member="serviceAccount:${RUNTIME_SA_EMAIL}" \
--role="roles/secretmanager.secretAccessor"
gcloud run services update "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--update-secrets="API_KEY=API_KEY:1"
Pin an explicit secret version for an environment variable. Update the service to a newer version when you rotate it.
List recent builds and inspect the failed build:
gcloud builds list --project="$PROJECT_ID" --region="$REGION" --limit=10
gcloud builds describe BUILD_ID --project="$PROJECT_ID" --region="$REGION"
Cloud Build exposes status, step results, artifacts, and a log URL through the build result. Viewing Cloud Build results
Common causes:
npm ci fails: package.json and package-lock.json disagree. Run npm install locally, test, and commit the updated lockfile..next/standalone is missing: confirm output: 'standalone' is in the active Next.js config and npm run build succeeds.PERMISSION_DENIED: confirm the repository exists in REGION and the build account has roles/artifactregistry.writer._REGION, _REPOSITORY, and _SERVICE_NAME values exactly as shown.PORTRead Cloud Run logs:
gcloud run services logs read "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--limit=50
Cloud Run automatically sends request, container, and system logs to Cloud Logging. Cloud Run logging
Check that the image starts with node server.js, listens on 0.0.0.0, and uses the injected PORT. Cloud Run also requires a Linux 64-bit image; building with Cloud Build avoids common ARM-laptop architecture mismatches. Cloud Run troubleshooting
403 ForbiddenThe service is private or an organization policy blocked unauthenticated access. Either:
roles/run.invoker and send an identity token, as shown in the private-service section; or--no-invoker-iam-check.Confirm the latest ready revision and image:
gcloud run services describe "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--format='yaml(status.latestReadyRevisionName,status.traffic,spec.template.spec.containers[0].image)'
If you previously configured a traffic split, a new revision might not receive all traffic. Send traffic to the latest revision:
gcloud run services update-traffic "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION" \
--to-latest
Cloud Run's writable filesystem is in-memory and does not persist after an instance stops. Next.js also keeps its default server cache on each instance, so applications that rely on ISR or on-demand revalidation can temporarily return different cached content from different instances. If that consistency matters for your application, configure a shared Next.js cache and tag coordination rather than relying on the container filesystem. Next.js self-hosting and cache coordination · Cloud Run filesystem behavior
.env*, credentials, and service-account keys out of Git and the Docker build context.Build-ID tags intentionally accumulate. Artifact Registry cleanup policies can delete old images while retaining a recent recovery window, and Google recommends testing policies in dry-run mode first. Artifact Registry cleanup policies
Create cleanup-policy.json locally:
[
{
"name": "delete-images-older-than-30-days",
"action": { "type": "Delete" },
"condition": {
"tagState": "any",
"olderThan": "30d"
}
},
{
"name": "keep-most-recent-10",
"action": { "type": "Keep" },
"mostRecentVersions": {
"keepCount": 10
}
}
]
Preview the policy:
gcloud artifacts repositories set-cleanup-policies "$REPOSITORY" \
--project="$PROJECT_ID" \
--location="$REGION" \
--policy=cleanup-policy.json \
--dry-run
After reviewing the dry-run audit results, activate it by replacing --dry-run with --no-dry-run. Cleanup actions are asynchronous and may take approximately one day to appear.
Cloud Run scales an idle service to zero instances by default, but Artifact Registry images and retained logs still consume storage. Do not configure minimum instances unless the latency tradeoff is worth the ongoing cost. Cloud Run autoscaling
These commands are destructive. Confirm PROJECT_ID, REGION, REPOSITORY, and SERVICE_NAME before running them. Deleting a Cloud Run service permanently deletes its revisions but does not delete its Artifact Registry images. Cloud Run service deletion
gcloud run services delete "$SERVICE_NAME" \
--project="$PROJECT_ID" \
--region="$REGION"
gcloud artifacts repositories delete "$REPOSITORY" \
--project="$PROJECT_ID" \
--location="$REGION"
gcloud iam service-accounts delete "$BUILD_SA_EMAIL" \
--project="$PROJECT_ID"
gcloud iam service-accounts delete "$RUNTIME_SA_EMAIL" \
--project="$PROJECT_ID"
Deleting the repository removes all of its images; copy anything you need to keep first. Artifact Registry deletion is not a dependable recovery mechanism. Deleting Artifact Registry repositories
If you granted your user roles/cloudbuild.builds.editor only for this tutorial, remove that binding after cleanup:
gcloud projects remove-iam-policy-binding "$PROJECT_ID" \
--member="user:${DEPLOYER_ACCOUNT}" \
--role="roles/cloudbuild.builds.editor"
Do not remove the binding if it existed before this tutorial or is still required for other builds.