Next.js containerization using Docker

RedRobot.dev - Nov 4 - - Dev Community

In this post, we'll explore how to containerize a Next.js application using Docker. We'll start with a real-world scenario to illustrate why containerization is valuable for modern web applications. Then, we'll explain what containers are, why this technology exists, and its benefits compared to traditional deployment methods. Finally, we'll walk through the process of containerizing a Next.js app step-by-step, giving you practical experience with this essential technology. By the end, you'll understand both the how and why of using containers for Next.js applications.

*Original Post
*

Example Scenario

Let's take the following example: We are tasked to create a Saas (Software as a Service) application that has a web interface giving users the capability to select a list of countries, cities, days of week and generate a weather video sequence similar to what you see in news broadcasts.

A Weather graphics source DW News

At minimum, such a system would require to have the following components:

  1. REST API to handle video render requests
  2. storage to hold videos
  3. video rendering engine
  4. frontend application that users can input the country, city and days and request for a video

The block diagram would look something like this:

A Weather video rendering service

We could develop the whole application using a single language like TS, JS, C++, C#, Rust or Go, ending up with a single executable that handles everything for us, but this is not a good solution for a SaaS application.

This is referred to as a monolith architecture where in certain cases like a desktop application it is desirable. However, in our case since it's a server-based SaaS application it is better to break down our application into smaller self contained components. This has several benefits:

  1. Much easier to break down tasks and assign to different engineers
  2. Easier to test component in isolation, using mock input and output
  3. Easier to scale horizontally, so lets say in the future our user base grows and we need to handle larger traffic - we can easy duplicate an instance of the Rendering engine, add a load balancer in front so we can handle larger client requests.

This solution is referred to as microservice architecture.

The Problem

So how would we go about and making this self contained components? Let's make it simple and only look at the frontend which is the topic of this post.

