This tutorial explains the benefits of running Node.js applications in Docker containers and how to create a practical development workflow.
Node.js allows you to create fast and scalable web apps using JavaScript on the server as well as on the client. Your app may run perfectly on your development machine, but can you be certain it’ll run on your colleague’s devices or production servers?
Consider these scenarios:
- You may be using macOS when others use Windows and the server runs Linux.
- You have Node.js 20 installed, but others use a range of runtime versions.
- You’re using dependencies such as databases, which have differences or may not be available on other platforms.
- Are you sure your new code can’t do anything dangerous on another operating system (OS)?
Key Takeaways
- Docker’s Solution to Cross-Platform Compatibility: Docker addresses the common issue of “it works on my machine” by allowing Node.js applications to run in isolated container environments.
- Creating and Running Node.js Apps in Docker: We’ll guide you through the process of setting up a basic Node.js script and running it inside a Docker container.
- Improved Development Workflow with Docker: We detail how Docker can enhance the development workflow for Node.js applications.
Docker Delivers
Docker helps to solve those “but it works on my machine” issues listed above. Rather than installing an application locally, you run it in a lightweight isolated virtual machine-like environment known as a container.
A real virtual machine emulates PC hardware so you can install an OS. Docker emulates an OS so you can install applications. It’s typical to install one app per Linux-based container and connect them via a virtual network so they can communicate on HTTP ports.
The advantages:
- Your Docker setup can either emulate a production Linux server or you can deploy using containers.
- You can download, install, and configure dependencies in minutes.
- Your containerized app runs identically across all devices.
- It’s safer. Your app could trash a container’s OS, but it won’t affect your PC and you can restart afresh in seconds.
With Docker, there’s no need to install Node.js on your PC or use a runtime management option such as nvm.
Your First Script
Install Docker Desktop on Windows, macOS, or Linux then create a small script named version.js
with the following code:
console.log(`Node.js version: ${ process.version }`);
If you have Node.js installed locally, try running the script. You’ll see the output such as this if you had version 18 installed:
$ node version.js
Node.js version: v18.18.2
You can now run the same script inside a Docker container. The command below uses the most recent long-term support (LTS) version of Node.js. cd
into the script’s directory and run it on macOS or Linux:
$ docker run --rm --name version \
-v $PWD:/home/node/app \
-w /home/node/app \
node:lts-alpine version.js
Node.js version: v20.9.0
Windows Powershell users can use a similar command with {}
brackets around PWD
:
> docker run --rm --name version -v ${PWD}:/home/node/app -w /home/node/app node:lts-alpine version.js
Node.js version: v20.9.0
The first run may take a minute or two to execute as Docker downloads dependencies. Subsequent runs are instantaneous.
Let’s try a different version of Node — such as the latest release of version 21. On macOS or Linux:
$ docker run --rm --name version \
-v $PWD:/home/node/app \
-w /home/node/app \
node:21-alpine version.js
Node.js version: v21.1.0
On Windows Powershell:
> docker run --rm --name version -v ${PWD}:/home/node/app -w /home/node/app node:21-alpine version.js
Node.js version: v21.1.0
Remember the script is running inside a Linux container which has a specific version of Node.js installed.
Argument explanation
For the curious, the command arguments are:
docker run
starts a new container from an image — more about that below.--rm
removes the container when it terminates. It’s not necessary to retain containers unless you have good reason to restart them again.--name version
assigns a name to the container for simpler management.-v $PWD:/home/node/app
(or-v ${PWD}:/home/node/app
) bind mounts a volume. In this case, the current directly on the host PC is mounted inside the container at/home/node/app
.-w /home/node/app
sets the Node.js working directory.node:lts-alpine
is the image — in this case, the LTS version of Node.js running in Alpine Linux. The image contains the OS and files required to run an application. Think of it as a disk snapshot. You can start any number of containers from the same image: they all reference the same set of files so each container requires minimal resources.version.js
is the command to execute (from inside the working directory).
Docker images are available from Docker Hub and they’re available for applications and runtimes including Node.js. Images are often available in multiple versions identified with a tag such as :lts-alpine
, 20-bullseye-slim
, or just latest
.
Note that Alpine is a tiny Linux distribution with a base image size of around 5MB. It doesn’t contain many libraries, but it’s good enough for simple projects such as those in this tutorial.
Running Complex Applications
The version.js
script above is simple and contains no dependencies or build steps. Most Node.js applications use npm
to install and manage modules in a node_modules
directory. You can’t use the command above because:
- You can’t run
npm
on the host PC (you may not have the Node.js or the correct version installed). - Some modules require platform specific binaries. You can’t install a Windows binary on the host PC and expect it to run in a Linux container.
The solution is to create your own Docker image containing:
- an appropriate version of the Node.js runtime
- an installed version of your app with all required modules
The following demonstration builds a simple Node.js app using the Express.js framework. Create a new directory named simple
and add a package.json
file with the following content:
{
"name": "simple",
"version": "1.0.0",
"description": "simple Node.js and Docker example",
"type": "module",
"main": "index.js",
"scripts": {
"debug": "node --watch --inspect=0.0.0.0:9229 index.js",
"start": "node index.js"
},
"license": "MIT",
"dependencies": {
"express": "^4.18.2"
}
}
Add an index.js
file with JavaScript code:
// Express application
import express from 'express';
// configuration
const cfg = {
port: process.env.PORT || 3000
};
// initialize Express
const app = express();
// home page route
app.get('/:name?', (req, res) => {
res.send(`Hello ${ req.params.name || 'World' }!`);
});
// start server
app.listen(cfg.port, () => {
console.log(`server listening at http://localhost:${ cfg.port }`);
});
Don’t attempt to install dependencies or run this app on the host PC!
Create a file named Dockerfile
with the following content:
# base Node.js LTS image
FROM node:lts-alpine
# define environment variables
ENV HOME=/home/node/app
ENV NODE_ENV=production
ENV NODE_PORT=3000
# create application folder and assign rights to the node user
RUN mkdir -p $HOME && chown -R node:node $HOME
# set the working directory
WORKDIR $HOME
# set the active user
USER node
# copy package.json from the host
COPY --chown=node:node package.json $HOME/
# install application modules
RUN npm install && npm cache clean --force
# copy remaining files
COPY --chown=node:node . .
# expose port on the host
EXPOSE $NODE_PORT
# application launch command
CMD [ "node", "./index.js" ]
This defines the steps required to install and execute your app. Note that package.json
is copied to the image, then npm install
is run before copying the remaining files. This is more efficient than copying all files at once, because Docker creates an image layer at every command. If your application files (index.js
) change, Docker need only run the final three steps; it doesn’t need to npm install
again.
Optionally, you can add a .dockerignore
file. It’s similar to .gitignore
and stops unnecessary files being copied into the image by COPY . .
. For example:
Dockerfile
.git
.gitignore
.vscode
node_modules
README.md
Build a Docker image named simple
by entering the following command (note the .
period at the end — which denotes you’re using files in the current directory):
$ docker image build -t simple .
The image should build within a few seconds if the node:lts-alpine
Docker image used above hasn’t been deleted from your system.
Assuming the build is successful, start a container from your image:
$ docker run -it --rm --name simple -p 3000:3000 simple
server listening at http://localhost:3000
The -p 3000:3000
publishes or exposes a <host-port>
to a <container-port>
so port 3000 on your host PC routes to port 3000 inside the container.
Open a browser and enter the URL http://localhost:3000/
to see “Hello World!”
Try adding names to the URL — such as http://localhost:3000/Craig
— to see alternative messages.
Finally, stop your app running by clicking the stop icon in the Containers tab of Docker Desktop, or enter the following command in another terminal window:
docker container stop simple
A Better Docker Development Workflow
The process above has some frustrating flaws:
- Any change to your code (in
index.js
) requires you to stop the container, rebuild the image, restart the container, and retest. - You can’t attach a Node.js debugger such as the one available in VS Code.
Docker can improve your development workflow by retaining the existing, production-level image, but running a container with overrides in order to do the following:
- Set environment variables such as
NODE_ENV
todevelopment
. - Mount the local directory into the container.
- Start the app with
npm run debug
. This runsnode --watch --inspect=0.0.0.0:9229 index.js
, which restarts the app when files change (new in Node.js 18) and starts the debugger with requests permitted from outside the container. - Exposes app port 3000 and debugger port 9229 to the host.
You can do this with one long docker run
command, but I prefer to use Docker Compose. It’s installed with Docker Desktop and is often used to start more than one container. Create a new file named docker-compse.yml
with the following content:
version: '3'
services:
simple:
environment:
- NODE_ENV=development
build:
context: ./
dockerfile: Dockerfile
container_name: simple
volumes:
- ./:/home/node/app
ports:
- "3000:3000"
- "9229:9229"
command: /bin/sh -c 'npm install && npm run debug'
Start your app running in debug mode with:
$ docker compose up
[+] Building 0.0s
[+] Running 2/2
✔ Network simple_default Created
✔ Container simple Created
Attaching to simple
simple |
simple | up to date, audited 63 packages in 481ms
simple |
simple | > simple@1.0.0 debug
simple | > node --watch --inspect=0.0.0.0:9229 index.js
simple |
simple | Debugger listening on ws://0.0.0.0:9229/de201ceb-5d00-1234-8692-8916f5969cba
simple | For help, see: https://nodejs.org/en/docs/inspector
simple | server listening at http://localhost:3000
Note that older versions of Docker Compose are Python scripts run using docker-compose
. Newer versions have Compose functionality integrated into the main executable, so it’s run with docker compose
.
Live application restarts
Open index.js
, make a change (such as the string on line 14), and save the file to see the application automatically restart:
simple | Restarting 'index.js'
simple | Debugger listening on ws://0.0.0.0:9229/acd16665-1399-4dbc-881a-8855ddf9d34c
simple | For help, see: https://nodejs.org/en/docs/inspector
simple | server listening at http://localhost:3000
Open or refresh your browser at https://localhost:3000/
to view the update.
Debug with VS Code
Open the VS Code Run and Debug panel and click create a launch.json file.
Choose Node.js in the dropdown and a .vscode/launch.json
file is created and opened in the editor. Add the following code which attaches the debugger to the running container:
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach to Container",
"address": "localhost",
"port": 9229,
"localRoot": "${workspaceFolder}",
"remoteRoot": "/home/node/app",
"skipFiles": [
"<node_internals>/**"
]
}
]
}
Save the file then click Attach to Container at the top of the Debug pane to start debugging.
A debugging toolbar appears. Switch to index.js
and add a breakpoint to line 14 by clicking the gutter to show a red dot.
Refresh https://localhost:3000/
in your browser and VS Code will halt execution at the breakpoint and show the state of all application variables. Click an icon on the debugging toolbar to continue running, step through the code, or disconnect the debugger.
Stop the container
Stop the running container by opening another terminal. cd
to the application directory, and enter:
docker compose down
Summary
While Docker requires some initial set-up time, the long-term benefits of robust, distributable code more than outweigh the effort. Docker becomes invaluable when you add further dependencies such as databases.
This tutorial explains the basics of running Node.js apps in Docker containers. To delve further, consider these SitePoint resources:
- Book: Docker for Web Developers
- Video course: Docker for Web Developers
- Course and videos: Node.js: Novice to Ninja. Chapters 13 to 16 demonstrate how to build a scalable, real-time quiz app using Docker to run a network containing two Node.js Express servers, three Node.js Web Socket servers, a PostgreSQL database, and a load balancer.
Craig is a freelance UK web consultant who built his first page for IE2.0 in 1995. Since that time he's been advocating standards, accessibility, and best-practice HTML5 techniques. He's created enterprise specifications, websites and online applications for companies and organisations including the UK Parliament, the European Parliament, the Department of Energy & Climate Change, Microsoft, and more. He's written more than 1,000 articles for SitePoint and you can find him @craigbuckler.