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
- Prepare your git repository with a Next.js app included.
- 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.
- Execute the pipeline step or do it manually on your Notebook.
- Create your first Cloud Run Revision to make sure all settings are correct, see my learnings below.
- 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
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)
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"
}
}
]
}
}
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"]
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
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.
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)