We can make an executable of the Next.js process using something like [Nexe (https://github.com/nexe/nexe) and run that on the destination server along with the other executables. But this solution has problems:

  1. the executable might not have bundled all the necessary 3rd libraries, say the libc lib that a package requires, and this could lead to missing dependencies when running hte executable on a destination server.

  2. the executable created or developed on one operating system, might not work on other operating system out of the box and will require extra work to make it intra operative

  3. the libraries included in the executable bundle might conflict with other software installed on the server

  4. there are other issues such as maintenance complexity, security issues and difficulty auditing, hard to patch issues, loss of flexibility and so forth

As you can tell, this would be a nightmare to manage. Writing good and clean code is great, but if your solution is not future-proof and easy to fix, then it doesn't matter if your code is good or high quality. Imagine creating a factory with machines where it takes months to fix a machine; the whole factory is shut down with no products being produced.

The Solution

One approach would be to create a virtual machine (VM) and install Next.js along with Node and all of the required libraries - and then load that onto the server. This would be a good solution as the VM is isolated from the server, and in fact, we can do the same for the rest of the components like the database, rendering engine, and other elements.

Virtual Machine

However, there is one major downside to VMs - they consume a lot of resources. Virtual machines run a full operating system with their own kernel and drivers, requiring dedicated CPU and memory allocations.

This is where containers come in. Containers are similar to VMs; however, they do not run a full operating system. Instead, they share the host operating system's kernel and resources.

Containers

Similar to virtual machines, containers are isolated environments that allow you to group applications and configurations into a single unit and run your application. Containers are more lightweight and efficient compared to VMs, making them an attractive option for deploying and scaling applications.

We are going to containerize a basic Next.js application to demonstrate the process.

Containerization refers to the process of packaging an application into a container or more accurately a container image, so we can run it using docker.

Docker

Docker is one of the most popular containerization technologies and in this post we will be using Docker to create a container that hosts a Next.js application.

Docker logo

Containerizing a Next.js Application

Lets containerize a Next.js application using Docker. If you don't have docker installed, install it from here https://www.docker.com/. Following the instructions provided on the website for your operating system.

Open up a terminal and create the Next.js frontend app by running:

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

Enter frontend for the app name, and then press enter for all prompts until it finishes creating the Next.js app.

update the next.config.mjs file and add the following line

const nextConfig = {
  output: 'standalone'
}
Enter fullscreen mode Exit fullscreen mode

this will automatically create a standalone folder that copies only the necessary files for a production deployment. This change in the Next.js config is required in order to get the outputs we need for a production build.

Next, in the newly created frontend folder we will create a file named Dockerfile with the content of this link there.

Dockerfile example from Vercel

Here are the commands to create the file:

cd frontend
curl https://raw.githubusercontent.com/vercel/next.js/canary/examples/with-docker/Dockerfile -o Dockerfile
Enter fullscreen mode Exit fullscreen mode

So what is this file for?

Dockerfile

The file we just downloaded contains instructions that tell Docker how to build a container image. An image is essentially a blueprint that holds all your code, third-party libraries, OS-related data, and other necessary components in a single package.

When you execute that image using Docker, you create a running instance of that image, which is referred to as a container. You can create multiple containers from a single image.

Lets take a look into the file we just downloaded:

FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN  npm ci
...
Enter fullscreen mode Exit fullscreen mode

Each line in the Dockerfile is a command, similar to how you would run a basic Linux command. If you would run mkdir /app in a Unix terminal, in the Dockerfile you would write RUN mkdir /app.

  • The top line FROM node:18-alpine AS base tells Docker which base image to use for building your container. In this case, it's using Node.js version 18 on an Alpine Linux distribution. Docker will fetch this image if it's not in your local cache. This is analogous to choosing an operating system for a VM, but it's more lightweight.

  • The line RUN apk add --no-cache libc6-compat is installing a specific compatibility package for libc inside the image. This is indeed better suited for the production output of Next.js. If we do this in an environment that has other applications depending on libc, it might cause problems.

  • The line RUN npm ci installs all the Next.js project dependencies, as you would normally do for a freshly cloned Next.js app. The ci command is used instead of install for more reproducible builds.

  • The following lines, are building and copying the Next.js outputs to a working path.

RUN npm run build
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
Enter fullscreen mode Exit fullscreen mode
  • And finally the last few lines expose port 3000 and starts the Next.js process:
EXPOSE 3000
CMD HOSTNAME="0.0.0.0" node server.js
Enter fullscreen mode Exit fullscreen mode

We are skipping most of the details here. The idea is to give you an overview of what is occurring in this Dockerfile. If you like to learn more about Dockerfile and how to create your own, checkout this official guide from Docker:

Guide to creating Dockerfile

and the reference docs for all Dockerfile commands:

Dockerfile Reference

We can now build our image, from inside the frontend folder run:

docker build -t nextjs-frontend .
Enter fullscreen mode Exit fullscreen mode

This is going to build a docker image named nextjs-frontend.

Make sure you are running the docker service, otherwise the command will fail

you can check to see if the image is in your local registry by running:

docker image ls
Enter fullscreen mode Exit fullscreen mode

lets run our our newly created image. By running the image, we are creating a container effectively. Execute the following command:

docker run -p 4000:3000 nextjs-frontend
Enter fullscreen mode Exit fullscreen mode

this command starts the docker image, and maps the internal port 3000 to 4000. So now if you open your browser and go to http://localhost:4000/ you will see the next.js page.

Run CTRL + C to stop the container. Alternatively you can run the container in the background or detached mode by passing the -d argument:

docker run -d -p 4000:3000 nextjs-frontend
Enter fullscreen mode Exit fullscreen mode

Then to view any detached running container run:

docker ps
Enter fullscreen mode Exit fullscreen mode

and the output should look like:

CONTAINER ID   IMAGE                       COMMAND                  CREATED         STATUS         PORTS                    NAMES
955c54690c9d   nextjs--frontend            "docker-entrypoint.s…"   4 seconds ago   Up 4 seconds   0.0.0.0:4000->3000/tcp   great_hamilton
Enter fullscreen mode Exit fullscreen mode

To stop the container from running, take a note of the name and then run:

docker stop great_hamilton
Enter fullscreen mode Exit fullscreen mode

Alternatives to Docker

White Docker is the most popular tool for containerization, other containerization technologies exist. Here are some of the popular ones:

  1. LXC (Linux Containers): A lightweight virtualization which is OS-level and allows you to create and run multiple isolated Linux virtual environments (VE) on a single control host. LXC is better suited for experienced users.

  2. LXD (Linux Daemon): it's a extension of LXC that aims to provide a better user experience. LXD is more user-friendly and it is similar to hypervisors like VMWare or KVM, but it's lighter on resources and doesn't require virtualization overhead.

  3. containerd: A container runtime that's used by Docker but can also be used independently.

  4. Kubernetes (k8s): While primarily an orchestration platform, it includes its own container runtime interface.

  5. OpenVZ: Primarily used for server virtualization on Linux.

  6. Windows Containers: Microsoft's native containerization technology for Windows.

Conclusion

Containerization, particularly using Docker, offers a powerful solution for deploying and managing complex applications like our Next.js frontend. Throughout this post, we've explored the challenges of deploying microservices and the advantages that containerization brings to the table.
We've learned that:

  1. Containerization provides a middle ground between monolithic applications and resource-heavy virtual machines.
  2. Docker allows us to package our Next.js application along with all its dependencies into a portable, consistent environment.
  3. The process involves creating a Dockerfile, which serves as a blueprint for building our container image.
  4. With a few simple commands, we can build, run, and manage our containerized Next.js application.

By containerizing our Next.js frontend, we've created a scalable, portable, and easily manageable component of our larger microservices architecture. This approach not only simplifies deployment but also enhances our ability to develop, test, and scale our application efficiently.

As we move forward in the world of modern web development, understanding and utilizing containerization technologies like Docker becomes increasingly crucial. Whether you're working on a small project or a large-scale SaaS application, the principles and practices we've discussed here will serve as a solid foundation for your containerization journey.
Remember, while Docker is the most popular containerization tool, alternatives exist, and the field is constantly evolving. Stay curious, keep learning, and happy containerizing!

Serverless Option

I‘ve developed a comprehensive Udemy course Serverless Fullstack with AWS/CDK/NextJS & Typescript that guides you through building a Serverless Single Page Application (SPA) from the ground up using AWS, AWS CDK, AWS SDK, Next.js and all written in TypeScript.

. . . .