...

How to Deploy a Node.js Application on a VPS with PM2, Nginx, and SSL

Martin Klein

Reading time 1 minute

In this guide, we will deploy a small Node.js application on a VPS and prepare it for production: install Node.js LTS, create a dedicated system user, and configure environment variables, PM2, Nginx, and HTTPS.

In the process, we will:

  • Install Node.js LTS and npm;
  • Create a dedicated user for the application;
  • Prepare a test Node.js application and .env;
  • Add a health endpoint to check the service status;
  • Run the application with PM2;
  • Configure automatic startup after a VPS reboot;
  • Set up Nginx as a reverse proxy;
  • Point a domain to the server and issue an SSL certificate;
  • Check the application and Nginx logs;
  • Test /health;
  • Update the application with pm2 reload to reduce downtime during restarts.

As a result, the Node.js application will run as a separate system service behind Nginx, start automatically after a server reboot, and be available over HTTPS. In this how-to, we will intentionally avoid containerization: Docker would make everything simpler, but this approach lets us walk through the steps and understand what happens under the hood of a Node.js application.

Installing Node.js LTS

Start by preparing a clean VPS and installing the current LTS release line of Node.js. LTS releases are designed for long-term support and are usually preferable to the Current release line for production servers.

Preparing the VPS

Connect to the server via SSH and update the package index: sudo apt update

Install the available updates: sudo apt upgrade -y

Also install the basic utilities needed later: sudo apt install -y curl ca-certificates

After updating, you can check the Ubuntu version: lsb_release -a

This guide uses Ubuntu 24.04.

Installing Node.js

To install Node.js, add the NodeSource repository for the LTS branch. For example, for Node.js 22: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash –

Note that we do not recommend automatically running any scripts from the internet unless you have verified that they are safe and come from trusted sources. In this example, this is an accepted assumption for the test environment.

After adding the repository, install Node.js: sudo apt install -y nodejs

The package includes the Node.js runtime itself and npm.

Check the versions:

node –version

npm –version

The output should show the installed versions of Node.js and npm.

The server is now ready to run Node.js applications. However, you should not run a production process as root, so the next step is to create a dedicated system user for the application.

Create a dedicated system user

For the application, it is best to use a dedicated Linux user account with only the minimum required permissions. This separates the application process from the server’s administrative account and limits the impact of a potential error or application compromise.

Why Run the Application as a Non-Root User

A process running as root has virtually unrestricted access to the system. If the application or one of its npm dependencies has a vulnerability, this level of privilege significantly increases the potential damage.

A Node.js application typically does not need administrative privileges. It should have access only to its own files, its working directory, and the required network resources.

Therefore, we will create a dedicated nodeapp user, under which we will later run the application with PM2.

Creating a User and Working Directory

Create a system user with a home directory: sudo adduser –disabled-password –gecos "" nodeapp

Prepare the application directory: sudo mkdir -p /var/www/nodeapp

Set the new user as the owner: sudo chown -R nodeapp:nodeapp /var/www/nodeapp

Verify the created user account: id nodeapp

Then check the permissions on the working directory: ls -ld /var/www/nodeapp

The directory owner and group should be nodeapp.

If needed, you can switch to the new user’s shell with the following command: sudo -iu nodeapp

The application’s working directory remains: /var/www/nodeapp

Next, in this directory, we will create a small functional Node.js application, move its settings into .env, and add a separate endpoint for checking the service status.

Deploying a test Node.js application

Now let’s create a small working Express application. It will serve the main page, read configuration from .env, and provide a separate /health endpoint for checking the service status.

Creating the application

Switch to the nodeapp user: sudo -iu nodeapp

Change to the working directory: cd /var/www/nodeapp

Initialize a new Node.js project: npm init -y

Install Express and the package for working with .env files: npm install express dotenv

Create the main application file: nano server.js

Add the following code:

require(‘dotenv’).config();

const express = require(‘express’);

const app = express();

const PORT = process.env.PORT || 3000;

const APP_NAME = process.env.APP_NAME || ‘Node.js VPS Demo’;

