Creating your first Docker application
By: Uday Hiwarale
Tags: Docker Image, Dockerfiles, Nodejs, Programming, Docker

The Dockerfile

In this lesson, we are going to create a Docker container that starts a simple HTTP server using ExpressJS. This HTTP server returns a JSON response with a list of users. And that’s about it, nothing more.

💡 You will need Node.js installed on your system to test the Docker application we are building. You can download it from this official website or use NVM (node version manager) CLI utility. However, this step is not necessary because we want to run this application inside a Docker container which will have the Node.js installation.

As we know, to create a container, we need a Docker image and to create a Docker image, we need a Dockerfile. Well, that’s not always true. Someone might have already created such an image that does the exact same thing. You can find public images on the Docker Hub registry. So you can just use the $ docker pull <image_name> command to download it from this registry.

But since we are trying to understand the process of creating Docker images, we are going to build an image on our own using a Dockerfile. You can find the source code of this project in this GitHub repository. So let’s create the project directory and install the dependencies.

$ mkdir docker-express-example && cd docker-express-example

$ npm init -y

$ npm install --save express

Above commands will generate package.json and package-lock.json files as well as node_modules directory inside our project. So far our project structure looks like this.

docker-express-example/

├── .dockerignore

├── .gitignore

├── Dockerfile

├── db.json

├── images

| ├── female.png

| └── male.png

├── node_modules/...

├── package-lock.json

├── package.json

└── server.js

We will talk about other files in our project, but for now, let’s focus on the server implementation. The db.json file contains a list of users. The server.js file serves the db.json from the / endpoint and images inside images/ directory from the /images endpoint.

The port at which this server will listen is received from the SERVER_PORT environment variable. Therefore, when we start the server, there need to provide a value for this environment variable. I have done that through the start command in package.json .

Now when we run the $ npm run start command from the project directory, we should receive the Server started at port 3000. message in the terminal and we should be able to see the JSON response in the browser at the http://localhost:3000 endpoint.

Now that our HTTP server is working as expected, let’s discuss why we want to containerize it. The USP of Docker to run applications in an isolated environment without having to install dependencies to run them. Also, we can spawn many replicas of the application really quickly as containers are cheap.

For example, if you wanted to share this application with your colleagues or users, then you have to share the source code and ask them to install Node.js to be able to run this application. Now, this can also lead to various problems such as runtime related issues (due to Node.js version difference), platform-related issues (Mac vs Windows), or other thousands of things.

To solve this issue, what we can do is to create a Docker image and put all the contents of this project inside it. This image will also have all the necessary dependencies to run this application such as a standard Node.js installation and whatnot. Then we can publically or privately publish the image so that other people can download it. Once they download the image, they can create a container from it and start the application (HTTP server) in a few seconds (or in a fraction of a second).

Alright. So we just got the gist of how Docker can be useful. So let’s start building our Docker image for this application. As we discussed, to build an image, we need a Dockerfile. This file is usually placed in the root directory of the project but we can place it anywhere in the project. More on that later.

Before we start working on the Dockerfile, let’s understand the requirements to successfully run this application. First of all, we need an operating system with Node.js installed (with NPM). Then we need package.json as well as package-lock.json files to install the dependencies. Then we need all the source code of the project. And then we need SERVER_PORT environment variable to launch the server. For this, the Dockerfile looks like below.

Dockerfile is a simple text file and it contains the instructions for the Docker daemon to assemble the image. Any text that starts with # is a comment but it can also be a parser directive. Let’s go through each instruction in our Dockerfile and understand their contribution to the image building process.

FROM instruction

The FROM instruction sets a parent image for the new image we are about to create using this Dockerfile. The parent image includes all the necessary things we need such as an operating system environment (alpine) and Node.js installation (v12.20.0). You can look for official images on Docker Hub. Here, we are using node:12.20.0-alpine3.10 image as our parent image.

💡 In the node:12.20.0-alpine3.10 image name format, the node part is the image name while 12.20.0-alpine3.10 after the : is called a tag. If you do not provide the :tag part, :latest tag will be used by default. We will talk about this format in another lesson.

You might hear the term “base image” used interchangeably with the parent image but there is a slight difference. The base image is the last image in the parent-child hierarchy. For example, our new image is derived from the node image, the node image is derived from the alpine image (see here) and the alpine image is derived from the scratch image (see here).

Now the scratch image contains absolutely minimal things to work with the Docker engine and it’s the last image in the parent-child hierarchy which means it’s not based on any other image. You can use this image to install an operating system, software, tools, etc., and make a parent image. Therefore, any image that inherits scratch using the FROM scratch instruction is a base image. To create your own base image, follow this documentation.

💡 Instructions in the Dockerfile are case-insensitive which means you can also use from <image> instead of FROM <image> . But it is recommended to use uppercase letters for instruction names to increase the readability.

Ideally, FROM instruction should be the first instruction in the Dockerfile. We can have more than one FROM instructions in the Dockerfile in the case of a multi-stage build which we will cover in another lesson.

WORKDIR instruction

The second important instruction in the Dockerfile is WORKDIR . This instruction sets a working directory for the instructions in the Dockerfile (such as RUN , CMD , ENTRYPOINT , COPY , ADD , etc.) that are going to access or modify the filesystem of the image using a relative path. If this directory is not already present in the filesystem, it will be created while creating the image.

💡 We can have multiple WORKDIR instructions in the Dockerfile. Each instruction will set a new working directory for the next instructions. We can provide a relative path to the next WORKDIR instruction which will relative to the previous WORKDIR path.

