Next.js 13 and Docker: Unleashing Web Development Magic, One Container at a Time!

Learn how to create and optimize a Next.js docker image like a pro.

🚢 Ahoy, code adventurers!

🌟 Brace yourself for an exciting journey into the world of Next.js 13 and Docker! Welcome to our weekly blog, where we blend the magic of website building with the power of smart containers, all while having a boatload of fun! ⚡️

Similar to Dockerizing other applications, encapsulating your Next.js application on a docker container brings numerous advantages. Firstly, you get to ship the entire stack—not just your code, but also the operating system, specific versions of other languages or executables, and more. This means bidding farewell to the dreaded "works on my machine" syndrome.

But that's not all! Docker also empowers you to run multiple copies of your application, allowing for horizontal scaling. With the ease and cost-effectiveness of spinning up a docker container, scaling your Next.js app becomes a breeze, especially when paired with a container orchestrator like Kubernetes. The benefits of portability and scalability offered by Docker are game-changers for your Next.js applications.

And let's not forget about the caching challenge posed by Next.js applications that utilize server-side rendering and API routes. Since these features can't be cached on a Content Delivery Network (CDN), Dockerizing your Next.js app becomes a seamless solution. It enables easy deployment into a Kubernetes cluster or even serverless containers, opening up a world of possibilities.

So, get ready to explore the fascinating realm where Next.js 13 and Docker intersect. Join me each week as I uncover invaluable tips, tricks, and insights to take your web development adventures to new heights. Let's set sail and unlock the full potential of Next.js and Docker together! 🚀

Key Requirements for Building a Docker Image

To ensure a smooth creation of the Docker image, there are a few prerequisites that need to be met:

  1. Node.js and NPM must be installed on your system.

  2. Docker needs to be installed as well.

Once these requirements are fulfilled, we can proceed with creating the Next.js app.

Creating a Next.js app

In this article, we will work with a basic next js app generated when you run the command:

npx create-next-app@latest nextjs-docker-app

Run the development server using the command npm run dev which should give an output that the server is running on localhost:3000. When you open your browser on the provided URL, you should have an output similar to the one below:

Writing a Dockerfile

Before we begin, let us first define the terms Container and Image, and why we are building a Dockerfile.

An image, or a pre-built package containing the application code and its dependencies, is required to execute within a container. To construct an image, you'll require a Dockerfile, which is a set of instructions that instructs Docker how to build the image.

Docker, in a nutshell, allows developers to simply generate, distribute, and deploy images, resulting in faster development cycles and simpler application management.

With that stated, let's create a file named, "Dockerfile" in our root directory and paste the following content within it.

FROM node:16-alpine
RUN mkdir -p /app
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

Here, we defined a base image i.e. node:16-alpine.

What exactly does it do? It will download the node:16-alpine image from the Docker Hub (with Node. js, npm, and other necessary tools pre-installed).

Afterward, as per the instruction, it creates a directory and sets it as a working directory, then copies the application files into the working directory. The “RUN npm install” and “RUN npm run build” lines install dependencies & build the application. Finally, we expose port 3000, and start the application using the command "npm start".

This was our configuration stored inside the Dockerfile.

Now, let’s build an image using this.

docker build -t nextjs-docker-app ./

We tagged(or named) this image as "nextjs-docker-app" and then specified the directory where the Dockerfile is located, which is the root directory for us.

As you can see from the logs, it is retrieving the base image and then executing everything specified in the Dockerfile.

After the process finishes, it will generate a docker image, which you can see using the "docker images" command.

It's 894MB in size and in the next section, we’ll see how you can reduce this number drastically.

To run this image within a container(specifying the port as 3000) use the following command.

docker run -d -p 3000:3000 nextjs-docker-app

You can now go to localhost:3000 and see your app running, but this time from the Docker container.

But wait, our docker image is not optimized 🤨.

Optimizing the Docker Image

Previously, we created a simple Dockerfile and provided some basic instructions to create an image that can be run inside a container.

However, there is room for further optimization as the current image size is 894MB which is not ideal for production apps. There are various methods to optimize the image size, and we will focus on a simple approach based on the factors listed below.

  1. Pick a different smaller base image(it’s not always the case): You can use a smaller base image to reduce the image size.

  2. Combine commands: You may combine the “RUN npm install” and “RUN npm run build” instructions into a single ”RUN npm ci —quiet && npm run build” command. This reduces the number of layers in the image and its size.

  3. Use multi-stage builds: You can divide your Dockerfile into two stages, with the first stage building your app and in the second stage you can copy only the files required. This will reduce redundant files and environments that we created in the first stage.

