iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🏃‍♂️

Deploying a Next.js App to Cloud Run with Secret Manager

に公開

While Vercel is the primary choice for deploying Next.js applications, they can also be run on other platforms[1]. This article is a record of creating a Docker Image for Next.js and deploying it to Google Cloud's Cloud Run. Note that this assumes a Server Side Rendering (SSR) app that calls GraphQL.

Creating an Artifact Registry with Terraform

To deploy to Cloud Run, a container image is required. For storing container images, Google Cloud's Artifact Registry is recommended. Please refer to the following to create an Artifact Registry repository:

https://zenn.dev/waddy/articles/terraform-google-cloud

For reference, I have prepared a modified version for this project in the following repository:

https://github.com/cm-wada-yusuke/gql-nest-prisma-training/tree/main/blog-deploy-cloud-run/infra/modules/artifact-registry

blog-deploy-cloud-run/infra/modules/artifact-registry/artifact-registry.tf
variable "gcp_project_id" {}
variable "artifact_registry_location" {
  type = string
  # https://cloud.google.com/storage/docs/locations
  description = "Location for the Artifact Registry"
}

# Artifact Registry repository for the backend application
resource "google_artifact_registry_repository" "blog-backend-training-app" {
  provider = google-beta

  project       = var.gcp_project_id
  location      = var.artifact_registry_location
  repository_id = "blog-backend-training-app"
  description   = "Backend application"
  format        = "DOCKER"
}


+# Artifact Registry repository for the frontend application
+resource "google_artifact_registry_repository" "blog-frontend-training-app" {
+  provider = google-beta
+
+  project       = var.gcp_project_id
+  location      = var.artifact_registry_location
+  repository_id = "blog-frontend-training-app"
+  description   = "Frontend application"
+  format        = "DOCKER"
+}

Creating the Dockerfile

I will create a Dockerfile for image building. There doesn't seem to be an official Dockerfile for building Next.js (please let me know if there is one). I referred to a description I found on GitHub Discussions.

https://github.com/vercel/next.js/discussions/16995

blog-deploy-cloud-run/frontend/Dockerfile
FROM node:16 AS builder

ARG graphql_endpoint

# Because devDependencies also need to be installed for the build
ENV NODE_ENV=development

# Environment variables to be embedded in the app
ENV NEXT_PUBLIC_GRAPHQL_ENDPOINT=$graphql_endpoint

WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
RUN yarn install
COPY . .
RUN yarn build


FROM node:16-stretch-slim AS runner
ENV NODE_ENV=production

WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
# When yarn install (npm install) is run with NODE_ENV=production, devDependencies are not installed
RUN yarn install
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
CMD ["yarn", "start"]

I will highlight several key points.

Multi-stage Build

In Docker image builds, there are scenarios where various libraries are needed during the build process, but the final executable file is small, meaning tools installed during the process would go to waste. In this example, devDependencies in package.json fall into this category. By using multi-stage builds, you can separate the build environment from the final production image. FROM node:16 AS builder is for building, and FROM node:16-stretch-slim AS runner is for execution. You can see that ENV NODE_ENV=production is set in the latter.

The artifacts from the builder are passed to the runner as follows:

COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public

Don't Forget to Include next.config.js in Production Files

I forgot this myself and suffered from an error whose cause was unclear. I had configured image domain settings for NextImage in next.config.js, but because I didn't include this file in the production deployment, the images wouldn't display. It took quite a while to figure it out because the image URLs were correct, yet they wouldn't show up. My attempt to make the production image lighter backfired. Damn it...

RUN yarn install
+ COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public

This is the part. Please make sure you don't forget it.

Environment Variables Exposed as NEXT_PUBLIC_XXX

In Next.js apps, adding the NEXT_PUBLIC_ prefix to an environment variable name allows it to be embedded into the resulting JavaScript files. As a result, those environment variables can be used in the browser. Note that these variables are required at build time; even if you set them at startup, the browser won't be able to use them. The same applies when creating a Docker image, so they are set as environment variables during the build phase.

