Prerequisites

To follow through with this tutorial, you need to have a recent version of Node.js and npm installed on your computer server. You also need to ensure that Docker is also installed on your machine. The following versions were used while testing this tutorial:

  • Docker version 20.10.14, build a224086 (docker -v).
  • Node.js v16.14.2.
  • npm v8.6.0.

betteruptime-product.png

🔭 Want to get alerted when your Node.js app stops working?

Head over to Better Uptime start monitoring your endpoints in 2 minutes

Step 1 — Downloading the demo project

To demonstrate the steps involved in deploying Node.js applications with Docker, we will utilize a simple Node.js app that presents a random Chuck Norris joke in your browser. Go ahead and clone it to your machine through the command below:

 
git clone https://github.com/betterstack-community/chucknorris

Afterward, cd into the chucknorris directory and download the application's dependencies through the commands below:

 
cd chucknorris
 
npm install

You can start the application through the following command and go to http://localhost:3000 in your browser to see it in action.

 
npm run dev

Chuck Norris App in action

You have now set up a working Node.js application that is ready to be deployed using Docker. In the next steps, we'll take look at how you can build a Docker image for this app and run it inside a Docker container.

Step 2 — Creating a Dockerfile

Dockerfile is a text document that contains instructions for assembling a Docker image, and these instructions are executed in the order in which they are written. The format of this file is shown below:

Dockerfile
# Comment
COMMAND arguments

Any line that begins with a # is a comment (except parser directives), while other lines must contain a specific command followed by its arguments. Although command names are not case-sensitive, they are often written in uppercase to distinguish them from arguments.

The first (non-comment) line in the Docker file must indicate the parent image that should be used as the foundation for our custom image. Subsequent commands are executed on this parent image, and the result of each successive instruction adds a new layer to this image before the final image is built and its ID is outputted to the console.

Go ahead and create a Dockerfile for your application and open it in your text editor using the command below:

 
nano Dockerfile

Paste the following contents into the file:

Dockerfile
# Use Node 16 alpine as parent image
FROM node:16-alpine

# Change the working directory on the Docker image to /app
WORKDIR /app

# Copy package.json and package-lock.json to the /app directory
COPY package.json package-lock.json ./

# Install dependencies
RUN npm install

# Copy the rest of project files into this image
COPY . .

# Expose application port
EXPOSE 3000

# Start the application
CMD npm start

Here's an explanation of what each line in the file indicates:

 
# Use Node 16 alpine as parent image
FROM node:16-alpine

The first instruction in a Dockerfile involves selecting a base image which is the official Node.js Alpine Linux image for v16.x. If you take a look at the Dockerfile for this image, you'll notice that it does all of the work of setting up a Node.js environment for you so that you don't need to spend time on such details when creating a Docker image for your Node.js app. All subsequent instructions in this file will be committed on top of our chosen base image.

 
# Change the working directory on the Docker image to /app
WORKDIR /app

The WORKDIR command defines the working directory of a Docker image for any RUNCMDENTRYPOINTCOPY, or ADD instructions that follow it in the file. This directory will be created if it doesn't exist already.

 
# Copy package.json and package-lock.json to the /app directory
COPY package.json package-lock.json ./

This COPY command copies the package.json and package-lock.json files from the project directory on your machine to the filesystem of the container in the current working directory which is /app as indicated by the previous WORKDIR instruction.

 
# Install dependencies
RUN npm install

At this point, the npm install command will be executed from the /app directory in the Docker image filesystem. Since it contains the package.json and package-lock.json files, it will use the information in both files to download all the dependencies from the NPM registry.

 
# Copy the rest of project files into this image
COPY . .

After installing the project's dependencies, the COPY command is used once again to copy the rest of the project files to the /app directory on the Docker image filesystem.

 
# Expose application port
EXPOSE 3000

Afterward, the EXPOSE command informs Docker that our application will listen on port 3000 at runtime. You can use the TCP or UDP protocol here (as in 3000/tcp or 3000/udp), although TCP is the default if the protocol is unspecified.

 
# Start the application
CMD npm start

Finally, the CMD instruction is used start the application by running the start script specified in the package.json. This command is executed when the container based on this Docker image is launched.

