petitviolet blog

    Running a blog powered by Next.js on Cloud Run

    2021-12-28

    Next.jsGCPCloud Run

    This 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

    Deploy blog via GitHub Actions Deploy automation using GitHub Actions. This blog(https://blog.petitviolet.net) is hosted on Google AppEngine(a.k.a GAE). GAE is super useful not only for dynamic WEB applications but also for static sites. To deploy applications and sites on GAE, it just needs to call gcloud app deploy command. Speaking of GAE deployments, it usuall

    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.

    dockerfile
    # 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