const APP_VERSION = process.env.APP_VERSION || ‘1.0.0’;

app.get(‘/’, (req, res) => {

res.json({

app: APP_NAME,

version: APP_VERSION,

message: ‘Application is running’

});

});

app.get(‘/health’, (req, res) => {

res.status(200).json({

status: ‘ok’,

app: APP_NAME,

version: APP_VERSION

});

});

app.listen(PORT, ‘127.0.0.1’, () => {

console.log(`${APP_NAME} is listening on 127.0.0.1:${PORT}`);

});

The application will listen only on 127.0.0.1:3000. This port does not need to be opened directly to the internet: Nginx will later handle external HTTP and HTTPS requests.

Add a start command to package.json:

"scripts": {

"start": "node server.js"

}

After that, you can start the application with the standard command: npm start

Configuring the health endpoint

The /health endpoint is used to quickly verify that the application process is running and can handle HTTP requests.

In our application, the request GET /health

returns:

{

"status": "ok",

"app": "Node.js VPS Demo",

"version": "1.0.0"

}

This endpoint can be used in monitoring, health checks for a load balancer or reverse proxy, or an external availability monitoring system.

After starting the application, you can run a local check with the command: curl http://127.0.0.1:3000/health

When the server is operating normally, it should return JSON with the status ok.

Creating the .env file

Let’s move variables that may differ between environments out of the source code and into .env.

Create the file: nano .env

Add:

PORT=3000

APP_NAME=Node.js VPS Demo

APP_VERSION=1.0.0

NODE_ENV=production

Restrict access permissions: chmod 600 .env

Application settings can now be changed without editing server.js.

Check the project structure: ls -la

The directory should contain roughly the following files:

.env

node_modules/

package.json

package-lock.json

server.js

After preparing the application, run it with PM2 so the process automatically restarts after errors and after the VPS is restored.

Running the Application with PM2

PM2 is a process manager for Node.js applications. It lets you run an application in the background, monitor the process status, keep logs, and automatically restart processes after a server reboot.

Installing PM2

While still logged in as the nodeapp user, install PM2 globally: npm install -g pm2

Check the installed version: pm2 –version

Starting the application

Change to the project directory: cd /var/www/nodeapp

Start server.js using PM2: pm2 start server.js –name nodeapp

Check the process list: pm2 status

The nodeapp application should have the following status: online

Additionally, check the endpoint: curl http://127.0.0.1:3000/health

If it returns JSON with the status ok, the application is running successfully under PM2.

Configuring automatic startup after a reboot

First, save the current process list: pm2 save

Then generate the command to integrate PM2 with systemd: pm2 startup systemd

PM2 will output a ready-to-use sudo command that must be run by a user with administrative privileges.

It will look something like this: sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u nodeapp –hp /home/nodeapp

After running it, save the process list again: pm2 save

You can check the created systemd service with the following command: systemctl status pm2-nodeapp –no-pager

If necessary, reboot the server: sudo reboot

After reconnecting, check the status: sudo -iu nodeapp pm2 status

If nodeapp is back in online status, automatic startup has been configured correctly.

Configuring Nginx

The Node.js application is already running locally on 127.0.0.1:3000 and is managed by PM2. Now we will put Nginx in front of it to accept external HTTP and HTTPS requests and forward them to the application as a reverse proxy.

This setup avoids exposing port 3000 directly to the internet and lets you manage the domain, TLS certificate, and HTTP headers centrally.

Installing Nginx

Install Nginx:

sudo apt update

sudo apt install -y nginx

Check the service status: sudo systemctl status nginx –no-pager

You can also quickly validate the configuration: sudo nginx -t

If Nginx is installed correctly, it will report:

syntax is ok

test is successful

If UFW is used, allow incoming HTTP and HTTPS connections: sudo ufw allow ‘Nginx Full’

Check the firewall rules: sudo ufw status

Port 3000 does not need to be opened separately, because the Node.js application accepts connections only over localhost.

Creating a reverse proxy

Create a separate site configuration file: sudo nano /etc/nginx/sites-available/nodeapp

