Running NextJS SSR Apps on AWS

At the time of writing this article, AWS is offering multiple options to run a NextJS SSR app. In the scale of self managed to AWS Managed, here are the options:

Out of these options, the best developer experience related AWS Managed offerings are Amplify Hosting and App Runner. They come with all the low level details handled by AWS in such a way that all you need is deploying via CI/CD pipeline and the rest is assured (scalability, logs, custom domains, auto-renewal of certificates etc.)

I have tried both Amplify Hosting and App Runner for a NextJS SSR app and ended up using and liking App Runner more. I’m hoping the context provided in this post can be useful to others too.

Constraints and Prerequisites of Amplify Hosting

Although Amplify Hosting does a great job for running SPAs that doesn’t require server side rendering, when it comes to SSR, the constraints and prerequisites are important to highlight.

First of all, in order to run a SSR app on Amplify Hosting as of writing this article, you need to connect a source code for Next.js apps can only be deployed with Continuous deployment. AWS currently do not support manual deployments of Next.js apps. This creates a huge challenge if your code is deployed to multiple environments from a single main branch. Because all environments in Amplify Hosting is required to listen to the same main branch and before any approval or testing, your code can go to production at the same time it goes to sandbox or dev environment. One workaround to this could be using a branch per environment but this conflicts with the idea of Continuous Delivery.

Second, you need to setup the build definition in a yaml file that requires changing how you export your NextJS SSR. I found this pretty confusing.

By following the below changes in your build step, you are exposed to issues like not being able to use some of the NextJS features. Currently, Amplify doesn’t fully support Image Component and Automatic Image Optimization available in Next.js 10. To manually deploy the next-app example, you must edit the index.js file to remove this feature.

"scripts": {
  "dev": "next dev",
  "build": "next build && next export",
  "start": "next start"
},

Third, as documented in AWS Docs, Amplify Hosting builds an S3 bucket, a CloudFront distribution and a Lambda@Edge stack in order to host SSR app. This may sound too many moving parts for a single frontend app while it is promising for a frontend app to have a global availability via CDN. The most interesting finding in this step was Lambda@Edge. As far as I understand, Amplify Hosting deploys some logic at Lambda@Edge with a very constraint timeout. In some Amplify Console Github issues, I have seen that Lambda@Edge constraints certain workloads in terms of quickly timing out. Also, due to how Lambda@Edge works, you will require deploying your app to us-east-1 where CloudFront and Lambda@Edge is centrally managed.

image

Last, when you choose Amplify Hosting, you are getting into an usual workflow. On top of your existing CI/CD pipelines, you are pushing your code to Amplify’s CD pipeline which is only visible via AWS Console. This means, you won’t have any realtime visibility in your existing CI/CD pipelines and rather check the progress of deployment from AWS Amplify Console. I’m not sure if there are any workarounds to this but having multiple disconnected tools for a CD pipeline is not a great developer experience. Also, from security point of view, it is tricky to allow all frontend developers touching the NextJS SSR app accessing the AWS Console. Even these frontend developers may not be familiar with the whole AWS Console experience so it is both a security concern and a learning curve issue.

AWS App Runner just works!

AWS App Runner is a fully managed service that makes it easy for developers to quickly deploy containerized web applications and APIs, at scale and with no prior infrastructure experience required.

This gives a huge advantage building a CI/CD pipeline for a NextJS SSR app using existing tools and a lot of flexibility on how you do it. To achieve this, all you need is couple of lines of CDK code and a docker file.

The following creates an App Runner app that auto-scales to 25 instances and scales down to 1 when there isn’t any HTTP requests to your system. It is pretty much how you deploy a container to Fargate without dealing with Route53, ALB, VPC, Security Groups etc. All of these lower level constructs are managed by AWS.

import * as cdk from "@aws-cdk/core";
import { Duration, NestedStack, NestedStackProps } from "@aws-cdk/core";
import * as apprunner from "@aws-cdk/aws-apprunner";
import * as ecrAssets from "@aws-cdk/aws-ecr-assets";

export class WebStack extends NestedStack {
    constructor(scope: cdk.Construct, id: string, props: NestedStackProps) {
        super(scope, id, props);

        /**
         * Create a docker image and push to ECR
         */
        const imageAsset = new ecrAssets.DockerImageAsset(this, "WebAppImage", {
            directory: "../src",
        });

        /**
         * Deploy to App Runner from the docker image pushed to ECR
         */
        const service = new apprunner.Service(this, "WebApp", {
            source: apprunner.Source.fromAsset({
                asset: imageAsset,
            }),
            cpu: apprunner.Cpu.TWO_VCPU,
            memory: apprunner.Memory.FOUR_GB,
        });
    }
}

There are couple things to note in this setup.

  1. The minimum CPU that requires a responsive and performance NextJS SSR app is apprunner.Cpu.TWO_VCPU. Otherwise, if you have an API connecting to a downstream service and receiving data, it quickly times out and gives 500 Server Error
  2. The minimum Memory is unfortunately needed to be the maximum App Runner supports, apprunner.Memory.FOUR_GB. This is again to make sure the node server has enough head room to process.

The next step is basically just dockerizing your NextJS SSR. For this, NextJS already provides a straightforward Docker file. All you need is putting this on your src folder. The rest is handled by CDK auto-magically.

# Install dependencies only when needed
FROM node:14-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json ./
RUN npm install

# Rebuild the source code only when needed
FROM node:14-alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN npm run build

# Production image, copy all the files and run next
FROM node:14-alpine AS runner
WORKDIR /app

ENV NODE_ENV production

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/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/.env ./

USER nextjs

EXPOSE 3000

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"]

This really is an uncompromising build process to get the best out of your NextJS build.

When you need to deploy this stack, all you need to is following in your CI/CD pipeline.

cdk bootstrap
cdk deploy

Some other benefits of App Runner

Following are other good parts of App Runner worths mentioning.

Custom Domains and Auto Renewed HTTPS Certificate

You can link a custom domain and within a couple of minutes your NextJS SSR app can be accessed via HTTPS only. For this to really work, as of writing this article, you need to update Route53 records and add a couple of CNAME entries

You can also link multiple domains to the same app…

Logs are welcome!

Your console.log is visible via App Runner console auto-magically. Although CloudWatch is a better option generally, for simple workloads, this saves a lot of time when something goes wrong and you need logs.

Of course, metrics!

Some basic metrics without any effort, out of box are available.

Conclusion

If your frontend app doesn’t require server side rendering, go all the way Amplify Hosting for great user experience by leveraging S3 and CDN provided out of box by Amplify. It is really one of the best frontend developer experience available in the market.

However, to improve site’s SEO ranking, Time to first byte (TTFB) and First contentful paint (FCP) and most importantly, connecting to downstream / backend services at the API layer without leaking anything to browser and client side for a better security, using App Runner is much more flexible choice. Not only for NextJS SSR but for any NodeJS app that requires SSR (Express, Gatsby, React SSR (experimental) etc), App Runner seems to be the easier setup and with very low effort CI/CD pipeline.

One long term benefit of using App Runner is dockerising your SSR web app. This gives opportunities to move your web app into more complex setups like Fargate and even EKS (yes, I have seen SPAs with SSR served from their own k8s clusters at scale 🤓).

What do you think?

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: