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 withnpm 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.