How to Use Node.js with Docker

Craig Buckler
Share

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)?
Table of Contents

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.

Docker

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 to development.

  • Mount the local directory into the container.

  • Start the app with npm run debug. This runs node --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.

VS Code Run and Debug pane

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.

VS Code Run and Debug

A debugging toolbar appears. Switch to index.js and add a breakpoint to line 14 by clicking the gutter to show a red dot.

set breakpoint in VS Code

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: