Docker — Containerizing a Nextjs Application

Murtuzaali Surti
Murtuzaali Surti

• 10 min read

🏷️ docker

🏷️ nextjs

Containerization in it's entirety is an incredibly useful concept. From being able to execute applications in isolation, to being able to port them easily with all of their dependencies and configuration is all a developer could ask for.

After getting somewhat familiar with this concept, I decided to get my hands on it. So, let me walk you through the whole process of containerizing a frontend Nextjs application using Docker.

Note that this is an absolute beginner approach. I am not advising to use it in production. This is something new to me and I am still exploring Docker. I don't know, maybe you can help me make some improvements to this approach.

Anyways, this tutorial is for someone who wants to explore and get a hands on experience with Docker.

By the way, a huge shoutout to Nana Janashia who is an incredible DevOps instructor. I learned everything about docker from her youtube channel. 💙

Why use Docker? #

You might be thinking what's the point of containerizing an application? Well, installing dependencies, setting up database, and doing a lot of configuration every single time, isn't it better to just configure it once and ship it so that it can be run on another machine without any hassle?

And not only that, some dependencies pollute your local environment but with docker everything runs in an isolated environment giving you more control.

Okay but why can't we use a virtual machine if the end goal is to isolate everything? The problem is — VMs are heavy and they run on their own OS and kernel. Docker uses the resources of its host but has its own application layer and file system. You only need the docker engine to run a containerized application.

Apart from that, it makes it so much easier to collaborate with project team members, testers, and devops team to run the application regardless of their operating system.

Watch this video to learn what problems docker solves in development as well as the deployment process. ✨

Install Docker from docker.com! 🏃

Understanding Docker #

Docker revolves mainly around three core components: containers, images and volumes. Let's understand what are they and how they work together.

If you want to containerize your application, first you have to build an image of it. An image is nothing but a combination of your app code, dependencies, and configuration. It's like a complete package of your application, ready to be shipped.

A container is just a running instance of an image. It lets you run the application in an isolated environment. You can run multiple containers based on different images.

Volumes are often used to store persistent data. For example, if you to access the host' files from the container, you can use volumes to map the host path to a container path. I am yet to have a good grasp on the concept of volumes in docker but here's a good video on docker volumes.

Docker Files #

There are a bunch of docker-specific files which are used to configure docker. It's good to understand what each of them does, so here we go:

  • Dockerfile: It's used to build an image of an application.
  • docker-compose.yaml: A structured way to execute docker commands with a lot of options in order to handle containers.
  • .dockerignore: Similar to .gitignore in git, it is used to ignore files in docker.

Containerization of a Simple Nextjs Application

Create a nextjs app using the steps in their documentation.

While creating a nextjs application, I went for directory based routing and thus the app directory will act like the src directory. Now, you need to build an image of your application so that you can run it inside a container.

Building a docker image #

To create your own docker image, you need to create the Dockerfile. This file will have everything you need to package your application including dependencies and initial commands. Create the Dockerfile at the root of your project.

FROM node:18-bullseye-slim

RUN mkdir -p /home/yourapp
COPY . /home/yourapp

WORKDIR /home/yourapp

RUN npm install

CMD ["npm", "run", "dev"]

Directives such as FROM, COPY, RUN, etc., are specific to the Dockerfile. To execute and run your application you need node in your image, that's why we are directing docker to pull a nodejs image from docker hub which the app can use.

Selecting node images from docker hub is much more nuanced than this! Do your own research before selecting any node image.

Then, with the RUN directive, you are telling docker to run a command inside the image's file system. It will create a yourapp directory in the home directory.

Next, the COPY directive will copy the files from the current directory of your local system to the yourapp directory of image's file system.

To ignore certain files or directories such as .env, node_modules, etc., you can create a .dockerignore file and list them there.

With the WORKDIR directive, the current directory will be set to the root directory of your project in image's file system. If you don't specify it, npm will install dependencies in the root directory of the file system and not your project.

The npm install command will install dependencies and create node_modules folder so you don't need to copy it from your local system. The CMD directive is like an entrypoint command to run your built image. Docker will by default execute this CMD command when you run your built image in a container.

The use of the CMD directive is a bit more nuanced too.

Now that your image building instructions are ready, you can execute the following command to create an image.

docker build -t image_name:tag .

The -t flag is for specifying a tag to the image. You can specify something like 1.0 or anything you want by replacing the :tag placeholder. And, the . is actually the context path which will be used by Docker to find your project files. If you are at the root of your project, you can specify ., otherwise you have to give a path relative to where your terminal is currently pointing to.

After the command gets executed successfully, you should get something like this in your terminal.

[+] Building 55.4s (11/11) FINISHED                                                       docker:default
 => [internal] load build definition from Dockerfile                                                0.1s
 => => transferring dockerfile: 208B                                                                0.0s 
 => [internal] load .dockerignore                                                                   0.0s 
 => => transferring context: 72B                                                                    0.0s 
 => [internal] load metadata for docker.io/library/node:18-bullseye-slim                            2.5s 
 => [auth] library/node:pull token for registry-1.docker.io                                         0.0s
 => [1/5] FROM docker.io/library/node:18-bullseye-slim@sha256:d2617c7df857596e4f29715c7a4d8e861852  0.0s
 => [internal] load build context                                                                   0.1s
 => => transferring context: 18.36kB                                                                0.1s
 => CACHED [2/5] RUN mkdir -p /app/path                                                   0.0s
 => [3/5] COPY . /app/path                                                                0.1s
 => [4/5] WORKDIR /app/path                                                               0.0s
 => [5/5] RUN npm install                                                                          41.8s
 => exporting to image                                                                             10.3s
 => => exporting layers                                                                            10.3s
 => => writing image sha256:fb05dcdb130301a8a1f8afa39c01a4430c59127e266cb49bcb763b5dd73d7aef        0.0s
 => => naming to docker.io/image_name:tag                                  0.0s

And if you have docker desktop installed, you can see a docker image entry in the images tab.

That's the basic process for building an image in docker from a Dockerfile. You can port this image to any other machine and it will execute exactly the same as it will on your machine.

But still, your app isn't running! Why? Because you didn't run the image. And that's where containers come into action.

Running Image in a Container #

As you know, a container is nothing but a running instance of an image. So how do you run an image? Using docker run:

docker run -d -p5000:3000 --name container_name image_name:tag

What's going on with this command? you may ask! Well, nothing much — the first flag -d is used for detached mode which basically means the container will keep running in the background by freeing your terminal. If you don't specify it, the container will log everything in the terminal and once you kill the process, the container will exit and stop running.

The second flag -p is used for port binding. Port binding in docker is a way to map the container port to a port on your machine i.e. host port. Let's say you are running your application at port 3000 inside the container and because the container is isolated, it has it's own ports, and thus you have to specify which port on your local machine will it forward the application content to.

Here, with -p5000:3000 you are binding the 3000 port of your container to the port 5000 of your local machine. So, the syntax is: -pHOST:CONTAINER.

The next two arguments are quite straightforward. You have to specify a container name and the name of the image which you want to run with it's tag.

If you have some env variables, you can specify them in an env file and then use the flag --env-file to specify the path of it.

After running the command, you will see a container being created and the command which we specified in the CMD directive will get executed, and the app will be live at port 5000!

To see all the running containers just run docker ps and to view all containers, run docker ps -a.

But there's a catch, every time you need to run an image, you have to run the docker run command with a long list of options. This isn't feasible and and it's time consuming when you have multiple services or containers talking to each other in a complex application. To overcome this, there's a file named docker-compose.yaml which you can use to specify the containers with all of their required options and env variables. With only one container, your docker compose file can look something like this:

services:
  your_container_name:
    container_name: your_container_name
    image: image_name:tag
    env_file: <<path to env file>>
    ports:
      - 5000:3000
    command: npm run dev

To run docker compose, execute docker-compose up -d with a detached flag for the container to run in the background. To stop and remove the container, run docker-compose down!

Persistent Data - Volumes #

Try updating your nextjs code locally and see if you can see those changes being reflected in your containerized application. Does it update? No, it won't. The reason for that is your local code is not in sync with the code in the container, i.e. the code running in the container hasn't updated and is still the same.

To overcome this issue, you need to keep some of your container files in sync with the local files. Volumes can be used to do that. You need to map your local directory to the container's directory with the help of a volume, which will help docker to listen for changes in the local directory and update the container directory!

In your docker-compose.yaml file, add the following:

services:
  container_name:
    # ...
    volumes:
      - ./app:your_container_app_dir/app
    # ...

For the current nextjs application, you can persist the app directory (if you opted for directory based navigation) or the src directory. The path before the colon : is your local directory's relative path to the app directory and the path after : is the absolute path to where the project lives and it's app directory.

Live Reloading (HMR) #

All of that takes care of synching files, but hot reloading still won't work when you make any changes to the app directory. For that to work, you have to add a webpack config to the next.config.js file.

const nextConfig = {
  webpack: (config => {
    config.watchOptions = {
      poll: 1000,
      aggregateTimeout: 300,
      ignored: ['**/node_modules']
    }
    return config
  })
}

The reason behind the use of polling is that webpack and other similar bundlers use some packages such as fsevent or inotify to detect file changes. The problem with that is that docker uses it's own filesystem which is linux based irrespective of the OS' filesystem. Maybe I am missing some details, but that causes discrepancies in the file change detection and live reload process. Polling works on a network level, thus resolving the filesystem issue, but some say it can be expensive and slow. — stackoverflow.com/a/46804953

After updating your next config, you need to rebuild the image because you didn't persist that file to reflect your local changes, rather added it as a one time static file using the COPY directive. A better approach would be to add whole project directory to the volume so that you don't have to rebuild the image, but it's debatable.

Final Words

That's it, you just built your own docker image and ran it in a container. If you want, you can also publish your docker image to docker hub.


WebSockets 101

Previous

Sharing Localhost From VS Code - Port Forwarding

Next