Since I want to inject them during the docker build command, I declared ARG graphql_endpoint to accept variables from the outside. The docker build command is executed as follows (the cloudbuild.yml will be described later):

Terminal
docker build \
--file=Dockerfile \
--build-arg=graphql_endpoint=$NEXT_PUBLIC_GRAPHQL_ENDPOINT \
.

If the NEXT_PUBLIC_GRAPHQL_ENDPOINT variable exists in the build environment, its value can be passed to docker build.

Creating a Cloud Build Trigger

You can create the trigger either through the console or using Terraform. For example, when deploying with Terraform, add a module like the following:

infra/modules/cloud-sql/cloud-sql.tf
resource "google_cloudbuild_trigger" "deploy-frontend-training-app" {
  name        = "deploy-frontend-training-app"
  description = "Deploy Next.js app to Cloud Run"
  github {
    owner = var.github_owner
    name  = var.github_app_repo_name
    push {
      branch = "^main$"
    }
  }
  included_files = ["blog-deploy-cloud-run/frontend/**"]
  filename       = "blog-deploy-cloud-run/frontend/cloudbuild.yml"
  substitutions = {
    _REGION                         = var.region
    _SERVICE_ACCOUNT                = var.cloud_run_service_account
    _ARTIFACT_REPOSITORY_IMAGE_NAME = "${var.region}-docker.pkg.dev/${var.gcp_project_id}/${var.frontend_app_name}/blog-frontend"
  }
}

Call it from main.tf as follows:

blog-deploy-cloud-run/infra/main.tf
locals {
  backend_app_name  = "blog-training-backend-app"
  frontend_app_name = "blog-training-frontend-app"
}

# Cloud Build
# Migration + Backend deployment
# Frontend deployment
module "cloud-build" {
  source                      = "./modules/cloud-build"
  gcp_project_id              = var.gcp_project_id
  region                      = var.primary_region
  cloud_run_service_account   = module.cloud-run.blog_training_app_runner_service_account
  frontend_app_name           = local.frontend_app_name
  github_owner                = "cm-wada-yusuke"
  github_app_repo_name        = "gql-nest-prisma-training"
}

Run terraform apply to create the Cloud Build Trigger.

Creating cloudbuild.yml

frontend/cloudbuild.yml
steps:
+  - id: build-frontend
+    name: "gcr.io/cloud-builders/docker"
+    entrypoint: "bash"
+    args:
+        - -c
+        - >-
+          docker build
+          --file=Dockerfile
+          --build-arg=graphql_endpoint=$$GRAPHQL_ENDPOINT
+          --tag=$_ARTIFACT_REPOSITORY_IMAGE_NAME:$SHORT_SHA
+          --tag=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest
+          --cache-from=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest
+          .
+    secretEnv: ["GRAPHQL_ENDPOINT"]
    dir: "blog-deploy-cloud-run/frontend"
  - id: push-frontend
    name: "docker"
    args:
      - push
      - --all-tags
      - $_ARTIFACT_REPOSITORY_IMAGE_NAME
    dir: "blog-deploy-cloud-run/frontend"
    waitFor: ["build-frontend"]
  - id: deploy-frontend
    name: gcr.io/cloud-builders/gcloud
    args:
      - beta
      - run
      - deploy
      - training-frontend
      - --quiet
      - --platform=managed
      - --project=$PROJECT_ID
      - --region=$_REGION
      - --image=$_ARTIFACT_REPOSITORY_IMAGE_NAME:$SHORT_SHA
      - --service-account=$_SERVICE_ACCOUNT
      - --revision-suffix=$SHORT_SHA
      - --tag=latest
      - --concurrency=40
      - --cpu=1
      - --memory=512Mi
      - --max-instances=3
      - --min-instances=0
      - --no-use-http2
      - --allow-unauthenticated
      - --no-cpu-throttling
      - --ingress=all
+     - --update-secrets=GRAPHQL_ENDPOINT=BLOG_TRAINING_GRAPHQL_ENDPOINT:latest
    dir: "blog-deploy-cloud-run/frontend"
    waitFor: ["push-frontend"]