At this point, we have specified all the necessary instructions for building a Docker image for our project. In the next step, we will execute the instructions to build the image for the first time.

Step 3 — Building the Docker image

The docker build command is used to build a Docker image from a Dockerfile. Run the command below from your project root to build the Docker image for our project:

 
docker build . -t chucknorris
 Output
. . .
Successfully built cd4bdd2ae572
Successfully tagged chucknorris:latest

The command above builds a Docker image using the Dockerfile in the current directory. The -t flag is used to set the tag name for the new image so that it may be referenced later as chucknorris:latest.

You can now run the docker images command to view some information on the Docker image all the Docker images on your machine, or pass an image repository and tag to only display info about a specific image:

 
docker images chucknorris:latest
 Output
REPOSITORY    TAG       IMAGE ID       CREATED         SIZE
chucknorris   latest    cd4bdd2ae572   7 minutes ago   135MB

Step 4 — Running your Docker image as a container

In the previous section, we created a Docker image that contains our Node.js project. We can now run that image in a Docker container and test if our application is running correctly. Ensure to kill any running instances of your application before executing the command below:

 
docker run -p 3000:3000 chucknorris

You should observe the following output:

 Output
> chucknorris@1.0.0 start
> node server.js

chucknorris server started on port: 3000

When you run a Docker image in a container, it creates a typical operating system process that has its filesystem, networking, and process tree separate from the host machine. Although we used the EXPOSE command in step 2 to indicate that the application running inside the container will listen on port 3000, this command does not make the container's port accessible from the host. It only ensures that another Docker container running on the same host can access the application running on the specified port.

To make the container ports available to the host machine, you need to publish it through the --publish or -p flag. It lets you to map a container's port to a corresponding host port. For example, in the previous command, port 3000 on the host is mapped to port 3000 on the container so that all requests made to http://localhost:3000 are forwarded to the application listening on port 3000 in the Docker container.

Try it out by opening http://localhost:3000 in your browser. You should observe the Chuck Norris application working as usual.

Chuck Norris App in action

You should also observe new log entries in the terminal instance where you executed the docker run command:

 Output
. . .
GET / 200 940 - 858.659 ms
GET /css/style.css 304 - - 1.365 ms
GET /javascript/script.js 304 - - 0.675 ms
GET / 200 951 - 825.021 ms
GET /css/style.css 304 - - 0.704 ms
GET /javascript/script.js 304 - - 0.516 ms
GET / 200 994 - 1247.833 ms
GET /css/style.css 304 - - 1.191 ms
GET /javascript/script.js 304 - - 0.660 ms
. . .

Note that you can bind any host port you want, so you don't have to use port 3000 on the host machine. For example, let's bind port 8080 on the host instead:

 
docker run -p 8080:3000 chucknorris
 Output
> chucknorris@1.0.0 start
> node server.js

chucknorris server started on port: 3000

At this point, you'll be able to access the application on port 8080 instead of port 3000 as before.

Chuck Norris app on port 8080

You can also bind your Docker container to multiple host ports by specifying multiple -p arguments:

 
docker run -p 3000:3000 -p 8080:3000 chucknorris

This will cause the Chuck Norris application to become accessible on both port 8080 and port 3000 on the host machine.

Step 5 — Running your Docker container in detached mode

You'll notice that when we used the docker run commands in the previous section, our terminal instance was connected to the Docker container. It's not ideal for our web server process to be tied to a specific terminal instance, therefore, we will run it in the background using the --detach or -d flag. Ensure to kill any running instance of your Docker container with Ctrl-C before executing the command below:

 
docker run -d -p 3000:3000 chucknorris
 Output
5441f8b2532b111e9dea8aeb55563c24302df96710c411c699a612e794e89ee4

Docker will launch your container as before, but instead of connecting your terminal instance to the container, it will print the container ID and return you to your terminal prompt. You can use this container ID to access details about this container in subsequent commands.

Right now, you can use the docker ps command to view all the running containers on your machine:

 
docker ps
 Output
CONTAINER ID   IMAGE         COMMAND                  CREATED          STATUS          PORTS                                       NAMES
5441f8b2532b   chucknorris   "docker-entrypoint.s…"   10 minutes ago   Up 10 minutes   0.0.0.0:3000->3000/tcp, :::3000->3000/tcp   boring_gould

