Skip to content

Instantly share code, notes, and snippets.

@mikesparr
Last active November 23, 2023 21:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mikesparr/4dda2243ba9ee8e40dfbfe703b56392c to your computer and use it in GitHub Desktop.
Save mikesparr/4dda2243ba9ee8e40dfbfe703b56392c to your computer and use it in GitHub Desktop.
Experiment migrating Vercel hosted NextJS app to Google Cloud Platform atop Cloud Run
#!/usr/bin/env bash
#####################################################################
# REFERENCES
# - https://nextjs.org/learn/dashboard-app/getting-started
# - https://github.com/vercel/next.js/tree/canary/examples/with-docker
# - https://cloud.google.com/run/docs/quickstarts/build-and-deploy/deploy-nodejs-service
# - https://cloud.google.com/run/docs/configuring/services/environment-variables
# - https://cloud.google.com/run/docs/securing/service-identity
# - https://cloud.google.com/sdk/gcloud/reference/run/deploy
# - https://cloud.google.com/sdk/gcloud/reference/iam/service-accounts/create
# - https://cloud.google.com/secret-manager/docs/add-secret-version
# - https://cloud.google.com/secret-manager/docs/manage-access-to-secrets
#####################################################################
export PROJECT_ID=$(gcloud config get-value project)
export PROJECT_USER=$(gcloud config get-value core/account) # set current user
export PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format="value(projectNumber)")
export IDNS=${PROJECT_ID}.svc.id.goog # workflow identity domain
export GCP_REGION="us-west1" # CHANGEME (OPT)
export GCP_ZONE="us-west1-b" # CHANGEME (OPT)
export NETWORK_NAME="default"
# enable apis
gcloud services enable compute.googleapis.com \
storage.googleapis.com \
run.googleapis.com \
cloudbuild.googleapis.com \
secretmanager.googleapis.com \
artifactregistry.googleapis.com
# configure gcloud sdk
gcloud config set compute/region $GCP_REGION
gcloud config set compute/zone $GCP_ZONE
############################################################
# Create NextJS 14 App
# - online tutorial (https://nextjs.org/learn/dashboard-app/getting-started)
# - build locally, setup storage and hosting on Vercel
############################################################
export SERVICE_NAME="nextjs-dashboard" # will use later for naming service
export STARTER_URL="https://github.com/vercel/next-learn/tree/main/dashboard/starter-example"
# create app, then follow tutorial
npx create-next-app@latest nextjs-dashboard --use-npm --example $STARTER_URL
cd $SERVICE_NAME # build it here
############################################################
# Service Account
# - run each application with unique service account
# - grant only permissions needed following least privileges
############################################################
export SA_NAME="$SERVICE_NAME-sa"
export SA_EMAIL="$SA_NAME@$PROJECT_ID.iam.gserviceaccount.com"
# create service account to run app
gcloud iam service-accounts create $SA_NAME \
--display-name="$SA_NAME"
############################################################
# Secrets
# - replace sensitive env vars from Vercel app with secrets
############################################################
export SECRET_PG_URL="postgres-url"
export SECRET_PG_PRISMA_URL="postgres-prisma-url"
export SECRET_PG_URL_NON_POOL="postgres-url-non-pooling"
export SECRET_PG_PASSWORD="postgres-password"
export SECRET_AUTH_SECRET="auth-secret"
export SECRET_VERSION="latest"
# source .env file but break out keys and secrets (could use --env-vars-file)
source .env
echo "POSTGRES_HOST=$POSTGRES_HOST" # verify
# create secrets in secret manager
gcloud secrets create $SECRET_PG_URL --replication-policy="automatic"
gcloud secrets create $SECRET_PG_PRISMA_URL --replication-policy="automatic"
gcloud secrets create $SECRET_PG_URL_NON_POOL --replication-policy="automatic"
gcloud secrets create $SECRET_PG_PASSWORD --replication-policy="automatic"
gcloud secrets create $SECRET_AUTH_SECRET --replication-policy="automatic"
# add secret versions (saving the sensitive values from local .env)
echo -n "$POSTGRES_URL" | \
gcloud secrets versions add $SECRET_PG_URL --data-file=-
echo -n "$POSTGRES_PRISMA_URL" | \
gcloud secrets versions add $SECRET_PG_PRISMA_URL --data-file=-
echo -n "$POSTGRES_URL_NON_POOLING" | \
gcloud secrets versions add $SECRET_PG_URL_NON_POOL --data-file=-
echo -n "$POSTGRES_PASSWORD" | \
gcloud secrets versions add $SECRET_PG_PASSWORD --data-file=-
echo -n "$AUTH_SECRET" | \
gcloud secrets versions add $SECRET_AUTH_SECRET --data-file=-
# add IAM policy bindings to allow service account to access secrets
gcloud secrets add-iam-policy-binding $SECRET_PG_URL \
--member="serviceAccount:$SA_EMAIL" \
--role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding $SECRET_PG_PRISMA_URL \
--member="serviceAccount:$SA_EMAIL" \
--role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding $SECRET_PG_URL_NON_POOL \
--member="serviceAccount:$SA_EMAIL" \
--role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding $SECRET_PG_PASSWORD \
--member="serviceAccount:$SA_EMAIL" \
--role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding $SECRET_AUTH_SECRET \
--member="serviceAccount:$SA_EMAIL" \
--role="roles/secretmanager.secretAccessor"
############################################################
# Containerize application
# - create artifact registry
# - create Dockerfile
# - build and push image to registry
############################################################
export REPO_NAME="mike-test-repo"
export IMAGE_NAME="$SERVICE_NAME"
export TAG_NAME="v0.0.1"
export IMAGE_URL=${GCP_REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}/${IMAGE_NAME}:${TAG_NAME}
# build docker image and push to registry
gcloud artifacts repositories create $REPO_NAME \
--repository-format=docker \
--location=$GCP_REGION \
--description="Docker repository"
# configure auth
gcloud auth configure-docker ${GCP_REGION}-docker.pkg.dev
# create Dockerfile
cat > Dockerfile << EOF
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache --update libc6-compat python3 make g++
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
EOF
# create .dockerignore
cat > .dockerignore << EOF
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
EOF
# build and push image to registry (platform flag for Mac compatibility)
docker build -t $IMAGE_URL --platform linux/amd64 .
docker push $IMAGE_URL
############################################################
# Deploy service to Cloud Run
# - replace sensitive env vars from Vercel app with secrets
############################################################
export SERVICE_PORT="3000"
export SERVICE_PLATFORM="managed"
# create cloud run service (with env vars and secrets)
gcloud run deploy $SERVICE_NAME \
--region $GCP_REGION \
--platform $SERVICE_PLATFORM \
--image $IMAGE_URL \
--port $SERVICE_PORT \
--allow-unauthenticated \
--session-affinity \
--service-account $SA_EMAIL \
--set-env-vars=POSTGRES_USER=$POSTGRES_USER \
--set-env-vars=POSTGRES_HOST=$POSTGRES_HOST \
--set-env-vars=POSTGRES_DATABASE=$POSTGRES_DATABASE \
--set-env-vars=AUTH_URL=$AUTH_URL \
--update-secrets=POSTGRES_URL=$SECRET_PG_URL:$SECRET_VERSION \
--update-secrets=POSTGRES_PRISMA_URL=$SECRET_PG_PRISMA_URL:$SECRET_VERSION \
--update-secrets=POSTGRES_URL_NON_POOLING=$SECRET_PG_URL_NON_POOL:$SECRET_VERSION \
--update-secrets=POSTGRES_PASSWORD=$SECRET_PG_PASSWORD:$SECRET_VERSION \
--update-secrets=AUTH_PASSWORD=$SECRET_AUTH_SECRET:$SECRET_VERSION
# get service URL and update AUTH_URL (localhost:3000/api/auth will fail)
export SERVICE_URL=$(gcloud run services describe $SERVICE_NAME --region $GCP_REGION --format="value(status.address.url)")
gcloud run services update $SERVICE_NAME \
--region $GCP_REGION \
--set-env-vars=AUTH_URL="$SERVICE_URL/api/auth"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment