Get Rewarded! We will reward you with up to €50 credit on your account for every tutorial that you write and we publish!

How to deploy a Node.js application with Docker

profile picture
Author
Matteo Contrini
Published
2024-09-05
Time to read
12 minutes reading time

Introduction

Node.js is a JavaScript runtime that in the past years has become popular for building server-side apps.

This tutorial shows how to deploy a Node.js application to a cloud server through Docker, Docker Hub and Docker Compose.

Prerequisites

  • This tutorial assumes that you have Docker installed on your local system. If you don't have it, you can find the instructions for installing it in the official documentation.

  • You should also have a cloud server with a Linux distribution, preferably Ubuntu 24.04. If you are using another distribution, you might have to look for specific instructions when it's time to install Docker on your server.

  • Some steps also require that you have Docker Hub (free) account to upload the Docker image for the application.

  • If you don't have any previous Docker experience, that's fine, this tutorial is pretty basic and explains the main concepts around what we're doing.

  • You need a Node.js application that you can deploy.

    Click here to view an example
    └── <project_name>
        ├── package.json
        ├── package-lock.json
        └── src
            └── index.js
    • package.json

      {
          "version": "1.1.0",
          "name": "myproject",
          "description": "Example package.json file.",
          "main": "src/index.js",
          "scripts": {
             "build": "echo \"Building the application\""
          }
      }
    • src/index.js

      const http = require('http');
      
      const server = http.createServer((req, res) => {
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/plain');
        res.end('Hello World!\n');
      });
      
      server.listen(8080, () => {
        console.log('Server running at http://localhost:80/');
      });

About Docker

In case you're just getting started with Docker, here are some terms that are worth reviewing, to make sure that we're on the same track.

  • Images: in Docker, images are "snapshots" or templates of a file system, and contain everything that is needed to launch an application.
  • Containers: these are the actual running instances of the application. They are created by taking a template (an image) and turning it into something that can be started and has a state.
  • Layers are the elements that compose a Docker image. Each layer is built on top of another one, allowing to provide a feature called layer caching. This means that you don't need to re-build or re-download all the layers of an image when only one of them changes.
  • Registries are the place where you upload (push) images to make them available to the world, or to those that have the credentials to access it. In this tutorial, we're going to use Docker Hub, but there are also alternatives provided by GCP, AWS, Azure, GitHub, and others.

Step 1 - Create a Dockerfile

Create a file called Dockerfile with the following content in the root of your Node.js project directory:

FROM node:20.17

ENV NODE_ENV=production

WORKDIR /app

COPY package*.json .

RUN npm ci

COPY . .

EXPOSE 8080

CMD [ "node", "src/index.js" ]

The Dockerfile is the place where you put the instructions that allow Docker to build an image. Every instruction represents the creation of a layer, which is a modification of the image file system that is being created.

In this case, we're composing our image by starting from a template, sometimes called the base image, that in this case is node:20.17. This is an official image provided by the Docker company, and you can find more about it here.

The next step sets the NODE_ENV environment variable to production. The main effect here is to avoid installing development packages when running the npm installation below, but it can often lead to better optimizations in modules you might be relying on.

With the WORKDIR command we move the current working directory to /app, which therefore becomes the directory where the following instructions will be executed.

The line COPY package*.json . copies the files package.json and package-lock.json into the /app directory of the Docker image file system. Note that the dot at the end is required to indicate the current directory.

We now use the RUN instruction to install production dependencies, by using the npm ci command (ci stands for clean install and is designed to be used in automated enviroments).

One thing that should be noted at this point is that until now we copied in the build only the package*.json files, instead of the whole project directory. This allows to leverage Docker layers caching, so that if the dependent packages are unchanged, the layers can be reused without rebuilding them.

The following line (COPY . .) copies the remaining files in the image.

Optionally, we can specify that we want to expose a specific network port of the container, so that a web application can be accessed through it. Note that the EXPOSE instruction doesn't actually expose the port: as the documentation says, «it functions as a type of documentation between the person who builds the image and the person who runs the container, about which ports are intended to be published».

Finally, the last instruction determines the command that should be used by Docker to run the application when the container starts. In this case, we're assuming that the entrypoint of the application is the index.js file.

Usually, it's a good idea to also create a file called .dockerignore, together with the Dockerfile. This makes sure that, when you run COPY . ., useless files from your computer are not copied inside the image:

.git
Dockerfile
node_modules

In this case, we're not interested in having development versions of directories like .git or node_modules available inside the template we're building.

Step 2 - Build the image

Now that we have a Dockerfile, we can tell Docker to use it to build an image.

The basic command to do that looks like the following one, and must be executed in the root directory of the project:

docker build -t myproject .

If you get the error "process '/bin/sh -c npm ci' did not complete successfully", replace npm ci in the Dockerfile with npm install and try again.

The -t option specifies the name of the image, in this case myproject. The . at the end of the line is required to tell Docker to look for a Dockerfile in the current directory.