Here, we will primarily use multi-stage builds to demonstrate how we can easily reduce the size by roughly 100 MB.

Let's proceed with the optimization process by replacing the contents of the existing Dockerfile with the following instructions.

# Build Stage
FROM node:16-alpine AS BUILD_IMAGE
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build


# Production Stage
FROM node:16-alpine AS PRODUCTION_STAGE
WORKDIR /app
COPY --from=BUILD_IMAGE /app/package*.json ./
COPY --from=BUILD_IMAGE /app/.next ./.next
COPY --from=BUILD_IMAGE /app/public ./public
COPY --from=BUILD_IMAGE /app/node_modules ./node_modules
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"]

We have divided our Dockerfile file into two sections i.e. "Build Stage" and "Production Stage". And the commands are pretty self-explanatory and straightforward.

For the build stage, the commands are similar to the Dockerfile we created earlier. On the other hand, in the production stage, we are just copying the files that we need from the build stage and running the app.

Let’s build a new app with the new Dockerfile and name this image "nextjs-docker-app-2"

docker build -t nextjs-docker-app-2 .

And as you can see from the command "docker images", the second image saved around 791MB.

You can run this image by running the command "docker run -p 3000:3000 nextjs-docker-app-2" as well, and you will get the app on the browser.

Taking Docker Image Optimization to the Next Level

As we can see, even after optimizing images with the help of multi-stage builds, we don't see significant image optimization because smaller Docker images are easier to deploy and scale. This is why we will be exploring other ways to further optimize our image size.

For that, create or update "next.config.js" file in the root directory to contain the below code.

/**
* @type {import('next').NextConfig}
*/

const nextConfig = {
   experimental: {
       outputStandalone: true,
   }
}

module.exports = nextConfig

According to the documentation, it will create a folder at “.next/standalone” which can then be deployed on its own without installing “node_modules”. It is also one of the most effective methods for optimizing the docker file. You can learn more about it here.

Let's modify the Dockerfile now.

FROM node:18-alpine as builder
WORKDIR /my-space

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine as runner
WORKDIR /my-space
COPY --from=builder /my-space/package.json .
COPY --from=builder /my-space/package-lock.json .
COPY --from=builder /my-space/next.config.js ./
COPY --from=builder /my-space/public ./public
COPY --from=builder /my-space/.next/standalone ./
COPY --from=builder /my-space/.next/static ./.next/static
EXPOSE 3000
ENTRYPOINT ["npm", "start"]

The code in this section is mostly identical to the Dockerfile we created earlier using a multi-stage build.

As you can see, we did not use node_modules here, but rather the standalone folder, which will optimize the image to a greater extent.

Let’s build a new app with the new Dockerfile and name this image "nextjs-docker-app-3".

docker build -t nextjs-docker-app-3 ./

The resulting image size has now been reduced to 203MB.

Now that's impressive. We can have a faster deployment and enhanced scalability 🥳.

What Really Matters for Docker Image Disk Usage

In the earlier section on optimizing Docker images, we explored many aspects that might influence image size, such as using a smaller base image. However, some sources, such as Semaphore, claim that the size of the base image is irrelevant in some cases.

Instead of the size of the base image, the size of frequently changing layers is the most important factor influencing disk usage. Because Docker images are composed of layers that may be reused by other images, the link between size and disk usage is not always clear.

Two images with the same layers, for example, might have drastically different disk consumption if one of the layers changes often. As a result, shrinking the base image may be ineffective in saving disc space and may restrict functionality.

If you wish to go more into the topic, you may do so here.

When to Consider Docker?

Docker, as we've seen, is a great tool for managing software dependencies and providing consistent environments.

Knowing when to use Docker and when to create a Docker image is crucial in achieving the benefits of this platform.

Docker is ideal for:

  1. An Isolated environment (for creating and testing apps)

  2. Deployment purposes

  3. Scalability

  4. Continuous Integration and Deployment (CI/CD)

It goes without saying that if you're using Docker, you'll need Docker images as well.

So, whether you require an isolated environment, want to deploy apps reliably and consistently, or want to ensure consistency across different environments, Docker can help.

That's all for now folks.

Happy coding

:)