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:
| Type | Name | Value |
| A | app | 203.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.