NOTE: the first time that you run the build, it will take a while because Docker has to download all the layers of the base image (Node.js 20.17 in this case).

Since we're going to upload this image to the Docker Hub online registry (to make it accessible from our server), we need to name the image by using a specific convention.

The command above would therefore look like this:

docker build -t username/myproject:latest .

Where username is your Docker Hub username, and latest is the tag of the image. An image can have multiple tags, so you sometimes see a workflow similar to this:

docker build -t myproject .
docker tag myproject username/myproject:latest
docker tag myproject username/myproject:20240905

These commands build an image and then tag it with the tags latest and 20240904 (the date this tutorial was last updated).

Docker Hub doesn't remove old images by default, so this allows to have an history of all the images that you pushed to the registry. The image with tag latest will always be the one that was most recently built, while the older ones will be tagged with a date.

Step 3 - Push the image

Now that we have the image, we need to push it to the registry. First of all, run the following command to make sure that your Docker instance is authenticated with Docker Hub:

docker login

Then run docker push to upload the image, together with all the tags.

docker push username/myproject

If your application is small, this command should be fast to complete, because it needs to upload only the layers corresponding to your Node.js application and its JavaScript dependencies.

When you have a new version of the image, you should run the push command again to make sure it's uploaded on Docker Hub.

Step 4 - Install Docker on Ubuntu 24.04

We can now move to the server to install Docker and Docker Compose. As mentioned in the prerequisites, the assumption here is that you have an Ubuntu 24.04 server that is already up and running.

First of all, installing Docker requires some system dependencies, which can be installed with the following commands:

sudo apt-get update
sudo apt-get install ca-certificates curl

Now add the official Docker GPG key and configure a custom apt repository:

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Finally, update the apt index again and install Docker Community Edition:

sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

The above command also installs Docker Compose, a tool that greatly simplifies the management of containers and their lifecycle.

One last useful step consists of adding the current Ubuntu user to the docker group, so that we can run Docker commands directly from it.

This can be easily done with the following command:

sudo gpasswd -a myuser docker

Verify that everything went fine by executing the following commands:

docker --version
docker ps
docker compose version

If you're not seeing any errors or warnings, you're good to go.

Step 5 - Run the container with Docker Compose

Create a file called docker-compose.yml with the following content on your server:

services:
  myproject:
    container_name: 'myproject'
    image: 'username/myproject'
    restart: unless-stopped

This is a very basic Docker Compose file that configures a single container called myproject, based on the username/myproject image from Docker Hub. If you don't specify a tag, it will default to latest, but you can also set a specific one, if you want:

services:
  myproject:
    container_name: 'myproject'
    image: 'username/myproject:20240904'
    restart: unless-stopped

Finally, the restart property indicates that the container should be automatically restarted when it crashes, unless it is manually stopped.

If you now run this up Compose command, the Docker image will be pulled from the registry and your application will hopefully run:

docker compose -f docker-compose.yml up

This command creates a container and executes it. The output of the container is captured by Docker and presented to you in the console. Press CTRL + C (or CMD + C) and wait some seconds for the container to stop.

If everything went fine, you're now ready to launch the container as a daemon, so that it will keep running in the background, until stopped. This can be achieved by adding the -d option to the command:

docker compose -f docker-compose.yml up -d

Boom, node! (oops, I meant done)

Make sure you take a quick look at the Compose file reference documentation, where you can find useful features like mapping network ports between the server and the container. Here's a quick example that maps the external port 80 to the internal port 8080:

services:
  myproject:
    container_name: 'myproject'
    image: 'username/myproject'
    restart: unless-stopped
    ports:
      - '80:8080'

Step 6 - Deploy a new version

Let's say that you need to publish a change to your application. Unless you enabled automated builds, you need to repeat steps 2 and 3, so that a new image will appear on Docker Hub.

Then, on your server, you must manually pull the new image, like this:

docker compose -f docker-compose.yml pull

And restart the container with the new image:

docker compose -f docker-compose.yml up -d --force-recreate

Conclusion

Great, you did it! This was a basic introduction to deploying a Node.js application to Ubuntu 24.04 by using Docker, Docker Hub and Docker Compose.

We've seen how to write a simple Dockerfile, how to build the image, push it, and deploy it on a server.

There's a lot more about Docker that is not covered by this tutorial, so make sure that you take a look at the Docker and Docker Compose documentation to learn more about the concepts and features.

License: MIT
Want to contribute?

Get Rewarded: Get up to €50 in credit! Be a part of the community and contribute. Do it for the money. Do it for the bragging rights. And do it to teach others!

Report Issue
Try Hetzner Cloud

Get 20€ free credit!

Valid until: 31 December 2024 Valid for: 3 months and only for new customers
Get started
Want to contribute?

Get Rewarded: Get up to €50 credit on your account for every tutorial you write and we publish!

Find out more