Add the following:

server {

listen 80;

listen [::]:80;

server_name app.example.com;

location / {

proxy_pass http://127.0.0.1:3000;

proxy_http_version 1.1;

proxy_set_header Host $host;

proxy_set_header X-Real-IP $remote_addr;

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_set_header X-Forwarded-Proto $scheme;

proxy_set_header Upgrade $http_upgrade;

proxy_set_header Connection "upgrade";

}

}

Here, app.example.com should be replaced with the domain or subdomain that will be used for the application.

The main directive: proxy_pass http://127.0.0.1:3000;

routes requests from Nginx to the local Node.js process.

The X-Real-IP, X-Forwarded-For, and X-Forwarded-Proto headers allow the application to receive information about the original client and the request protocol.

Enable the configuration:

sudo ln -s /etc/nginx/sites-available/nodeapp \

/etc/nginx/sites-enabled/nodeapp

You can disable the default Nginx site: sudo rm -f /etc/nginx/sites-enabled/default

Checking the Configuration

Before reloading Nginx, be sure to check the syntax: sudo nginx -t

If there are no errors, apply the configuration: sudo systemctl reload nginx

Before configuring DNS, you can test the reverse proxy locally by passing the appropriate Host header: curl -H "Host: app.example.com" http://127.0.0.1/

Nginx should forward the request to the Node.js application and return its response:

{

"app": "Node.js VPS Demo",

"version": "1.0.0",

"message": "Application is running"

}

Similarly, check the health endpoint through Nginx: curl -H "Host: app.example.com" http://127.0.0.1/health

External requests can now be routed through Nginx, while the application itself remains bound to the local interface.

Connect a domain

For HTTPS to work, the application needs a domain name that resolves to the VPS’s public IP address.

DNS Record

Create an A record with your DNS provider.

For example:

TypeNameValue
Aapp203.0.113.10

As a result, app.example.com will point to the VPS.

203.0.113.10 is used here as an example address for documentation purposes. In a real DNS record, specify the server’s actual public IPv4 address.

If Cloudflare is used and the certificate is issued directly via Certbot on the VPS, during initial setup you can temporarily use the DNS onlymode. After obtaining the certificate, proxying can be re-enabled if needed.

DNS changes may not take effect immediately; the timing depends on the TTL and the DNS provider.

Checking domain resolution

After creating the record, check which address DNS returns: dig +short A app.example.com

If dig is not installed: sudo apt install -y dnsutils

You can also use: nslookup app.example.com

The returned address must match the public IP address of the VPS.

Additionally, check HTTP using the domain name: curl http://app.example.com/

And the health endpoint: curl http://app.example.com/health

If DNS is configured correctly, the request will first reach Nginx and then be forwarded to the application at 127.0.0.1:3000.

The domain is then ready for a TLS certificate to be issued. In the next step, we will install Certbot and switch the application to HTTPS.

Configuring HTTPS

After configuring DNS and verifying the HTTP connection, we will switch the application to HTTPS. To do this, we will install Certbot and obtain a free Let’s Encrypt TLS certificate for the application domain.

Installing Certbot

Install Certbot and the Nginx integration module:

sudo apt update

sudo apt install -y certbot python3-certbot-nginx

Check the installed version: certbot –version

Before issuing the certificate, verify that the Nginx configuration is valid: sudo nginx -t

The domain must also already point to the VPS’s public IP address, and ports 80 and 443 must be accessible externally.

Issuing a Let’s Encrypt Certificate

Run Certbot for the application domain: sudo certbot –nginx -d app.example.com

On the first run, Certbot will prompt you for an email address for notifications, ask you to agree to the Let’s Encrypt terms, and offer to configure an HTTP-to-HTTPS redirect.

After the domain is successfully validated, Certbot will obtain the certificate and automatically update the Nginx configuration.

You can check existing certificates with the following command: sudo certbot certificates

The output will show the domain, expiration date, and paths to the certificate files.

Let’s Encrypt issues certificates with a limited validity period, so Certbot also configures automatic renewal.