The ps command presents some information about the running containers on your machine. You can see the container ID, the Docker image running inside the container, the command used to start the container, when it was created, its current status, the ports exposed by the container, and the container name. Docker assigns a random name to the container by default, but we can change this by using the --name flag.

Stop running your container first by using the provided name in the NAME column:

 
docker stop boring_gould
 Output
boring_gould

Afterward, use the rm command to delete it:

 
docker rm boring_gould
 Output
boring_gould

You can now start it again and provide a --name argument this time around:

 
docker run -d -p 3000:3000 --name chucknorris-server chucknorris
 Output

When you run docker ps once more, you'll notice that the NAME column reflects your argument to the --name flag. Henceforth, you can identify your running container through the chucknorris-server name.

 
docker ps
 Output
CONTAINER ID   IMAGE         COMMAND                  CREATED         STATUS         PORTS                                       NAMES
f0bd97af5b2e   chucknorris   "docker-entrypoint.s…"   3 seconds ago   Up 2 seconds   0.0.0.0:3000->3000/tcp, :::3000->3000/tcp   chucknorris-server

Step 6 — Viewing your container logs

When you run your Docker container in detached mode, you're no longer able to view your application logs in the console since the process is being run in the background. Docker provides the logs command to view the logs of a running container, and you can use it to monitor your Chuck Norris server as shown below:

 
docker logs chucknorris-server
 Output
> chucknorris@1.0.0 start
> node server.js

chucknorris server started on port: 3000
GET / 200 981 - 1763.992 ms
GET /css/style.css 304 - - 2.767 ms
GET /javascript/script.js 304 - - 0.855 ms

You can also continuously monitor container log files as they're written by including the -f or --follow flag:

 
docker logs chucknorris-server -f

If you want to learn more about how logging works in Docker containers and some best practices for collecting and storing log entries emitted by the applications running in such containers, check out our article on logging in Docker for a more comprehensive discussion of the topic.

Step 7 — Sharing Docker images with others

After building a Docker image for your application, you might want to transfer it to a different machine or share it with a colleague so that they can easily run the application without having to build it all over again with docker build. This also ensures that everyone's machine is running the exact same software without any variations which can help with avoiding the "it works on my machine" problem.

There are two major ways to share a Docker image. The first one involves utilizing container registries from DockerGitLabGoogle CloudRedHat, and others. You can also set up a private container registry for your organization to easily share Docker images with teammates.

Once you've set up the registry you'd like to use, you can use the docker push command to send the Docker image to the registry, and docker pull to retrieve the image from the registry. Let's try it out by pushing our chucknorris image to the official Docker registry. You need to sign up for a free account first.

Once you're done with the signup procedure, find the Repositories entry in the top navigation, and click the blue Create Repository button.

Docker Hub Repos

Give your repository a name (such as chucknorris), and choose the visibility of the repo. Note that free accounts are limited to just one private repository. Afterward, click the blue Create button.

Docker Hub create repo

Once your repo is created, execute the command below to log into Docker Hub on your server. Enter the username and password combo that you used to set up your Docker Hub account.

 
docker login

Docker Hub login

Once the login succeeds, you can now push your Docker image to your repository. Before you use the docker push command, ensure that your image tag matches your repository namespace (<your_docker_hub_username>/<your_docker_hub_repo>) because the push expects its argument to match this format. You can create a new tag on an image using the command below:

 
docker tag chucknorris <your_docker_hub_username>/chucknorris

After executing the command above, you can run docker images to view the changes:

 
docker images
 Output
REPOSITORY              TAG         IMAGE ID       CREATED        SIZE
ayoisaiah/chucknorris   latest      cd4bdd2ae572   43 hours ago   135MB
chucknorris             latest      cd4bdd2ae572   43 hours ago   135MB
node                    16-alpine   59b389513e8a   13 days ago    111MB

You are now ready to push your Docker image to the remote repository. Enter the command below to push the chucknorris image to your repo:

 
docker push <your_docker_hub_username>/chucknorris
 Output
