Deploying a Next.js app with Docker and AWS Elastic Beanstalk

February 26, 2022

(updated August 4, 2022)

DevOps

Update as of August 2022:
I used to run this Next.js blog on AWS Elastic Beanstalk. I have since migrated over to Vercel. The whole migration took ~5 minutes and it costs me $0 a month. If you are thinking about deploying Next.js to Elastic Beanstalk, you might want to read the summary of why I migrated over to Vercel.

TLDR: If you have followed my previous tutorial on how to build yourself an epic blog with Next.js, you should now be the proud owner of a blog. At this point, you've probably asked your mom and everybody you know to visit your blog to read your ramblings. There is only one problem. You have not deployed your blog yet so nobody can visit it.

If you have not read my previous tutorial, you can rest assured for you can still follow this tutorial to deploy any kind of Next.js app in Docker on AWS Elastic Beanstalk.

Next.js + Docker

If you have never heard about Docker, the main idea is that it lets you define an isolated environment called an image. You define an image using a Dockerfile that contains instructions for setting up the environment inside the image.

To deploy our Next.js app, we could build our own image from scratch. That would involve finding a suitable base image to build upon, installing our dependencies inside the image and building the Next.js from source code.

We do not need to do that here because Next.js has an official Dockerfile up on GitHub that we can steal.

Watch out

If you are getting the "/app/.next/standalone" not found error, you need to add outputStandalone to your next.config.js file before building the image or the build will fail with the following error: "/app/.next/standalone" not found.
Here's the settings you need to add (ignore the @type line if you are not using TypeScript):
JS
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
    // ... rest of your config ...
    experimental: {
        outputStandalone: true,
    },
};

module.exports = nextConfig;
Here's what the Dockerfile looks like:
Dockerfile
FROM node:16-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN yarn build

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

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]

You might notice there are multiple FROM ... statements in this Dockerfile. This is because this Dockerfile defines a multi-stage build.

The idea is to use different environments for different stages of the build pipeline and only keep the bare minimum for our production image in the final stage. That helps keep the production image small in size, and it also helps minimize build time as build stages can be cached between runs.

Let's break down the Dockerfile to get a feel for what is going on in each stage.

Stage: Installing dependencies

Dockerfile
FROM node:16-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

This stage extends the node:alpine base image, which is a Linux distribution commonly used in Docker images - it is a bare bones Linux distribution. The node variant comes with node.js installed.

We copy the package.json and yarn.lock files into the Docker image in an /app directory. These two files are the files that yarn dependency manager uses to keep track of the dependency tree for your project. Then we install our dependencies in /app/node_modules (in the image).

If you are using npm instead of yarn, then replace yarn.lock with package-lock.json and replace yarn install with npm install.

At this point, your deps image is a node:alpine environment with all of your node dependencies installed inside.

Stage: Building

Dockerfile
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn build

In this stage, we grab the node_modules installed in the previous stage (deps) and copy them over into a directory in this stage.

We also copy the content of the host directory (that's the directory on your computer that contains the Dockerfile and the rest of your Next.js files) into the image. We run yarn build (or npm run build) to generate an optimized version of our Next.js application for production.

Stage: Production

Dockerfile
FROM node:16-alpine AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]

This is the last stage, we grab the files from the previous stage (builder) and copy them over into this new environment. We set the NODE_ENV environment variable to production - that variable is used by Next.js (and can be used by your application code too) to make decisions based on which environment the app is running in.

The image now contains all we need to run the project in production.

You should also have a .dockerignore file in the root of your project to prevent Docker from copying certain files into the image:
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

You are all done with the Docker setup. If you want to try out the image locally, you can run the following commands:

sh
docker build . -t your-app-name
docker run -p 3000:3000 your-app-name

Your app should now be running at http://localhost:3000.

AWS Elastic Beanstalk

AWS Elastic Beanstalk (EB) is a noob-friendly service for deploying and managing web applications. It lets you deploy your application on a dozen different available runtimes.

There's a runtime for Python, Node.js, Go. There's even one for .NET. There is also the option to deploy your app as a Docker container instead. That's the option we are going to be using here.

The main reason to use Docker here is to maximize flexibility and control. With Docker, you can define your own runtime using a fairly generic tool instead of having to mess with Elastic Beanstalk-specific configuration such as obscure .ebextensions config files.

Elastic Beanstalk helps you with things like building the image, but it also helps you run your app in general. It lets you add load balancing, SSL/TLS termination on the load balancer, rolling deployment, centralized logging with AWS CloudWatch, among other things.

Deploying a Docker container on AWS Elastic Beanstalk is significantly easier (but more blackbox-ish) than deploying on a Docker-based service using an orchestrator such as Kubernetes or Amazon Elastic Container Service (Amazon ECS). All it takes to deploy an app on AWS Elastic Beanstalk in Docker is run a couple of commands on the aws-cli.