In our case, we have set the /app directory (relative to the root of the filesystem of the image) as the working directory. Since this directory doesn’t exist in the filesystem of the node image, it will be created.

You can consider the working directory as the directory in the image where a terminal shell will be opened and all relative paths used in the commands to access or modify files will be relative to this directory.

Similarly, a build context is a directory on the host machine (on which we are creating the Docker image) which is sent to the Docker daemon while building an image that contains Dockerfile , .dockerignore and application-related files such as package.json , images/ , .etc. This directory is provided via the $ docker build <path> command used to create the image where path is the path of the build context directory. In our case, it will be the path to docker-express-example directory.

ADD instruction

The ADD instruction copies the file from the build context to the working directory (for relative destination paths) or any other directory (for absolute paths) of the image. We can copy one or more source files from the build context to a destination in the image. There are two ways to use this instruction as represented below. The latter is useful for files containing whitespaces.

ADD <src>... <dest>

ADD ["<src>",... "<dest>"]

💡 The ADD instruction can also copy files from remote URLs such as a Git repository. If src is a local tar archive file, then it will be extracted at the dest during the copy operation. Read more about this behavior from this documentation. This functionality is not supported in the COPY instruction.

In our case, we are first copying the package.json and package-lock.json file to the working directory (specified by ./ path) so that we can install the NPM dependencies. But doesn’t the build context already contain node_module directory which contains all the dependencies? Can’t we just copy it from the build context into the workspace directory?

First of all, we should really avoid sending large, unnecessary, and sensitive files to the Docker daemon which could get copied to the image using the ADD or COPY instructions. The .dockerignore file works just like .gitignore file but excludes the files from the build context while sending it to the Docker daemon. We have excluded node_module directory by adding node_modules line inside the .dockerignore file.

💡 If src path doesn’t belong to the build context such as ../file.txt , then ADD or COPY operation will fail.

RUN instruction

The RUN instructions run commands at build time. This could be a bash command such as rm -rf ./file.txt or any complex command such as to download files, access or modify the file system, .etc. Results of the RUN commands are committed to the image such as a file modification. There are two flavors of RUN commands as illustrated below.

RUN <command>

RUN ["executable", "param1", "param2"]

The former format of the instruction is called the shell form because it is executed with the /bin/sh -c by default while the latter is called the exec form since you can pass a custom executable.

In our case, we are running npm install command using the default shell of the image which installs NPM dependencies in the workspace directory.

ENV instruction

The ENV instruction sets an environment variable in the image. The value of this instruction is a series of key=value pairs, however, you can have multiple ENV instructions in the Dockerfile.

ENV <key>=<value> ...

We can use the value of an environment variable in other instructions. For example, we have used the value of SERVER_PORT environment variable in the EXPOSE instruction. You can do the same for RUN or CMD instructions but there are some caveats while using the exec form, read more.

These environment variables will persist in the container as well. This means when we create a container from this image, you can access the value of this environment variable (such as using the process.env.SERVER_PORT in the case of a JavaScript program running on Node). You can override the value of an environment variable set by ENV using the --env or -e flag of the $ docker run command which creates a container from the image.

If you do not want an environment variable to be accessible in the container but have the presence during the build time only, then consider the ARG instruction. You can also override the default value of the ARG instruction using the --build-arg flag just like --env flag.

EXPOSE instruction

A container is an isolated environment with its own filesystem and network just like having a virtual guest operating system. Therefore a container does not use the ports of the host machine. All the ports in the container are closed behind a firewall and the host machine (the one running the container) can not access it.

EXPOSE <port> [<port>/<protocol>...]

The EXPOSE instruction instructs the container to open a certain port and make it public to the host. You can have as many EXPOSE instructions as you want in the Dockerfile. While creating a container from the image, you can then choose to bind a port on the host machine to one of these open ports to send the traffic from the host_port to container_port using the --publish or -p flag of the $ docker run command.

In our case, since the default value of SERVER_PORT environment variable is 8000 , only the port 8000 will be exposed to the host when the container is running. But this value can be changed by changing the value of SERVER_PORT environment variable using the --env flag which means we can control which port is exposed during the runtime.

💡 You can use the --expose flag with the $ docker run command to expose a port from the container during runtime.

COPY instruction

The COPY instruction works just like the ADD instruction. It copies the files from the build context to the workspace directory or any other directory of the image.

COPY <src>... <dest>

COPY ["<src>",... "<dest>"]

However, the COPY instruction does not support URLs and it does not extract sources that are tar archives. So it is recommended to use COPY instead of ADD to avoid mistakenly downloading files or extracting archives.

However, the place where COPY shines is the ability to copy files from another image using the --from=<name> flag with the COPY instruction. Here, the name is the name of the image that was generated in the multi-stage build process. We will learn more about this and the multi-stage build in a separate lesson.

In our case, we are copying files from the . to . which means copy the contents of the build context recursively into the workspace directory by honoring the excluded files mentioned inside the .dockerignore file.

CMD instruction

The CMD instructions work just like the RUN instruction but they do have a big difference. As discussed, the RUN instruction executes a command during the build and commits the results of the command to the image.

CMD <command>

CMD ["executable", "param1", "param2"]

However, the CMD command doesn’t get executed during the build. It is executed by default when we create the container. That means CMD will have an effect during runtime. So if you want to start a server or any process as soon as the container is created, CMD is the right choice.