Deploying a Next.js app with Docker and AWS Elastic Beanstalk
February 26, 2022
(updated August 4, 2022)
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
"/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.@type line if you are not using TypeScript):/** @type {import('next').NextConfig} */
const nextConfig = {
// ... rest of your config ...
experimental: {
outputStandalone: true,
},
};
module.exports = nextConfig;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
FROM node:16-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfileThis 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).
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
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn buildIn 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
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.
.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
.gitYou are all done with the Docker setup. If you want to try out the image locally, you can run the following commands:
docker build . -t your-app-name
docker run -p 3000:3000 your-app-nameYour 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.jsfile should enableoutputStandalone; 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
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:
eb initThe 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:
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: ApplicationDeploy
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:
eb deployThis 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.