You can test the renewal mechanism without actually issuing a new certificate as follows: sudo certbot renew –dry-run

HTTP → HTTPS Redirect

After configuring Certbot, requests to http://app.example.com should be automatically redirected to https://app.example.com.

Check the HTTP headers: curl -I http://app.example.com

The response should include a redirect status code, for example:

HTTP/1.1 301 Moved Permanently

Location: https://app.example.com/

Then check HTTPS: curl -I https://app.example.com

TLS terminates at Nginx, so the internal connection between Nginx and Node.js can remain over local HTTP.

Testing the Application

After configuring the reverse proxy and HTTPS, let’s verify the application’s main routes through the public domain.

Checking the main page

Open: https://app.example.com

Or run: curl https://app.example.com/

The Node.js application should return:

{

"app": "Node.js VPS Demo",

"version": "1.0.0",

"message": "Application is running"

}

This confirms that the request passes through the entire chain:

HTTPS

Nginx

127.0.0.1:3000

Node.js

You can also check the HTTP status code without outputting the response body: curl -o /dev/null -s -w "%{http_code}\n" https://app.example.com/

Expected result: 200

Checking the health endpoint

Now let’s check the dedicated health endpoint: curl https://app.example.com/health

Expected response:

{

"status": "ok",

"app": "Node.js VPS Demo",

"version": "1.0.0"

}

Also check the HTTP status: curl -o /dev/null -s -w "%{http_code}\n" https://app.example.com/health

Result: 200

A health endpoint is useful because it lets you check application availability separately from the main page. It can be used in monitoring systems and automated service health checks.

At this stage, the application is fully accessible through Nginx and HTTPS. Next, we will check the PM2 and Nginx logs, and then update the application with minimal downtime.

Checking Logs

After configuring PM2 and Nginx, it is useful to verify that the application is running without errors and handling requests correctly. To do this, check the logs for the Node.js process itself and the reverse proxy logs.

PM2 logs

PM2 saves the application’s standard output and process errors.

You can view the latest entries with this command: pm2 logs nodeapp –lines 50

In our case, the log should contain the startup message: Node.js VPS Demo is listening on 127.0.0.1:3000

If the application outputs additional messages using console.log() or console.error(), they will also appear here.

To view only errors, use: pm2 logs nodeapp –err –lines 50

You can view the paths to the log files with this command: pm2 show nodeapp

PM2 is especially useful for diagnosing situations where a process terminates unexpectedly, cannot read environment variables, or encounters an unhandled exception.

Nginx logs

Nginx keeps separate access and error logs.

You can view the most recent HTTP requests as follows: sudo tail -n 50 /var/log/nginx/access.log

After accessing the main page and /health, entries similar to the following will appear in the log:

"GET / HTTP/1.1" 200

"GET /health HTTP/1.1" 200

Reverse proxy errors are logged here: sudo tail -n 50 /var/log/nginx/error.log

If the Node.js application is stopped or Nginx cannot connect to 127.0.0.1:3000, this is usually where you can find upstream connection error messages.

To monitor the log in real time, use: sudo tail -f /var/log/nginx/access.log

Checking PM2 and Nginx together helps you quickly determine where the problem occurred: within the Node.js application itself or between the client, the reverse proxy, and the local process.

Updating the Application Without Extended Downtime

In production, an application needs to be updated regularly. With a standard stop and restart of the process, there may be a brief period of unavailability between the two operations.

PM2 supports the reload command, which lets you gracefully replace a running process with a new version.

Changing the Application Version

For demonstration purposes, change the version number in the .env file.

Open the file: nano /var/www/nodeapp/.env

Replace APP_VERSION=1.0.0 with APP_VERSION=1.1.0

Save the file.

You can also change the response text on the main page or add new functionality to server.js. For a simple test, changing the version is sufficient.

Restart via PM2 reload

Since the environment variables have changed, run reload with the environment updated: pm2 reload nodeapp –update-env

Then check the status: pm2 status

The application should remain in the following state: online

