DEV Community

Florian Flock
Florian Flock

Posted on

Serverless Next.js with Google Cloud Run

Introduction

Serverless is one of the many buzzwords since cloud technologies have been around. What it means exactly and for which applications it is best suited is beyond the scope of my blog post. However, I would like to warmly recommend an O'Reilly book with the title Learning Serverless.

In the area of frontend development, I have been working with Next.js for some time. The framework enables server-side rendering of React websites and also offers some performance improvements. Next.js is hosted in Node environments, so it offers the highest possible flexibility. Especially for volatile frontends, a serverless infrastructure is best suited for hosting.

The official documentation of Next.js includes an example with Firebase Hosting. The main disadvantage is that the dynamic part of the application is executed on Cloud Functions which comes with many limitations. For example, the next/image optimisation package is not usage because of limited support.

In the following blog post, I explain how Next.JS can be hosted on Google Cloud Run. In addition to Cloud Run, static files are provided via Firebase. Firebase thus serves as a proxy and CDN at the same time.

Prerequisites

  • Understanding and Knowledege of Next.js
  • Up and running Google Cloud Project with billing enabled
  • Artifact Registry enabled on Google Cloud
  • Basic understanding in Google Cloud Run and Firebase
  • Basic understanding of Bitbucket Pipelines or other CI/CD tools

Additionally, please read through the Google documentation regarding Cloud Run and Custom Domains with Firebase. Recently, custom domains are supported for Cloud Run as well, without a firebase application. However, it does not work as expected since it is still in preview. However, there are some additional advantages with upfront Firebase Hosting.

Have a look on the Firebase documentation page as well where they describe how to enable Cloud Run as a dynamic backend for Firebase Hosting.

Process Overview

  1. Prepare your git repository with a Next.js app included.
  2. Get your CI/CD pipeline ready. I went for Bitbucket Pipelines, you can use the tool of your choice, even executing everything manually is possible.
  3. Execute the pipeline step or do it manually on your Notebook.
  4. Create your first Cloud Run Revision to make sure all settings are correct, see my learnings below.
  5. Add a custom domain.

Application Structure

My Next.js application is already somewhat more extensive. Among other things, it is multilingual and connected to several backend APIs. Furthermore, I decided to use TypeScript from the beginning.

|-- Dockerfile
|-- README.md
|-- bitbucket-pipelines.yml
|-- components
|-- config
|-- contexts
|-- dist
|-- firebase.json
|-- global.d.ts
|-- hooks
|-- interfaces
|-- models
|-- next-env.d.ts
|-- next-i18next.config.js
|-- next.config.js
|-- node_modules
|-- package.json
|-- pages
|-- providers
|-- public
|-- reducers
|-- tsconfig.json
|-- utils
`-- yarn.lock

Enter fullscreen mode Exit fullscreen mode

Next.js Config

Before we start, I want to share my next.config.js. It does include some improvements. But I want you to pay attention to the distDir. Normally, the application builds into the folder .next. For some reason, I rewrite it to dist. The built folder is referenced in the Dockerfile later on.

require('dotenv').config()

const withPlugins = require('next-compose-plugins')
const nextFonts = require('next-fonts')
const { i18n } = require('./next-i18next.config')

// @ts-check
/**
 * @color {import('next').Config}
 */
const nextConfig = {
  reactStrictMode: true,
  i18n,
  cssModules: true,
  cssLoaderOptions: {
    importLoaders: 1,
    localIdentName: '[local]___[hash:base64:5]'
  },
  images: {
    domains: ['checkoutshopper-live.adyen.com']
  },
  optimizeFonts: true,
  distDir: './dist',
  publicRuntimeConfig: {
    apiUrl: process.env.API_URL,
    adyenEnvironment: process.env.ADYEN_ENVIRONMENT
  }
}

module.exports = withPlugins([
  [nextFonts]
], nextConfig)

Enter fullscreen mode Exit fullscreen mode

Firebase

As you might already have read through the articles in the blog post, you are familiar with the json structure of firebase.json.

It basically says that all the dynamic traffic gets proxied to the Cloud Run service with the name frontend hosted in the region europe-west6.

{
  "hosting": {
    "site": "your-company-frontend",
    "public": "public",
    "rewrites": [
      {
        "source": "**",
        "run": {
          "serviceId": "frontend",
          "region": "europe-west6"
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Multi-Stage Dockerfile

The heart of the process is a huge multi-stage Dockerfile. If you did not heard anything about Docker multi-stage builds, I would highly recommend the official documentation.

Base Image with Build Dependencies

I prepared an image based on alpine with pre-installed node 17. It just adds some dependencies which are needed for building the Next.js application. Just to notice: It was a real pain to get all necessary packages in a list, it took many hours.

Install Yarn Dependencies

This stage just installs the necessary packages with Yarn. We later refer on the stage as deps.

Build the Next.js Application

We continue with building the Next.js application with yarn build. Take care that you build your application with production data, e.g. .env file with production-ready content.

Production Image

This stage of the Dockerfile is the main image which is later running on Google Cloud Run. I decided to create a non-root user for the main process for security reasons. Afterwards, all necessary files are copied from the previous build steps.

Management Image for Firebase Deploy

On the top end of the file, I created another step for the firebase deployment. The stage is used in the pipeline later on.

################################################
### Base Image with Build Dependencies       ###
################################################
FROM node:17-alpine AS base

RUN apk add --update --no-cache \
    build-base \
    autoconf \
    automake \
    libtool \
    shadow \
    gcc \
    musl-dev \
    make \
    tiff \
    jpeg \
    zlib \
    zlib-dev \
    file \
    pkgconf \
    g++ \
    libc6-compat \
    libjpeg-turbo-dev \
    libpng-dev \
    libwebp-tools \
    nasm

WORKDIR /app

################################################
### Install Yarn Dependencies                ###
################################################
FROM base AS deps

WORKDIR /app

COPY package.json yarn.lock ./

RUN yarn install --frozen-lockfile

################################################
### Build the Next.js Application            ###
################################################
FROM base AS builder

WORKDIR /app

COPY . .
RUN mv /app/.env.production /app/.env
COPY --from=deps /app/node_modules ./node_modules

ENV NODE_ENV production

RUN yarn build

################################################
### Production Image                         ###
################################################
FROM node:17-alpine AS runner

WORKDIR /app

ENV NODE_ENV production

# Create a non-root user for runtime
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001

# You only need to copy next.config.js if you are NOT using the default configuration
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/next-i18next.config.js ./
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist

# firebase caches the /public/ folder and set it to the root.
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/dist/static ./public/_next/static

# Copy rest of the files from the builder
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/.env ./.env

USER nextjs

ENV PORT 3000

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry.
# ENV NEXT_TELEMETRY_DISABLED 1

CMD ["node_modules/.bin/next", "start"]

################################################
### Management Image for Firebase Deploy     ###
################################################
FROM node:14-alpine as firebase-deploy
RUN npm install -g firebase-tools

WORKDIR /app

# Copy all necessary files to be recognzied by firebase
COPY --from=builder /app/firebase.json ./firebase.json
COPY --from=builder /app/.firebaserc ./.firebaserc
COPY --from=builder /app/public ./public
# Copy static files from next.js build into the public folder
# to be recognized by the CDN of Firebase.
COPY --from=builder /app/dist/static ./public/_next/static

CMD ["/bin/sh", "-c", "firebase deploy"]
Enter fullscreen mode Exit fullscreen mode

Bitbucket Pipeline

Let us move on to the Pipeline. Years ago, I decided to use Bitbucket Pipelines because most of my source code is hosted on Bitbucket as well. I would recommend getting familiar with the structure of the YAML file bitbucket-pipelines.yml in the documentation.

The first part of the deployment step is all about authorization to the Google Cloud. As you might have recognized, I use the image google/cloud-sdk:latest as a base image during the deployment. As you can see, the first line of the step requires a Environment Variable GCLOUD_CREDENTIALS. You have to add it in the Bitbucket Pipelines interface and basically, it is a GCP Service Account as base64 string. The service account should have the proper access rights to push the image, deploy the cloud run revision and execute the firebase command.

The second part of the step is building the docker image and push it to the Artifact Registry.

The last pipeline-managed thing is to deploy the new image as an Cloud Run revision in order to update the entire application.

There is even a very last step: The previously shown stage in the Dockerfile for the firebase deployment gets build and executed as one-off container, just to deploy the firebase frontend.

definitions:
  services:
    docker:
      memory: 3072
  steps:
    - step: &build-and-push-cloudrun-app
        name: Build Cloud Run Application
        image: google/cloud-sdk:latest
        caches:
          - docker
        services:
          - docker
        script:
          - echo $GCLOUD_CREDENTIALS | base64 --decode --ignore-garbage > ./gcloud-api-key.json
          - gcloud auth activate-service-account --key-file gcloud-api-key.json
          - gcloud config set project $GCLOUD_PROJECT
          - gcloud auth configure-docker europe-west3-docker.pkg.dev --quiet
          - rm ./gcloud-api-key.json

          - export IMAGE_NAME=europe-west3-docker.pkg.dev/$GCLOUD_PROJECT/cloud-run/frontend

          - docker build -t $IMAGE_NAME:v$BITBUCKET_BUILD_NUMBER --target runner .
          - docker tag $IMAGE_NAME:v$BITBUCKET_BUILD_NUMBER $IMAGE_NAME:latest
          - docker push $IMAGE_NAME:v$BITBUCKET_BUILD_NUMBER && docker push $IMAGE_NAME:latest

          - |
            gcloud run deploy frontend \
            --async \
            --image=$IMAGE_NAME:v$BITBUCKET_BUILD_NUMBER \
            --region=europe-west6 \
            --project=$GCLOUD_PROJECT

          - docker build -t firebase-deploy --target firebase-deploy .
          - docker run --rm -e FIREBASE_TOKEN=$FIREBASE_TOKEN firebase-deploy
pipelines:
  branches:
    master:
      - step: *build-and-push-cloudrun-app

Enter fullscreen mode Exit fullscreen mode

Custom Domain

If everything has been deployed correctly, you should be able to reach the application via the url which is provided by Firebase. Now you can add the custom domain described here.

Important Learnings

I would recommend creating the first Cloud Run revision by yourself in the user interface. Please ensure that you have at least one container running to avoid long cold starts.

Image description

I learned a lot about cold starts and the performance of Cloud Run instances by reading the community-maintained FAQ repository.

Conclusion

The serverless frontend gives us the huge opportunity to allocate resources only when necessary and even scale from zero. We have seen huge cost and performance improvements.

Top comments (0)