timeout: 2000s
substitutions:
  _REGION: by-terraform
  _ARTIFACT_REPOSITORY_IMAGE_NAME: by-terraform
  _SERVICE_ACCOUNT: by-terraform
+availableSecrets:
+  secretManager:
+    - versionName: projects/$PROJECT_ID/secrets/BLOG_TRAINING_GRAPHQL_ENDPOINT/versions/latest
+      env: GRAPHQL_ENDPOINT

# Display generated image information in the build results
# https://cloud.google.com/build/docs/building/build-containers
images:
  - $_ARTIFACT_REPOSITORY_IMAGE_NAME:$SHORT_SHA

I will highlight several key points here as well.

Retrieving Secrets from Secret Manager

By using availableSecrets, you can expand Secret Manager values as environment variables during build time. This is used in the docker build section mentioned earlier:

  - id: build-frontend
    name: "gcr.io/cloud-builders/docker"
    entrypoint: "bash"
    args:
        - -c
        - >-
          docker build
          --file=Dockerfile
+          --build-arg=graphql_endpoint=$$GRAPHQL_ENDPOINT
          --tag=$_ARTIFACT_REPOSITORY_IMAGE_NAME:$SHORT_SHA
          --tag=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest
          --cache-from=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest
          .
+    secretEnv: ["GRAPHQL_ENDPOINT"]

When reading from Secret Manager, it must be used from a bash entrypoint in the build step. If you look closely, within the bash command, it is specified with two dollar signs like graphql_endpoint=$$GRAPHQL_ENDPOINT. This is because it is expanded as an environment variable in the docker build execution environment. For more details on the interaction between Cloud Build and Secret Manager, please refer to catnose's article, which covers it well.

https://zenn.dev/catnose99/articles/6cb0fc434a4a62

Mounting Secret Manager with Cloud Run Environment Variables

In the Cloud Run deployment command, this part:

--update-secrets=GRAPHQL_ENDPOINT=BLOG_TRAINING_GRAPHQL_ENDPOINT:latest

This option is not for embedding at build time, but rather for the Cloud Run execution environment to expand it. Please note that the configuration method is different from the method used in docker build earlier.

Registering the GRAPHQL_ENDPOINT Value in Secret Manager

Next, register the GraphQL endpoint needed for both build and runtime in Secret Manager. Since this is an SSR app, you would likely use GRAPHQL_ENDPOINT from getServerSideProps, but if you consider form inputs and such, you may also want to call it from the browser. For this reason, NEXT_PUBLIC_GRAPHQL_ENDPOINT is also configured in cloudbuild.yml. While it won't be a "Secret" in that case, please use this as a sample for delaying the configuration of environment variables until build/deployment time.

There are various ways to register values in Secret Manager, but generally, since Secret Manager stores sensitive information, I believe manual registration is safer. Register it via the console.

  • Name: BLOG_TRAINING_GRAPHQL_ENDPOINT
  • Secret Value: URL of the GraphQL backend
    • Example: https://training-backend-xxxxxxxxx-uc.a.run.app/graphql

Executing the Build

Launch the trigger from Cloud Build.

Confirm that it is deployed to Cloud Run.

(Although this article doesn't mention the backend) it is a success if it connects to the backend GraphQL and data can be read.

Conclusion

Since Next.js officially supports specific deployment targets, there is a lot of know-how regarding Vercel. For deploying to other platforms, I tried this approach because being able to build as a Docker image makes it versatile and convenient. I also confirmed that it actually deploys to Cloud Run and operates as intended. I hope this serves as a reference for someone.

Reference

https://github.com/vercel/next.js/discussions/16995

Source Code

The source code used in this article is available on GitHub.

https://github.com/cm-wada-yusuke/gql-nest-prisma-training/tree/main/blog-deploy-cloud-run

脚注
  1. Please be aware that some Vercel-specific features, such as ISR, may not be available. Reference: Story of migrating a Next.js app from Vercel to Google Cloud (Japanese) ↩︎

Discussion