For a more predictable zero-downtime reload in production, PM2 is typically used in cluster mode with multiple application instances. In a simple single-process scenario, reload reduces downtime but does not guarantee that it will be eliminated entirely.

For example, in cluster mode, the application can be started using an ecosystem file with multiple instances:

module.exports = {

apps: [{

name: ‘nodeapp’,

script: ‘server.js’,

instances: 2,

exec_mode: ‘cluster’

}]

};

PM2 can then restart instances one at a time, keeping at least one active process running during the update.

Checking the new version

After the reload, check the main page: curl https://app.example.com/

The application should now return:

{

"app": "Node.js VPS Demo",

"version": "1.1.0",

"message": "Application is running"

}

The health endpoint should also continue to respond: curl https://app.example.com/health

Expected result:

{

"status": "ok",

"app": "Node.js VPS Demo",

"version": "1.1.0"

}

Also check PM2: pm2 status

This approach lets you update a Node.js application in a controlled way: PM2 manages the process lifecycle, while Nginx continues to accept external HTTPS requests and route them to the local backend.

Conclusion

A Node.js application can be deployed on a VPS without a complex management platform by dividing the infrastructure into a few clear components: the application itself, PM2 for process management, and Nginx as the external reverse proxy.

In this guide, we installed Node.js LTS, created a dedicated system user, prepared the application and the .env file, configured a health endpoint, started the service with PM2, and enabled automatic startup after a VPS reboot. We then configured Nginx, the domain, and HTTPS with Let’s Encrypt.

Finally, we checked the PM2 and Nginx logs, verified that /health was working correctly, and updated the application using pm2 reload. For production workloads that require minimal downtime, use cluster mode with multiple process instances so that PM2 can replace them one at a time.

FAQ

Why use PM2 if you can run a Node.js application with node server.js?

The node server.js command runs the application only in the current terminal. If the SSH session is closed or the VPS is rebooted, the process will stop.

PM2 runs the application in the background, monitors its status, stores logs, and can automatically restart processes after a server reboot.

Do I need to expose port 3000 to the internet?

No. In our setup, Node.js listens only on 127.0.0.1:3000, while Nginx accepts external requests on ports 80 and 443.

This way, the backend is not exposed directly to the internet.

Why create a separate nodeapp user?

Running the application as root gives the process unnecessary privileges. A separate system user limits the application’s access to the rest of the system and reduces the impact of a potential vulnerability.

Where should the .env file be stored?

The .env file can be stored in the application’s working directory, but access to it should be restricted using file system permissions.

It should not be added to a public Git repository. Typically, .env is added to .gitignore.

What is a health endpoint used for?

An endpoint such as /health lets you automatically verify that the application is running and responding to HTTP requests.

It can be used by monitoring systems, reverse proxies, load balancers, and external availability checks.

How do PM2 logs differ from Nginx logs?

PM2 shows the output and errors from the Node.js application itself.

Nginx stores information about incoming HTTP requests and reverse proxy errors. Therefore, when troubleshooting, it is useful to check both layers.

What should I do if Nginx returns a 502 Bad Gateway error?

First, run pm2 status and curl http://127.0.0.1:3000/health.

If the application does not respond locally, the problem is with Node.js or PM2.

If the local request works, check the Nginx configuration and error log: sudo tail -n 50 /var/log/nginx/error.log

Is HTTPS required?

For a public production application, HTTPS is virtually mandatory. It protects data in transit and is required by many browser APIs and external services.

Let’s Encrypt lets you obtain a free TLS certificate and renew it automatically with Certbot.

Does PM2 reload guarantee an update with absolutely zero downtime?

Not always.

For a single process, reload can significantly reduce downtime, but a true rolling update works better in cluster mode with multiple application instances.

For example, with two instances, PM2 can restart one process while the other continues serving requests.

Sources

  1. Node.js — Download / Releases
  2. PM2 Documentation — Quick Start and Startup Script
  3. Nginx Documentation — Proxying HTTP Traffic to a Group of Servers
  4. Certbot Documentation — Nginx

Subscribe to our newsletter and receive articles and news

    Check out our other materials