Skip to article
← Back to the notebook

From the notebook

Understanding Nginx: From Reverse Proxy Basics to a Secure Node.js Deployment

Nginx is often introduced simply as a web server, but in modern web applications it commonly plays a broader role. It can sit in front of an application, receive incoming web traffic, handle HTTPS, serve static files, and forward requests to backend services.

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.

Keep reading

More from the notebook.

All articles
01

The Math You Actually Need for AI

The article introduces the core mathematics needed to begin machine learning without overwhelming the reader with advanced theory. It uses a simple student-score prediction example to explain mean, standard deviation, probability, linear relationships, functions, prediction error, vectors, and matrices. The central idea is that AI math becomes much easier when each concept is tied to a practical purpose. The article shows how data is summarized, how relationships are identified, how models turn inputs into outputs, and how prediction errors can be measured using Mean Absolute Error. It also reinforces that libraries such as NumPy handle the calculations, while the reader’s job is to understand what those calculations mean. The article ends by preparing the reader for the next step: training a real machine-learning model.

Read article
02

Numbers, Arrays, and NumPy: How AI Represents Data

This article explains how machine-learning systems represent real-world information as numerical data. It introduces NumPy arrays and shows how they differ from regular Python lists, then builds the foundation for understanding scalars, vectors, matrices, dimensions, and shapes. Through practical examples, it shows how students, images, text, and other real-world data can be converted into numerical structures that AI models can process efficiently.

Read article
03

Working With Data Using Pandas

Once you move from basic Python into data science, machine learning, or AI, one of the first things you need to become comfortable with is working with tables of data. Most AI projects do not begin with a clever model or a complicated algorithm.

Read article