Using default tag: latest
The push refers to repository [docker.io/ayoisaiah/chucknorris]
f4a6dd0924eb: Pushed
d00ab9f1c441: Pushed
01e349f65d42: Pushed
e38d70150f2d: Pushed
9c8958a02c6e: Mounted from library/node
b5a53db2b893: Mounted from library/node
cdb4a052fad7: Mounted from library/node
4fc242d58285: Mounted from library/node
latest: digest: sha256:79b31c0e3bd66d8b000bbe9740cf10e8e04f1598fbf878f763ba55713800f2fd size: 1995

Once the upload is done, you can download the image on a different machine using the docker pull command. You need to make sure you're logged in first if the image of interest is in a private repository.

 
docker pull <your_docker_hub_username>/chucknorris

Another way to share Docker images with others without using a registry is by exporting it to a .tar archive as shown below:

 
docker save chucknorris > chucknorris.tar

You should observe a new chucknorris.tar archive in your current working directory that contains everything needed to recreate the image. You can now transfer this archive to another machine through any method you wish and run docker load on the target machine to import the archive's contents and add it to your list of local images:

 
docker load < chucknorris.tar
 Output
Loaded image: chucknorris:latest

Using a Docker registry to share images is probably best for frequent use, but converting an image to a tar archive can come in handy for long-term storage or for a quick transfer between local machines.

Step 8 — Configuring a Docker CI/CD pipeline with GitHub Actions

Building and testing Docker images can get tedious really quickly if you do it often, but setting up a CI/CD pipeline for automating this process can help. In this step, you'll set up a GitHub Actions workflow for building the Docker images and pushing them to Docker Hub so that they're ready to deploy at any time. This section assumes that you have some basic familiarity with GitHub Actions.

Start by returning to the Docker Hub website to create a Personal Access Token that will allow you to access your Docker Hub account resources. Head over to the security settings page, and click the New Access Token button.

Docker Hub Security Settings

Give your token a description (such as chucknorrisci), and set its permissions to Read, Write, Delete. Click the Generate button once you're done.

Docker Hub New Token

Your access token will be displayed in the resulting dialog. Make sure you copy it and store in a safe place as it will be displayed only once.

Docker Hub Copy Token

Now, go to the GitHub repository for your project and go to Settings → Secrets → Actions.

GitHub Repo Secrets

Click the New repository secret button and enter your Docker Hub Access Token with its Name field set to DOCKER_HUB_ACCESS_TOKEN.

GitHub Repo Docker Hub Access Token

You can also add a secret for your Docker Hub username under the DOCKER_HUB_USERNAME name:

GitHub Repo Docker Hub Username

Afterward, your Actions secrets page should look like this:

GitHub Repo List Secrets

You are now ready to set up the GitHub Actions workflow for your repository. Return to the command line, and make sure you're in the root of your project. Create the .github/workflows directory in your project using the command below:

 
mkdir -p .github/workflows

Create a docker.yml file in the .github/workflows directory and open it in your text editor:

 
sudo nano .github/workflows/docker.yml

Paste the following contents into the file:

.github/workflows/docker.yml
name: Create Docker image
on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Login to Docker Hub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1

      - name: Build and push
        uses: docker/build-push-action@v2
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ secrets.DOCKER_HUB_USERNAME }}/chucknorris:latest

This file configures GitHub Actions to run the workflow defined in the jobs section on every push against the main branch of the repository. It runs on the latest Ubuntu instance available and the runs the following steps:

  1. Checkout the repository to so that the workflow can access it.
  2. Log in to Docker Hub using the previously configured access token and username secrets.
  3. Use the Docker Buildx Action to create a builder instance using a BuildKit container.
  4. Build the Docker image and push it to Docker Hub.

Save the file and exit your editor, then stage, commit and push your changes to GitHub.

 
git add .github/workflows/docker.yml
 
git commit -m 'Add Docker image workflow'
 
git push origin main

Afterward, return to your GitHub repository and confirm that your workflow run was successful under the Actions tab.

GitHub Actions successful run

You can also verify that the Docker Hub repository was updated by viewing the chucknorris repo you created earlier. It should show that it was updated recently.

Docker Hub repo

At this point, you've set up a GitHub Actions pipeline that builds a Docker image for you and uploads it to Docker Hub each time you push to the main branch of your GitHub repository. You can tweak the workflow file to trigger the Docker image job on pull requests, new tags or.