How to Use PM2 Clusters with NodeJS for Zero Downtime Deployment

Updated

7 min read

How to Use PM2 Clusters with NodeJS for Zero Downtime Deployment

I frequently use ExpressJS for building APIs for my web services.

In the past, I would deploy my server using PM2 a popular process manager, such as pm2 start index.js.

While this has mostly been okay, I've been building Featurable, which needs a stable, low downtime service so my users and customers don't experience outages on their websites.

As a result, I've migrated my PM2 setup to use clusters for zero downtime deployment, and I'm going to walk you through how I achieved it.

What is PM2 cluster mode?

Simply put, PM2 cluster mode lets Node.js apps be distributed across multiple (or all) CPUs using Node cluster modules.

Practically speaking, this means that we can do graceful, zero-downtime deployments of our Node apps.

When we push changes, using PM2 cluster mode, we can continue serving requests as PM2 reloads clusters while ensuring at least 1 cluster is always available.

Prerequisites:

I'm assuming you have a NodeJS app (Typescript or Javascript) and PM2 installed. Your app must be stateless (e.g. RESTful) in order to utilize clusters properly.

Setting up ecosystem.config.js

Our first major improvement will be using an ecosystem.config.js file, rather than running PM2 commands to start and reload our processes.

An ecosystem.config.js file basically replaces the need to run pm2 start index.js, and is especially helpful for managing multiple related processes.

For example, I often serve a NextJS or SvelteKit frontend, alongside an ExpressJS backend. Setting up our ecosystem file helps keep both these processes organized and in one place, and will allow for cluster mode more easily.

ecosystem.config.js
javascriptmodule.exports = {
    apps: [
        {
            name: "nextjs_client",
            script: "npm",
            args: "run start",
            cwd: "./client",
            instances: 1,
            autorestart: true,
            watch: false
        },
        {
            name: "expressjs_backend",
            script: "dist/bundle.js", // must point to entry point *file*
            cwd: "./backend",
            instances: 2,
            exec_mode: "cluster",
            autorestart: true,
            watch: false,
            wait_ready: true,
            // load environment variables if needed
            // env: {
            //      NODE_ENV: "development"
            // }
            // env_production: {
            //      NODE_ENV: "production"
            // }
        },
    ],
};

We define two apps for our frontend and backend. For our frontend, we can simply use an npm run start command as normal.

For our backend, we point to dist/bundle.js, the entry point of our ExpressJS app. Your entry point might look like src/index.js for example.

Note that you can specify as many cluster instances as needed, up to the number of CPU cores.

Important

If you're using Typescript, PM2 cluster mode will not work correctly with an "npm start" command. You need to transpile your code to Javascript and let PM2 manage Node itself, in order for PM2 to properly utilize the Node cluster module.

Remember to load environment variables as needed in your ecosystem.config.js. In this example, the ExpressJS server is assumed to load environment variables itself, using the dotenv package for example.

Transpiling our Typescript app (optional)

Normally, I would simply start my Typescript app using npx ts-node src/index.ts. But as stated above, we need to transpile our code for PM2 clusters to work properly.

For many cases, you can simply use npx tsc --build to transpile your Typescript project into Javascript.

But it's a very common practice to use path aliases in the tsconfig.json, such as

tsconfig.json
json"paths": {
    "@/*": ["./src/*"],
    "common/*": ["../common/src/*"],
},

If we just use tsc, these path aliases won't be resolved and will cause errors when attempting to run the Javascript.

So we'll use Webpack to bundle and transpile our code into a single bundle.js entry point.

Bundling and transpiling with Webpack (optional)

First, let's install some dependencies we'll need for webpack.

bashnpm i -D webpack webpack-cli webpack-node-externals ts-loader @types/node

Then create a webpack.config.js in the root of your Node app:

webpack.config.js
javascriptconst path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'production',
  target: 'node',  // Important in Node.js apps to enable built-in Node.js modules
  externals: [nodeExternals()], // Exclude node_modules from the output bundle
  entry: './src/index.ts',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js', // Output filename
    clean: true,
  },
  resolve: {
    extensions: ['.ts', '.js'],
    alias: {
        // Add path aliases here to match your `tsconfig.json`
      '@': path.resolve(__dirname, 'src'),
      'common': path.resolve(__dirname, '../common/src'),
    },
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader', // Use ts-loader to handle TypeScript files
        exclude: /node_modules/,
      },
    ],
  },
  // Optional: Source map configuration for better debugging support
  devtool: 'inline-source-map',
};

Now we can modify our package.json to add a build command:

package.json
json"scripts": {
    // ... rest of scripts
    "build": "webpack --config webpack.config.js"
}

If we run npm run build, we should now get a dist/bundle.js file.

Since we've successfully transpiled our Typescript Node app into Javascript, when using PM2 cluster mode, PM2 will now handle Node clusters properly.

Note

In my testing, PM2's cluster mode won't work with Typescript even if you specify the interpreter as "ts-node" for example. I've only gotten PM2 clusters to work properly with transpiled Javascript.

Emit a Ready Signal

You may have noticed in our ecosystem.config.js that we set wait_ready: true.

This is important because of how ExpressJS apps work:

  1. You run the app (e.g. node index.js)
  2. ExpressJS boots up
  3. A few seconds later, the server starts listening

If we let PM2 run our clusters without wait_ready, there may be downtime as PM2 thinks the ExpressJS app is ready, but the server isn't actually listening yet.

To mitigate this is pretty simple, just modify your server listener like so:

src/index.ts
typescript//  start listening
server = app.listen(port, () => {
    logger.info(`Listening to port ${port}`);

    // Emit ready event for PM2
    if (process.send) process.send("ready");
});

While we're at it, we can also gracefully handle terminations:

src/index.ts
typescriptprocess.on("SIGTERM", () => {
    logger.info("SIGTERM received");
    server.close(() => {
        logger.info("Server closed")
    });
});

Now PM2 will know when our ExpressJS app is actually ready.

Start PM2 processes

Now we can startup our PM2 processes by running:

pm2 start ecosystem.config.js

You should see something like, which shows each cluster instance as we expect.

pm2 status
bash┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐
│ id │ name               │ mode     │ ↺    │ status    │ cpu      │ memory   │
├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤
│ 2  │ expressjs_backend  │ cluster  │ 3    │ online    │ 0%       │ 12.6mb   │
│ 1  │ expressjs_backend  │ cluster  │ 3    │ online    │ 0%       │ 12.5mb   │
│ 0  │ nextjs_client      │ cluster  │ 29   │ online    │ 0%       │ 10.0mb   │
└────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘

Now when we send a request to the Express.js server, it will be distributed to a worker process using a round-robin approach.

Deploy changes with zero downtime

Now when we need to deploy changes to our app, we can simply run:

npm run build
pm2 reload ecosystem.config.js

You can test this for yourself by reloading the processes whilst sending several requests and notice how even when deploying changes, the requests are being handled correctly.

Lastly, if you update your ecosystem.config.js, you can reload it via:

pm2 reload ecosystem.config.js --update-env

Wrapping Up

Hopefully this short tutorial has helped you setup zero downtime deployment using PM2 cluster mode.

It's straightforward to setup, but one of the biggest caveats is that you must let PM2 manage Node and run transpiled Javascript only.

In my experience, PM2 cluster mode didn't work properly when specifying npm run start in my ecosystem or when attempting to run Typescript directly using a ts-node interpreter.

In summary:

  1. Transpile your Typescript (if needed)
  2. Use an ecosystem.config.js for easier PM2 management
  3. Always define the script as the entry point file, not an npm start command when using PM2 cluster mode
  4. Emit a ready signal for PM2

With PM2 cluster mode, you can now deploy any stateless Node.js app with zero downtime and experience huge benefits for your web services.

Thanks for reading!

Ryan Chiang

Meet the Author

Ryan Chiang

Hello, I'm Ryan. I build things and write about them. This is my blog of my learnings, tutorials, and whatever else I feel like writing about.
See what I'm building →.

Thanks for reading! If you want a heads up when I write a new blog post, you can subscribe below:

2024

2023

© 2023 Ryan Chiang|ryanschiang.com