Running a blog powered by Next.js on Cloud Run
2021-12-28
Next.jsGCPCloud RunThis blog had been built with Gatsby, but I decided to migrate to Next.js just for learning it. This post is going to describe what the arcitecture is and how to deploy this blog to GCP Cloud Run which is a serverless platform to run containers at scale without complexity.
Architecture
As I stated above, this blog is now powered by Next.js and running on Cloud Run. This blog depends on Next.js functionalities: static generation, dynamic routing, and API routes. Some of pages of this blog like this page and the index page can be generated statically and served just static files. And the other parts of this blog like search and oEmbed API are build on top of API routes, which is to build API within a Next.js application.
The running environment is GCP Cloud Run. Previously this blog had been running on Google AppEngine(a.k.a. GAE) as I stated at
Cloud Run can be considered a next generation of GAE in some sense, so I also decided to move running environment from GAE to Cloud Run. In GAE, static files can be served from edge cache as Storing and Serving Static Files explains whereas Cloud Run doesn't have such a useful mechanism in terms of serving static files. For keeping performance as fast as GAE edge cache does, first I thought using Cloud DNS but it turned out I'll have to pay much more than I've been paying for GAE because of various resources that are required to be able to use Cloud DNS as a frontend of the cloud run service, like load balancers, static IP addresses, and so on. Therefore I don't use Cloud DNS, and serve static files from Cloud Run service (i.e. Next.js application) even though it would take seconds to spin up when there is no running instances. Needless to say, I can keep 1 instance always up but it consumes 💰.
Dockerfile
For being able to run on Cloud Run, the application is packed as a Docker container that expects $PORT
env value to be given.
Deployment#Docker Image describes what basic Dockerfile would look like, and the following Dockerfile is fundamentally the same.
# Get NPM packages
FROM node:17-alpine AS dependencies
RUN apk add --no-cache libc6-compat git
WORKDIR /
COPY package.json yarn.lock ./
# RUN npm ci --only=production
RUN yarn install --frozen-lockfile
# Rebuild the source code only when needed
FROM node:17-alpine AS builder
RUN apk add --no-cache libc6-compat git
WORKDIR /
COPY . .
COPY --from=dependencies /node_modules ./node_modules
RUN yarn build # && yarn install --production --ignore-scripts --prefer-offline
# Production image, copy all the files and run next
FROM node:17-alpine AS runner
WORKDIR /
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup -g 1001 -S nextjs
RUN adduser -S nextjs -u 1001
COPY --from=builder /next.config.js ./
COPY --from=builder /public ./public
COPY --from=builder --chown=nextjs:nextjs /.next ./.next
COPY --from=builder /node_modules ./node_modules
COPY --from=builder /package.json ./package.json
COPY ./content ./content
USER nextjs
EXPOSE 3000
CMD ["yarn", "start-production"]
The key difference is COPY ./content ./content
. It totally depends on how file structures are, but as my blog's structure is that original blog contents written as markdown files are stored in ./content
, it needs the directory available in the application container to be able to serve API routes and something that needs local files.
When missing the line, I faced unexpected 404 errors.
Additionally, start-production
command in package.json looks like
$ cat package.json | jq '.scripts | {"start-production"}'
{
"start-production": "next start -p ${PORT:=3000}"
}
Run on Cloud Run
See official doc for more information https://cloud.google.com/run.
As long as an application container can get launched via docker run
, Cloud Run doesn't require any further actions.
So, just push the container image to GCR(Google Container Registry) and then deploy it on Cloud Run as followings via gcloud
command.
$ gcloud auth configure-docker
$ docker push asia.gcr.io/${GCP_PROJECT}/my-blog:latest # change location as needed
$ gcloud run deploy ${GCP_CLOUD_RUN_SERVICE} --image asia.gcr.io/${GCP_PROJECT}/my-blog:latest --project ${GCP_PROJECT}
The last command should show a URL for accessing the deployed application. In case you need custom domain on the cloud run service, it enables having custom domain mappings as https://cloud.google.com/run/docs/mapping-custom-domains explains.
Verfify the service
It's easy to verify since the service should be exposed to public.
$ curl 'https://blog.petitviolet.net' --head
HTTP/2 200
x-powered-by: Next.js
...
server: Google Frontend
It looks working as expected.
In addition, to remove x-powered-by
header, see https://nextjs.org/docs/api-reference/next.config.js/disabling-x-powered-by