In this article, we will first understand what Nginx is and where it fits into a typical web application. We will then use it as a reverse proxy for a Node.js application, keep that application running with PM2, protect the server with UFW, and finally add HTTPS using Certbot and Let's Encrypt.
By the end, the basic architecture will look like this:
Internet
|
| HTTPS
v
Nginx
|
| http://127.0.0.1:6001
v
Node.js application
|
v
PM2
Instead of exposing the Node.js application directly to the internet, Nginx becomes the public entry point and forwards requests to the application internally.
What Is Nginx?
Imagine that you have built a Node.js application and it is listening on port 6001. During development, you might access it directly using an address such as:
http://your-server-ip:6001
Technically, that works. In production, however, we usually do not want visitors to know or care which internal port our application uses. We want them to visit a normal address such as:
https://example.com
This is one of the most common reasons to place Nginx in front of an application.
Nginx, pronounced Engine-X, is a high-performance web server that can also work as a reverse proxy, load balancer, and HTTP cache. In a typical Node.js deployment, Nginx receives requests from the internet and forwards them to the Node.js application running behind it.
Conceptually, the request path is simple:
Browser → Nginx → Node.js
The Node.js application processes the request and sends its response back through Nginx to the browser. From the visitor's point of view, none of this internal routing is visible. They simply visit your domain and receive a response.
What Else Can Nginx Do?
Although we are mainly interested in reverse proxying, Nginx can perform several useful jobs.
As a web server, it can serve static files such as HTML, CSS, JavaScript, images, fonts, and downloads directly. A completely static website may not need Node.js at all.
As a reverse proxy, Nginx accepts a public request and forwards it to another service:
https://example.com
|
v
Nginx
|
v
http://127.0.0.1:6001
The internal port remains an implementation detail. Visitors never need to know that the application is actually running on port 6001.
Nginx can also act as a load balancer. If an application grows large enough to run several instances, Nginx can distribute requests between them:
┌─ Application 1
Browser → Nginx ────┼─ Application 2
└─ Application 3
It can also cache suitable responses, reducing the amount of work that repeatedly reaches the backend.
For this article, however, we will focus on its most useful role in a straightforward Node.js deployment: Nginx as a reverse proxy.
The Deployment We Are Building
We will assume that we have an Ubuntu-based server, SSH access, Node.js and npm installed, a working Node.js application listening on port 6001, and a domain whose DNS already points to the server.
Our final setup will look like this:
Public Internet
|
HTTPS :443
|
v
+-----------+
| Nginx |
+-----------+
|
127.0.0.1:6001
|
v
+-----------+
| Node.js |
+-----------+
|
managed by PM2
One detail is particularly important. If Nginx and Node.js are running on the same machine, the Node.js port normally does not need to be publicly accessible. Nginx is the public-facing service, while Node.js can remain reachable only from the local machine.
Installing Nginx
Update the available package information:
sudo apt update
Then install Nginx:
sudo apt install nginx
Check that the service is running:
sudo systemctl status nginx
At this point, opening the server's IP address in a browser should display the default Nginx page, provided the server's firewall allows incoming HTTP traffic.
Configuring the Firewall
Ubuntu commonly uses UFW, the Uncomplicated Firewall, as a simple interface for managing firewall rules.
If UFW is not already installed:
sudo apt install ufw
Before enabling the firewall, make sure SSH is allowed:
sudo ufw allow OpenSSH
This order matters. If you are connected to a remote server through SSH and enable a restrictive firewall without allowing SSH first, you could lock yourself out of the machine.
Now allow HTTP and HTTPS traffic handled by Nginx:
sudo ufw allow "Nginx Full"
Enable the firewall:
sudo ufw enable
Then inspect the active rules:
sudo ufw status
You should see rules allowing SSH along with Nginx traffic. If your VPS provider also has its own cloud firewall or security group, ports 80 and 443 must be allowed there as well.
Notice that we are not opening port 6001. If Nginx and Node.js are on the same server, there is normally no reason to expose the Node.js port directly to the internet.
Running the Node.js Application Locally
For this example, imagine a very small Express application:
const express = require("express");
const app = express();
const PORT = 6001;
app.get("/", (req, res) => {
res.send("Hello from Node.js");
});
app.listen(PORT, "127.0.0.1", () => {
console.log(`Server running on http://127.0.0.1:${PORT}`);
});
The important part is:
app.listen(PORT, "127.0.0.1", ...)
The application listens only on the local interface. Other machines cannot connect directly to port 6001, but Nginx, which runs on the same server, can.
Start the application normally:
npm start
Then test it from the server:
curl http://127.0.0.1:6001
If everything is working, you should receive the application's response.
This simple test is extremely useful when troubleshooting. If the curl command fails, the problem is with Node.js or the application itself. If it works locally but the public domain fails, the problem is more likely to involve Nginx, DNS, or the firewall.
Creating the Nginx Configuration
On Ubuntu, Nginx commonly stores site configurations in:
/etc/nginx/sites-available/
Enabled sites are represented by symbolic links inside:
/etc/nginx/sites-enabled/
Create a new configuration for your domain:
sudo nano /etc/nginx/sites-available/example.com
Replace example.com with your actual domain, then add:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:6001;
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;
}
}
The configuration is small, but it is enough to turn Nginx into a reverse proxy for our application.
The listen directives tell Nginx to accept ordinary HTTP traffic on port 80. The server_name directive tells it which hostname belongs to this configuration.
The location / block applies to requests under the root of the website, including paths such as /, /about, /login, /api/users, and /articles/123.
The central instruction is:
proxy_pass http://127.0.0.1:6001;
It tells Nginx to forward the request to our Node.js application.
For example, a public request to:
https://example.com/articles
can internally become:
http://127.0.0.1:6001/articles
The visitor never sees the internal address.
Forwarding Request Information
Because Nginx is now sitting between the browser and Node.js, we also preserve useful information about the original request:
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;
Host preserves the requested hostname. X-Real-IP provides the original client address. X-Forwarded-For keeps information about the proxy chain, while X-Forwarded-Proto tells the backend whether the original request arrived over HTTP or HTTPS.
If your application uses WebSockets, additional headers may be required:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
Do not add those directives simply because they appear in another tutorial. If your application does not use WebSockets, they are unnecessary.
That is a useful general rule for infrastructure configuration: understand why a directive exists before copying it.
Enabling and Testing the Site
Creating a file inside sites-available does not enable it automatically. Create a symbolic link inside sites-enabled:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com
If the default Ubuntu Nginx site is still enabled and you no longer need it, remove its symbolic link:
sudo unlink /etc/nginx/sites-enabled/default
The original file inside sites-available can remain in place.
Before applying any Nginx configuration change, test it:
sudo nginx -t
If the syntax is valid, reload Nginx:
sudo systemctl reload nginx
You can now test the domain:
curl http://example.com
At this stage, the request should follow this path:
Browser → Nginx → Node.js :6001
If you receive your Node.js response through the domain, the reverse proxy is working.
Keeping Node.js Running With PM2
Our deployment still has a weakness. If the application was started manually with:
npm start
we do not yet have proper process management. We want the application to survive crashes, be easy to restart, provide accessible logs, and return automatically after a server reboot.
PM2 solves that problem.
Install it globally:
npm install -g pm2
If your application's entry point is server.js, start it with:
pm2 start server.js --name example-app
Check the process:
pm2 status
View its logs:
pm2 logs example-app
For a larger or longer-lived project, an ecosystem file makes the configuration easier to maintain. Create:
ecosystem.config.cjs
with content such as:
module.exports = {
apps: [
{
name: "example-app",
script: "./server.js",
env: {
NODE_ENV: "production",
PORT: 6001
}
}
]
};
Start it with:
pm2 start ecosystem.config.cjs
When you deploy updated code, restart the application explicitly:
pm2 restart example-app
or, where appropriate:
pm2 reload example-app
I would generally avoid enabling PM2's file-watching feature in production. Automatically restarting whenever a source file changes can be useful during development, but production deployments should usually be deliberate and reproducible.
Starting PM2 After Reboots
We also want the process manager itself to return automatically after the server restarts.
Run:
pm2 startup
PM2 will display another command that must normally be run with elevated privileges. Copy and execute the command it gives you.
Then save the current PM2 process list:
pm2 save
You can verify everything with:
pm2 status
Now Node.js is no longer tied to an SSH terminal and should be restored after a server reboot.
Adding HTTPS With Certbot
At this point the application is reachable through Nginx, but it is still using plain HTTP.
For a public website, HTTPS should be considered part of the normal deployment rather than an optional feature. It encrypts traffic between the browser and your server and allows the browser to verify the server's identity.
Let's Encrypt provides free TLS certificates, while Certbot automates the process of requesting, installing, and renewing them.
Before continuing, make sure your domain already points to the server and that Nginx is publicly reachable on port 80.
If an older Certbot package installed through apt is present, remove it first:
sudo apt remove certbot
Install Certbot through Snap:
sudo snap install --classic certbot
If necessary, make the command available in the usual location:
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
Now request a certificate and let Certbot configure Nginx:
sudo certbot --nginx -d example.com -d www.example.com
If you do not use www, use:
sudo certbot --nginx -d example.com
Certbot verifies that you control the domain, obtains a certificate, and updates the Nginx configuration for HTTPS.
When the process completes successfully, open:
https://example.com
Your browser should now connect securely.
Test Certificate Renewal
Let's Encrypt certificates are intentionally short-lived, so renewal must work automatically.
Test the renewal process with:
sudo certbot renew --dry-run
A successful dry run confirms that Certbot should be able to renew the certificate when necessary.
What Actually Happens When Someone Visits the Site?
Now that the entire deployment is configured, consider what happens when a visitor opens:
https://example.com/profile
The browser connects to the server using HTTPS. Nginx accepts that connection and handles TLS. It examines the hostname, selects the correct server configuration, and forwards the request internally to:
http://127.0.0.1:6001/profile
The Node.js application processes the request and returns a response. Nginx then sends that response back to the visitor over the encrypted HTTPS connection.
The complete flow is:
Browser
|
| HTTPS
v
Nginx
|
| HTTP on localhost
v
Node.js
|
v
Application logic
PM2 is not part of the request path itself. Its job is to keep the Node.js process running and manageable.
This separation of responsibilities is one of the main strengths of the setup. Nginx handles public traffic and TLS. Node.js handles application logic. PM2 manages the application process. Certbot manages TLS certificates. UFW controls which network services are publicly reachable.
Once those responsibilities are clear, the deployment becomes much easier to reason about.
Useful Commands
These are the commands worth remembering after deployment:
# Test Nginx configuration
sudo nginx -t
# Reload Nginx
sudo systemctl reload nginx
# Check Nginx
sudo systemctl status nginx
# Check PM2 processes
pm2 status
# View application logs
pm2 logs example-app
# Restart the Node.js application
pm2 restart example-app
# Save the PM2 process list
pm2 save
# Check firewall rules
sudo ufw status
# Test certificate renewal
sudo certbot renew --dry-run
These commands are enough to diagnose a large percentage of the problems that occur in a simple Node.js deployment.
Troubleshooting Common Problems
The Domain Does Not Open
First confirm that the domain's DNS points to the correct server. Then make sure Nginx is running:
sudo systemctl status nginx
Check the firewall:
sudo ufw status
If your VPS provider has its own firewall or security group, verify that ports 80 and 443 are allowed there too.
Nginx Returns 502 Bad Gateway
A 502 Bad Gateway usually means that Nginx is running but cannot successfully communicate with the backend application.
Check PM2:
pm2 status
Inspect your application logs:
pm2 logs
Then test Node.js directly from the server:
curl http://127.0.0.1:6001
If this request fails, fix the Node.js application before changing the Nginx configuration.
Nginx Configuration Changes Do Not Appear
Make sure the site is enabled:
ls -l /etc/nginx/sites-enabled/
Then test and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
Certbot Cannot Create the Certificate
Make sure the domain points to the correct public IP, port 80 is reachable, Nginx is running, and the hostname appears correctly in server_name.
Certbot must be able to prove that the server controls the requested domain. If DNS or public connectivity is incorrect, validation will fail.
A Few Production Habits Worth Keeping
A deployment can work perfectly and still be poorly configured. A few simple habits make production systems considerably easier to maintain.
Do not expose application ports unless there is a real reason to do so. If Node.js and Nginx run on the same machine, keeping Node.js bound to 127.0.0.1 reduces unnecessary public exposure.
Always run:
sudo nginx -t
before reloading Nginx. It takes only a moment and catches configuration mistakes before they affect a running site.
When something goes wrong, check the logs instead of guessing. PM2 provides application logs with:
pm2 logs
Nginx logs are commonly available at:
/var/log/nginx/access.log
/var/log/nginx/error.log
Finally, keep production changes deliberate. Development conveniences such as automatically restarting whenever files change can be useful locally, but production deployments should normally involve a controlled update, restart or reload, and verification.
Understanding the Architecture Is More Important Than Memorizing Commands
The hardest part of learning Nginx is usually not the syntax. It is understanding where Nginx fits into the system.
For the deployment we built, the architecture is:
Internet
|
v
+-----------+
| Nginx |
+-----------+
|
v
+-----------+
| Node.js |
+-----------+
|
v
Application
Nginx sits at the edge of the application. It accepts public web traffic, handles web-server concerns such as HTTPS, and forwards application requests to Node.js.
PM2 keeps the Node.js process alive and manageable. Certbot provides and renews the TLS certificate. UFW limits which services can be reached from outside the server.
Once those roles are understood, the configuration stops looking like a random collection of Linux commands. It becomes a small system in which every component has a clear job.
That is the useful way to learn infrastructure: not by memorizing commands, but by understanding what problem each component is solving.