With Amazon ECS, you would have to build the Docker image, push it out into a container registry (such as ECR), define ECS tasks, define ECS services, spin up an ECS cluster, configure a bunch of things with the load balancer and so on.

With a cloud-managed Kubernetes cluster such as Amazon Elastic Kubernetes Service (EKS), you would have to pay for the control plane and set up much more complex configurations.

Deploying to AWS Elastic Beanstalk

Prerequisites

  • Install the aws-cliif you haven't already
  • Modify next.config.json to automatically copy traced files see here. Your next.config.js file should enable outputStandalone; add it if you haven't already:
    JS
    /** @type {import('next').NextConfig} */
    const nextConfig = {
        // ... rest of your config ...
        experimental: {
            outputStandalone: true,
        },
    };
    
    module.exports = nextConfig;
  • Create your Next.js project and have the Dockerfile and .dockerignore file at the root of the directory. Your project tree should look something like this:
    .
    ├── components
    ├── lib
    ├── pages
    ├── posts
    ├── public
    ├── styles
    ├── Dockerfile
    ├── README.md
    ├── next-env.d.ts
    ├── next.config.js
    ├── package.json
    ├── postcss.config.js
    ├── tailwind.config.js
    ├── tsconfig.json
    └── yarn.lock

Create AWS Elastic Beanstalk environment

An Elastic Beanstalk environment is a collection of AWS resources that you can deploy your Elastic Beanstalk application on. You can create an environment using the aws-cli or using the console.

The type of environment you want to create for this app is Web server environment (the other type is Worker environment, which is used to run scheduled tasks).

The default configuration is good enough for most cases. The only fields you need to fill out are:

  • Application Name: whatever name you want (e.g. your-app-name)
  • Environment name: whatever name you want (e.g. your-env-name)
  • Platform: choose Docker
  • Platform Branch: choose Docker running on 64bit Amazon Linux 2

That's it, you can go ahead and Create environment.

About load balancing

The most notable piece of infrastructure that AWS Elastic Beanstalk sets up for you is the Application Load Balancer (ALB) that routes traffic to an EC2 Autoscaling Group managed for you. AWS offers a handful of different Elastic Load Balancers (ELB).
Different load balancers operate at different layers of the Open Systems Interconnection (OSI) model. Some operate at the network level and fan traffic out to the instances at that layer. Others operate at Layer 7 (application layer).
An ALB is a Layer 7 load balancer based on NGINX. What it does is act as a reverse-proxy that takes in traffic and according to a set of routing and load distribution rules, routes traffic to the instances that your service is running on. The ALB also handle SSL/TLS termination - that means ingress traffic over HTTPS will be decrypted by the load balancer before being proxied on to the AWS-managed EC2 instances your app is running on.

At the end of the process, you should be seeing your new environment in the console.

Create AWS Elastic Beanstalk config.yaml

To initialize an Elastic Beanstalk environment, run:

sh
eb init

The CLI will guide you through a series of steps for creating an AWS Elastic Beanstalk application.

  • Select a default region: must be the same region that you created your environment / application in during the previous step
  • Select an application to use: select the application you created in the previous step
  • Do you wish to continue with CodeCommit? (Y/n): unless you have a reason to use it, pick No

This will create a YAML config file /.elasticbeanstalk/config.yaml. That file contains all the information that the aws-cli needs.

You should end up with a config that looks like the following:

YAML
branch-defaults:
  main:
    environment: your-env-name
environment-defaults:
  your-env-name:
    branch: null
    repository: null
global:
  application_name: your-app-name
  default_ec2_keyname: null
  default_platform: Docker running on 64bit Amazon Linux 2
  default_region: us-east-1
  include_git_submodules: true
  instance_profile: null
  platform_name: null
  platform_version: null
  profile: eb-cli
  sc: git
  workspace_type: Application

Deploy

Important: the aws eb commands appear to be using your .git repository to determine which files are part of your projects. You must commit your Dockerfile or the aws-cli will ignore it and the deployment will fail.

Almost done, now run:

sh
eb deploy

This command will zip your project files up into a ZIP file and upload it to an S3 bucket. AWS will then build your Docker image (on the EC2 instances managed by your AWS EB deployment) using the Dockerfile and deploy the container on your EC2 instance deployed by AWS EB.

You can find the domain for your app in the console (e.g. http://your-env-name.eba-vqr7qrr9.us-east-1.elasticbeanstalk.com).

That's it, your app is now up and running on AWS Elastic Beanstalk. In the next article we will see how you can set up CI/CD with GitHub Actions to automate the process of deploying your app.