How to use pm2 to serve a Node.js app

By

Learn how to use pm2 to run a Node.js app on a Linux VPS, manage it with pm2 list and pm2 restart, and redeploy it on every GitHub push with GitHub Actions.

~~~

pm2 is a super useful process management tool for Linux.

I’ve used it for several projects of mine, and here I want to tell you how to use it too!

If you prefer to run your Node app with systemd instead of pm2, I built a free systemd service generator that writes the unit file and the install commands for you.

In particular I’m going to use it to run a Node.js app on a DigitalOcean VPS, and I’ll set it up so that whenever we push an update to the app’s GitHub repository, the server pulls the new code and pm2 restarts the app.

Sounds cool? Let’s go!

First, sign up for DigitalOcean with my referral link and you get $200 in credit for 60 days. This is an affiliate link: if you sign up through it and then spend $25, I get $25 in DigitalOcean credit. Then follow my tutorial to create a VPS on DigitalOcean.

Important: use the NodeJS image on DigitalOcean, which is already set up with pm2 and node, and has a nodejs user in addition to root.

Once you’re up and running we can start.

Make sure you ssh as the nodejs user. When you’re logged in as root, you can just run su nodejs to use that user.

The sample Node.js app you have running in the VPS is in the folder /var/www/html/, and it’s composed by the hello.js file.

The deployment/running of the application is already managed through the pm2 program, a daemon process manager.

You can use pm2 list to see all the applications running now:

Terminal showing pm2 list command output with one process named hello running in fork mode with online status

If you do any change to the application now, the changes won’t be applied until you restart the application running:

pm2 restart hello

Terminal output showing pm2 restart hello command execution and updated process list with restart count of 1

You can stop the application running

pm2 stop hello

and this will give you an error in the browser if you try to reload the page:

Browser showing 502 Bad Gateway error from nginx when the pm2 process is stopped

You can run:

pm2 start hello

to bring the app up again.

The nice thing about pm2 is that it will take care of running the app(s) again when the system is restarted. When you start or stop apps, run pm2 save so pm2 remembers the current list for the next reboot.

pm2 is also the right place to set NODE_ENV=production for the app, either by prefixing the start command or with an env_production block in an ecosystem file. Without it Express runs in development mode on the server. I explain why that matters in Node, the difference between development and production.

Now that you’ve seen how the built-in hello world works, let’s deploy another application.

Let’s stop the current sample app:

pm2 stop hello

and create a test folder in /var/www/html:

mkdir test

go into that

cd test

and run

npm init -y

then install Express:

npm install express

Now run nano app.js and add this code:

const express = require("express")
const app = express()

app.get("/", (req, res) => res.send("Hello World!"))
app.listen(3000, () => console.log("Server ready"))

Run

pm2 start app.js --name app

and you should see the app running!

Browser displaying Hello World message after starting the Express app with pm2

We used port 3000, which is what the hello app used, so we did not have to configure anything else.

Otherwise we’d need to modify the /etc/nginx/sites-available/default and add a new URL to map to our app.

Now that we deployed a sample app, let’s see how to deploy an app from GitHub.

First thing, let’s stop the app we just built in the previous lesson:

pm2 stop app

and let’s make a sample GitHub app. It’s not going to be complex, it’s very similar to the app we built in the previous lesson, but it will say Hello World, deployed from GitHub! instead of just Hello World!

On your local create a folder, and inside it run

npm init -y

then install Express:

npm install express

Now run nano app.js and add this code:

const express = require("express")
const app = express()

app.get("/", (req, res) => res.send("Hello World, deployed from GitHub!"))
app.listen(3000, () => console.log("Server ready"))

Now it’s time to push the app to GitHub. I use GitHub Desktop, the official GitHub app, so I drag and drop the folder into the app, and it lets me create a new Git repo, and a GitHub repository.

GitHub Desktop showing Publish Repository dialog with testdo repository name and Keep this code private option checked

I initialized the repo with the standard Node.js .gitignore file, which ignored the node_modules folder. We’ll see how to initialize the npm modules during deployment.

Now we have to create a SSH key on the DigitalOcean machine, and add it to GitHub. Otherwise we will not have access to the private repository we just created.

Double-check the email address you used to connect to GitHub, because it’s important. You can find it in the GitHub settings, in the Email panel. Then run:

ssh-keygen -t rsa -b 4096 -C "[email protected]"

Now open the “SSH and GPG keys” panel on GitHub:

GitHub SSH and GPG keys settings page showing empty SSH keys section with New SSH key button

and click “New SSH key”. Enter “DigitalOcean” as the title, so you know what this key is used for.

Now type

less /home/nodejs/.ssh/id_rsa.pub

on your DigitalOcean droplet to get the public key.

Copy that to your settings, and save. You should see it in the SSH keys list.

Now we should be able to clone the private repository on the server.

Go in the /var/www/html folder, and run

git clone [email protected]:<yourname>/<reponame>.git

In my case it was:

git clone [email protected]:flaviocopes/testdo.git

and the app was successfully downloaded:

Terminal showing successful git clone of testdo repository from GitHub with download progress

Now type cd testdo and run npm install.

The app is now ready to run, so type

pm2 stop test

to stop the previous app, and

pm2 start app.js --name testdo

to start the new one:

Browser showing Hello World deployed from GitHub message after starting the GitHub-cloned app with pm2

Now let’s make the server redeploy the app every time we push to GitHub.

We’ll use GitHub Actions for this. On every push, GitHub starts a small job that connects to our server over SSH, pulls the new code, installs the dependencies and restarts the app with pm2.

First we need a key that GitHub can use to log in to the server. On the droplet, as the nodejs user, run:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_key -N ""
cat ~/.ssh/deploy_key.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

This creates a new key pair with no passphrase, and allows the public key to log in as nodejs.

Now print the private key:

cat ~/.ssh/deploy_key

Open the repository settings on GitHub, go to Secrets and variables > Actions and add two repository secrets:

Once it’s saved in GitHub, delete the private key from the server with rm ~/.ssh/deploy_key. The public key stays in authorized_keys.

Back on your computer, create the file .github/workflows/deploy.yml in the app folder:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy over SSH
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          SERVER: ${{ secrets.DEPLOY_HOST }}
        run: |
          mkdir -p ~/.ssh
          echo "$SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new nodejs@"$SERVER" \
            'cd /var/www/html/testdo && git pull && npm ci --omit=dev && pm2 restart testdo'

If your default branch is not main, change it in the branches line.

The last command is the whole deploy. It goes into the app folder, pulls the new code, and runs npm ci --omit=dev, which installs exactly the versions in package-lock.json and skips the dev dependencies. Then pm2 restart testdo restarts the app.

The git pull runs on the server with the GitHub key we created earlier, so that key must have no passphrase, otherwise the pull stops and waits for one.

Commit the workflow file and push it. You’ll see the job run in the Actions tab of the repository.

Now if you try doing a change to your code and push it, it should automatically be deployed!

Browser displaying updated Hello World UPDATED and deployed from GitHub message showing automatic deployment works

An earlier version of this post used the pm2-githook module, which listened for GitHub webhooks on its own port. It hasn’t been updated since 2023, so I don’t recommend it anymore. GitHub Actions does the same job without opening an extra port on the server.

Tagged: CLI · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cli: