In this guide, we will configure Nginx on Ubuntu as a reverse proxy between a domain and an application running on a local port, such as 127.0.0.1:3000.
First, we will install Nginx, create a separate site configuration, and configure proxy_pass to forward requests from the domain to the application. We will then pass the required HTTP headers, add WebSocket support using the Upgrade and Connection headers, and enable HTTPS with Certbot.
We will also validate the configuration using nginx -t, configure an automatic redirect from HTTP to HTTPS, and verify that the certificates can be renewed automatically.
Finally, we will examine common causes of 502 Bad Gateway errors: an application that is not running, an incorrect port or address in proxy_pass, configuration errors, and issues that can be identified in the Nginx and application logs.
What We Will Configure
Welcome, dear reader!
Today, we will look at a fairly common server administration task. Suppose an application—for example, one built with Node.js, Python, or Go—is already running on a VPS. It is up, responding to requests, and listening on a local port, such as 127.0.0.1:3000.
In other words, everything is already working on the server itself.
However, users should not need to know the IP address of our VPS or the application’s internal port, let alone enter something like http://203.0.113.10:3000 in their browser.
It is much more familiar and convenient to open a standard URL: https://example.com
We need a way to connect these two endpoints: accept a request sent to the domain and forward it to the application running inside the VPS.
Nginx will handle this task.
The resulting setup will look like this:

Nginx will accept external requests and then forward them to the application’s local port. This operating mode is called a reverse proxy.
However, forwarding alone is not enough.
In this article, we will:
- Install Nginx on Ubuntu;
- Forward requests from the domain to the application’s local port;
- Configure proxy_pass;
- Forward the required HTTP headers;
- Add WebSocket support;
- Enable HTTPS with Certbot;
- Validate the configuration;
- Explore what causes a 502 Bad Gateway error and how to troubleshoot it.
We now understand the overall setup. But this raises a reasonable question: if the application can already accept HTTP requests without Nginx, why add another layer at all?
Why an Application Needs Nginx
At first glance, it may seem that a simpler setup would be sufficient.
If the application runs on port 3000, you could expose that port and access the service directly: http://203.0.113.10:3000
Technically, this setup is possible.
However, it quickly becomes impractical for a public-facing application.
First, it is easier for the user to work with a standard URL https://example.com than with: http://example.com:3000
In this setup, port 3000 remains an internal implementation detail and can be changed without affecting users.
Second, Nginx provides a convenient entry point for HTTPS.
The user establishes a secure connection directly with Nginx:

Nginx handles the TLS certificate, accepts the HTTPS request, and then forwards it to the application within the VPS.
This means that each application does not have to manage certificates and external HTTPS on its own.
Third, Nginx helps separate the public-facing part of the server from its internal services.
The application itself can continue listening only on 127.0.0.1:3000, while the standard ports, 80 and 443, will be externally accessible.
As a result, all external traffic is managed through a single, well-defined entry point—Nginx—while internal services remain behind it.
Nginx also makes it easy to:
- Proxy multiple applications on a single IP address;
- Forward the required HTTP headers;
- Configure access more flexibly;
- Work with WebSocket connections;
- Redirect HTTP to HTTPS;
- Maintain access and error logs;
- Change the application’s internal port without changing its public address.
In other words, the responsibilities can be divided quite simply:
Application → performs its core functions
Nginx → accepts and routes external traffic
We now have a complete picture of what we are going to build.
Preparing Ubuntu and the Application
Before configuring Nginx, it is important to verify two things: the application itself is actually running on a local port, and the domain is already pointing to our VPS.
It may sound obvious, but this is where people often save a couple of minutes, only to spend half an hour later troubleshooting Nginx.
I know what I’m talking about because I ran into a similar issue myself. The application seemed to be running, but at the same time, it wasn’t. Nginx was configured and the domain was connected, yet requests were not getting through. In the end, the problem was not with the reverse proxy—the service itself was listening on the wrong port.
So we’ll verify the foundation first and only then add Nginx on top of it.
Prerequisites
To continue with the configuration, you will need:
- A VPS running Ubuntu 24.04 or 26.04 LTS;
- A user with sudo privileges;
- An installed and running application;
- The local port on which the application listens for requests;
- A domain or subdomain;
- A domain A record pointing to the VPS’s public IP address.
For this article, we will assume that the application is running at: 127.0.0.1:3000
The domain used in the examples will be: example.com
These are placeholder values. In a real project, the port could be 8000, 5000, 8080, or any other port.
The key is to know exactly where the application accepts requests.
Before installing Nginx, it is advisable to check the Ubuntu version: cat /etc/os-release
Also make sure that the server is accessible and the system is operating normally.
If the application is run using systemd, PM2, Docker, or another process manager, it is also advisable to verify that its process is actually active.
Verify that the application responds on the local port
Let’s start with the simplest: curl http://127.0.0.1:3000
If the application is running, the response should contain data from the service itself.
This could be HTML:
<h1>Hello from app</h1>
JSON:
{“status”:”ok”}
Or any other expected response.
If curl returns something like Connection refused, there is no point proceeding to Nginx yet.
First, check the following:
- Whether the application is running;
- Whether the correct port is specified;
- Whether the process exited with an error;
- Which address the service is actually listening on.
You can view open TCP ports using the following command: sudo ss -lntp
For example, we are looking for a line similar to this: LISTEN 0 511 127.0.0.1:3000 0.0.0.0:*
This indicates that the process is listening on 127.0.0.1:3000.
If the application is running this way, great.
For a reverse proxy, this is actually preferable to listening on 0.0.0.0:3000.
This is because binding to 127.0.0.1 makes the service accessible only from within the VPS itself. External users cannot access it directly, and all public traffic will pass through Nginx.
The first link in the chain is now ready.
Now let’s check the second one: the domain.
Checking the Domain and DNS
For users to access https://example.com, the domain must point to the public IP address of our VPS.
Typically, an A record is created in DNS for this purpose: example.com → 203.0.113.10
If a subdomain is used: app.example.com → 203.0.113.10
For example, you can check which address the domain currently resolves to as follows: getent hosts example.com
Or: dig +short example.com
If dig is not available, there is no need to install it solely for this check—getent is usually sufficient.
The output should show the public IP address of our VPS.
In other words, you should see something like this:

If the domain points to a different IP address, you must correct the DNS record first.
Also keep in mind that DNS changes may not take effect immediately. Depending on the TTL and provider, the old value may remain cached for some time.
This is especially important before configuring HTTPS: we will use free certificates from Let’s Encrypt, and Certbot must be able to verify that the specified domain actually points to the server running Nginx.
At this point, two conditions must be met:

Now both ends of the chain are ready.
All that remains is to put Nginx—the “reception desk” we mentioned at the beginning—between the domain and the application.
Installing Nginx
Installing via APT
On Ubuntu, Nginx can be installed from the standard system repositories using APT, so there is no need to add any third-party repositories.
First, update the package index: sudo apt update
Then install Nginx: sudo apt install -y nginx
APT will download the package and the required dependencies, then install the web server on the system.
After installation, Ubuntu typically starts the Nginx service automatically via systemd.
The overall process is straightforward:

We have not created a reverse proxy configuration yet. At this stage, Nginx is simply installed and running with its default configuration.
Now let’s verify that the service has started successfully.
Checking the Service Status
Let’s check the status of Nginx: sudo systemctl status nginx --no-pager
We are interested in the following line: Active: active (running)
This means that the Nginx process is running and systemd considers the service operational.
For a quick check, use: systemctl is-active nginx
Expected response: active
It is also useful to check whether Nginx is enabled to start automatically: systemctl is-enabled nginx
Under normal circumstances, we will get: enabled
In other words, Nginx will start automatically after the VPS reboots.
If the service is not running for some reason, first try: sudo systemctl start nginx
And if it has entered the failed state, you can check the cause using: sudo journalctl -u nginx --no-pager -n 50
But if the status shows active (running), you can proceed.
However, a running process alone does not confirm that Nginx is actually responding to HTTP requests. Therefore, we will perform one more simple check.
Checking the Default Page

First, let’s access Nginx directly from the VPS: curl http://127.0.0.1
If the installation was successful, the response will contain the HTML for the default Nginx page.
You can also verify it in a browser by opening http://203.0.113.10 or by using our domain: http://example.com
If the DNS records have propagated and port 80 is accessible externally, the default Nginx page should appear.
This confirms several things:
- The package is installed;
- The service is running;
- Nginx is listening on HTTP port 80;
- Requests are successfully reaching the server.
At this stage, Nginx is already accepting external requests, but it is still serving its own default page.
The next step is to change this behavior: instead of serving the default content, Nginx should forward requests to our application at 127.0.0.1:3000.
To do this, we will configure a reverse proxy.
Configuring Nginx as a Reverse Proxy
Creating the Site Configuration
On Ubuntu, site configurations are typically stored in the directory: /etc/nginx/sites-available/
Active configurations are enabled via: /etc/nginx/sites-enabled/
You can think of this as two lists:
- sites-available → all prepared configurations
- sites-enabled → configurations that Nginx actually uses
Let’s create a separate file for our domain:
sudo nano /etc/nginx/sites-available/example.com Now let’s add a basic configuration:
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
That’s not much—just a few lines—but it is already enough to understand the basic logic:
- listen 80 tells Nginx to accept regular HTTP requests on port 80.
- server_name specifies the domain this block applies to.
- Inside location /, we define how to handle all requests sent to the site.
This brings us to the key directive in this article: proxy_pass.
Configuring proxy_pass
The line proxy_pass http://127.0.0.1:3000 tells Nginx: “Forward all requests that reach this location to the application on local port 3000.”
For example, a user opens: http://example.com/profile
Nginx receives this request and forwards it to the application roughly as follows:

In other words, the external and internal addresses are different, but the user does not notice.
This is also convenient because the application can later be moved to a different local port.
For example, the original address was: 127.0.0.1:3000
The new address is: 127.0.0.1:5000
You can change not only the port but also the address—for example, if you need to move the application or part of it to another VPS.
Users do not need to change the website address. We simply update one line in the Nginx configuration.
This can be compared to forwarding an internal telephone extension: the company’s external number remains the same, while an employee can be moved to a different office or line.
However, proxy_pass alone is usually not enough for everything to work properly.
The application often needs to know which domain the user requested, which IP address the request came from, and whether HTTPS was used.
This information is passed through HTTP headers.
Forwarding the required headers
Let’s add several standard directives to the location block:
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
Now let’s look at what each one is used for:
| Header | Information passed to the application |
| Host | The domain the user used to access the server |
| X-Real-IP | The client’s actual IP address |
| X-Forwarded-For | The chain of IP addresses through which the request passed |
| X-Forwarded-Proto | The protocol used for the original request: HTTP or HTTPS |
For example, a user opens https://example.com and connects from the IP address: 198.51.100.25
The application behind Nginx will receive information similar to the following:
Host: example.com
X-Real-IP: 198.51.100.25
X-Forwarded-Proto: https
Without these headers, the application sees only the connection from Nginx itself and may lose some information about the original client.
This is especially important for:
- Logging;
- Determining the user’s actual IP address;
- Generating correct URLs;
- Redirects;
- Applications that need to determine whether a request was made over HTTP or HTTPS.
Our configuration file now looks like this:
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
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 ready, but as far as Nginx is concerned, it currently exists only on disk. We still need to activate it.
Enabling the configuration

To do this, create a symbolic link from sites-available to sites-enabled:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/ The idea is simple.
The file /etc/nginx/sites-available/example.com contains the configuration itself.
The symlink in /etc/nginx/sites-enabled/ tells Nginx to use this configuration.
On Ubuntu, the default configuration is also typically enabled after installation: /etc/nginx/sites-enabled/default
If we no longer need it, we can disable it: sudo rm /etc/nginx/sites-enabled/default
There is no need to delete the file itself from sites-available—we are removing only the active symlink.
The structure now looks like this:

The reverse proxy is now configured, but we will not apply the changes without validating them first.
A missing semicolon or an extra bracket in the configuration can prevent Nginx from reloading the file. Therefore, we will first validate the syntax, then reload the service and send the first request through the domain.
Verify the Configuration and Make the First Request via the Domain
Check the syntax with nginx -t
Run: sudo nginx -t
If everything is correct, you will see something like this:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful This means that Nginx was able to read the main configuration file and the included files without syntax errors.
If there is an error, the command will usually identify the file and line immediately.
For example:
nginx: [emerg] unexpected "}" in /etc/nginx/sites-enabled/example.com:12 Then open the specified file:
sudo nano /etc/nginx/sites-available/example.com Fix the issue and run the command again: sudo nginx -t
Until we get: test is successful
Done. The syntax has been checked. You can now apply the configuration.
Reload Nginx
To make Nginx reload the configuration, use: sudo systemctl reload nginx
Please note: this is specifically reload, not restart.
When you run reload, Nginx rereads its configuration without fully stopping the service.
In simplified terms:
restart
→ stop Nginx
→ start it again
reload
→ keep Nginx running
→ reread the configuration
This is sufficient for routine changes to a server or location block.
After reloading, you can check the status again: systemctl is-active nginx
Expected: active
Nginx is now using our configuration:

The only remaining step is to verify that an actual request passes through the entire chain—the final check, so to speak.
Testing the Site via the Domain
First, you can send a request directly from the VPS: curl http://example.com
If everything is configured correctly, the response should contain our application’s content rather than the default Nginx HTML.
Suppose the local check previously run with curl http://127.0.0.1:3000 returned: Hello from app
The same response should now be returned when accessing the application through the domain:
curl http://example.com
Hello from app
This is an important point.
Before the configuration, the request path looked like this:

Now it looks like this:

The application itself has not changed. We have simply placed a new public entry point in front of it.
After that, open the following URL in your browser: http://example.com
If the application is displayed correctly, the reverse proxy is working.
If the default Nginx page appears instead of the application, the wrong server block is most likely being used, or the default configuration is still enabled.
Put simply, Nginx selects a configuration based on the domain to which the request was sent. For example.com, it should find our block with server_name example.com. If the default configuration is selected instead, Nginx displays its welcome page—it simply does not know that the request should be forwarded to our application.
It is like arriving at an office building and giving the company name at reception. If the receptionist cannot find the appropriate listing, they will not direct you to the correct office, leaving you in the main lobby. In this analogy, the default Nginx page is the “main lobby.”
In this case, it is useful to check ls -l /etc/nginx/sites-enabled/ and make sure that the following is actually enabled there: /etc/nginx/sites-enabled/example.com
If Nginx returns a 502 Bad Gateway error, however, the situation is different: the reverse proxy is accepting the request but cannot communicate properly with the application.
We will return to troubleshooting 502 errors separately.
The key point at this stage is that a regular HTTP request now passes through Nginx to the local application.
The next step is more complex. If the application uses WebSocket, proxy_pass alone is not enough—you must also forward the parameters that allow the HTTP connection to switch to WebSocket mode.
Adding WebSocket Support
Why a Standard proxy_pass Configuration Is Not Enough
Suppose the application supports WebSocket at /ws.
The user connects to: wss://example.com/ws
The request first reaches Nginx, which must then forward it to: 127.0.0.1:3000
The issue is that a WebSocket connection starts as a regular HTTP request, but then the client asks the server to switch the connection protocol.
The request includes a special header: Upgrade: websocket
In effect, the client is saying: “We started communicating over HTTP, but now let’s keep the connection open and continue over WebSocket.”
Nginx must forward this request to the application correctly.
Otherwise, you may encounter a puzzling situation:
- The website works
- The API works
- HTTPS works
- WebSocket does not
This is why WebSocket issues can sometimes be confusing: the reverse proxy may seem to be fully configured because regular web pages load without any problems.
However, WebSocket requires additional configuration parameters.
Forwarding the Upgrade and Connection Headers
Let’s return to our location / block.
Previously, it looked something like this:
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
Now let’s add WebSocket support:
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”;
}
Three important lines have been added:
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection “upgrade”;
Let’s examine them:
| Parameter | Purpose |
| proxy_http_version 1.1 | Uses HTTP/1.1 between Nginx and the application |
| Upgrade $http_upgrade | Forwards the client’s protocol upgrade request to the application |
| Connection "upgrade" | Indicates that the connection should switch to the new mode |
You can think of this as changing trains.
A regular HTTP request reaches the Nginx station, and then the client says, “I’m taking a different line from here.”
Upgrade indicates, which protocol to switch to, while Connection: upgrade confirms that the switch is indeed required.
If the application uses a separate WebSocket path, such as /ws,
you can configure a separate location block:
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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;
}
This allows regular HTTP requests and WebSocket connections to be logically separated.
After changing the configuration, follow the same familiar procedure: sudo nginx -t
If everything is successful: sudo systemctl reload nginx
We’ve done this before, so there is nothing new here: first validate the configuration, then apply it.
All that remains is to verify that WebSocket connections are actually passing through Nginx.
Testing the WebSocket Connection
One important point to understand is that standard curl is not always suitable for comprehensive WebSocket testing.
It works well for HTTP, but WebSocket connections are better tested with a client designed specifically for this type of connection.
If the application already has an interface that uses WebSocket functionality, such as a chat or live notifications, the easiest option is to test it directly in the browser.
Open DevTools → Network → WS.
Once connected, a WebSocket session should appear.
A successful protocol switch is typically accompanied by the following status: 101 Switching Protocols
This is a good sign.
It means:

You can also inspect the response headers:
Upgrade: websocket
Connection: upgrade
If the connection closes immediately or does not appear at all, check:
- Whether the application actually supports WebSocket;
- Whether the correct path is specified, for example, /ws;
- Whether the Upgrade and Connection headers are being passed;
- Whether the correct application port is being used;
- What the Nginx and application logs report.
For Nginx: sudo tail -f /var/log/nginx/error.log
If the application maintains its own logs, it is useful to monitor them at the same time.
If DevTools shows a 101 Switching Protocols status, Nginx is successfully proxying not only standard HTTP requests but also a persistent WebSocket connection.
External traffic now passes through Nginx in both modes.
The only thing left to add is something no public website should be without today: HTTPS. To do this, we will install a TLS certificate using Certbot and configure Nginx to accept secure connections on port 443. There is just one final step left.
Enabling HTTPS with Certbot
For those unfamiliar with HTTPS and why it is needed, simply put, HTTPS encrypts the connection between the browser and the server. This prevents data from being transmitted over the network in plaintext and allows users to verify that they are connected to the intended domain.
To continue our reception desk analogy, Nginx already knows how to receive visitors and direct them to the appropriate office. Now we simply install a proper access control system at the entrance so that no one can eavesdrop on the conversation along the way.
For the certificate, we will use Certbot and free Let’s Encrypt certificates.
Installing Certbot
On Ubuntu, you can install Certbot via APT.
First, update the package index: sudo apt update
Now let’s install Certbot itself and the Nginx plugin:
sudo apt install -y certbot python3-certbot-nginx Here:
| Package | Purpose |
certbot | Obtains and renews TLS certificates |
| python3-certbot-nginx | Allows Certbot to work with the Nginx configuration automatically |
You can verify the installation with the following command: certbot --version
If the version is displayed, the tool is ready to use.
Before obtaining a certificate, however, you should check two things once more.
- First, the domain must already point to this VPS.
- Second, Nginx must respond to HTTP requests on port 80.
We checked both conditions earlier, so we can proceed.
Obtaining a Certificate for the Domain

Let’s request a certificate for our example:
sudo certbot --nginx -d example.com -d www.example.com If you do not use the www.example.com subdomain and there is no DNS record for it, you do not need to include it in the command: sudo certbot --nginx -d example.com
Next, Certbot will ask you to provide an email address and accept the terms of service, and then it will verify the domain.
Let’s Encrypt must verify that the person requesting a certificate for example.com actually controls the server to which the domain points.
The process works as follows:

The –nginx plugin is convenient because Certbot not only obtains the certificate but can also update the Nginx configuration automatically.
As a result, the configuration will include HTTPS handling on port 443 and the paths to the certificate files.
The configuration will look roughly like this:
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
In our case, there is usually no need to specify these paths manually—Certbot will do it automatically.
Once the process completes successfully, Certbot will report where the certificate is stored and when it expires.
You can now check the website: https://example.com
If everything was successful, the browser should now open the application over HTTPS.
However, there is one more important consideration. We do not want users to keep accidentally visiting the old HTTP address.
Checking the HTTPS Redirect
After setup, Certbot typically offers to redirect HTTP requests to HTTPS.
As a result, a user who opens http://example.com will be automatically redirected to https://example.com
A convenient way to check this is with: curl -I http://example.com
The -I option displays only the HTTP response headers.
If the redirect works, we will see a status such as HTTP/1.1 301 Moved Permanently or another redirect status, and the headers will include: Location: https://example.com/
In other words, this is roughly what happens:

Now, even a user who manually enters the old HTTP address will automatically be redirected to a secure connection.
After that, it is useful to check the HTTPS response again: curl -I https://example.com
If the server returns the expected status code, HTTPS is working not only in the browser but also at the HTTP protocol level.
One final question remains: the certificate is not valid indefinitely. What happens when it approaches its expiration date?
Automatic Certificate Renewal
Let’s Encrypt certificates have a limited validity period, so they must be renewed regularly.
Fortunately, Certbot can do this automatically.
On Ubuntu, Certbot is typically installed with a systemd timer that periodically checks the certificates.
You can view it using the following command: systemctl list-timers | grep certbot
However, you should not rely solely on the timer being present. It is best to verify in advance that Certbot can actually renew the certificate.
There is a safe test for this: sudo certbot renew --dry-run
To clarify, –dry-run simulates an actual renewal without replacing the current certificate.
In other words, it is a full rehearsal:

If the command completes without errors, the automatic renewal mechanism is configured correctly.
You can check the certificates already installed separately: sudo certbot certificates
The command will display:
- Domains;
- Certificate path;
- Validity period;
- Expiration date.
After an actual renewal, Certbot should also ensure that Nginx uses the renewed certificate, so there is no need to manually update the paths in the configuration each time.
At this point, the public-facing part of the setup looks complete:

Now we need to examine a particularly common reverse proxy issue: Nginx itself is running and the domain is accessible, but the user receives a 502 Bad Gateway error instead of the application.
Let’s look at what this error means and where to find its cause.
If You Get a 502 Bad Gateway Error
At first glance, a 502 Bad Gateway error may look like Nginx itself has failed. More often, however, the situation is different:
Nginx is running and received the request, but could not properly connect to the application .
In other words, the request chain is breaking somewhere around here:

So, let’s check everything in order, starting with the application and working our way toward Nginx.
The application is not running
The simplest explanation is that the service Nginx is configured to connect to is not running at all.
Suppose the configuration specifies: proxy_pass http://127.0.0.1:3000;
However, the application is not running at that address.
Test directly: curl http://127.0.0.1:3000
If we get a “Connection refused” then the issue is not with Nginx at all yet.
Nginx simply connects to the address we provided, but no service is listening there.
Next, check how the application is started:
- If using systemd:
sudo systemctl status myapp --no-pager - If using PM2:
pm2 status - If using Docker:
docker ps -a
If the application is indeed stopped, start it using the same method normally used to manage it:
- For systemd:
sudo systemctl start myapp - For PM2:
pm2 start APP_NAME - If the process is already registered with PM2 but is stopped:
pm2 restart APP_NAME - For Docker:
docker start CONTAINER_NAME - If the project is started using Docker Compose:
docker compose up -d
After startup, check again: curl http://127.0.0.1:3000
If the application responds locally, return to the domain and test the request through Nginx.
After starting the application, check again: curl http://127.0.0.1:3000
Only after the local request starts working does it make sense to access the domain again.
The wrong port is specified in proxy_pass
The next scenario is more subtle: the application is running, but not where Nginx expects to find it.
Suppose the application is listening on 127.0.0.1:5000 but the configuration still contains: proxy_pass http://127.0.0.1:3000;
To Nginx, these are completely different addresses.
It cannot guess: “The developer probably meant port 5000.”
It connects to exactly the address we specified.
To see which ports are actually listening on the server, run: sudo ss -lntp
For example, the output might show: LISTEN 0 511 127.0.0.1:5000 0.0.0.0:*
So, let’s fix the Nginx configuration: proxy_pass http://127.0.0.1:5000;
After making changes, be sure to run sudo nginx -t and then sudo systemctl reload nginx
A simple rule to remember is: the port in proxy_pass must match the port the application is actually listening on.
The Application Is Listening on the Wrong Interface
Sometimes the port is correct, but Nginx still cannot connect to the application.
The issue may be related to the specific address on which the application accepts connections.
For example, proxy_pass is set to: 127.0.0.1:3000
This means that Nginx is trying to connect to the application through the VPS’s own local address.
Let’s check where the application is actually listening on port 3000: sudo ss -lntp | grep 3000
Suppose you see: 192.168.1.10:3000
This means that the application is accepting connections on a different address on the server.
In that case, a request to 127.0.0.1:3000 may fail, even if port 3000 itself was selected correctly.
In simple terms:
- Nginx is looking for the application here: 127.0.0.1:3000
- But the application is waiting for requests here: 192.168.1.10:3000
The addresses do not match, so the connection cannot be established.
Another common configuration is: 0.0.0.0:3000
This means that the application accepts connections on all available IPv4 addresses of the VPS.
In this case, Nginx can usually connect to it through: 127.0.0.1:3000
However, if the application should be accessible only through the reverse proxy, it is usually more convenient and secure to run it specifically on: 127.0.0.1:3000
Users will then be unable to access the application’s internal port directly, and all external traffic will pass through Nginx.
Nginx Configuration Error
Assume that the application itself is working properly and curl http://127.0.0.1:3000 returns the expected response.
However, the site still does not open through the domain, or Nginx returns a 502 Bad Gateway error.
This means it is time to check Nginx itself.
First, run: sudo nginx -t
This command checks whether Nginx can read our configuration.
For example, if we forgot a semicolon or a closing brace, or made another syntax error, Nginx will usually identify the file and line where the problem occurred.
However, there is an important caveat.
nginx -t only checks whether the configuration is valid from Nginx’s perspective.
It does not know whether we specified the correct application address.
For example: proxy_pass http://127.0.0.1:300;
As far as Nginx is concerned, this line is perfectly valid.
There is no syntax error, so the check may still return:
syntax is ok
test is successful
But our application is actually running here: 127.0.0.1:3000
The situation is straightforward:
- Configuration: 127.0.0.1:300
- Application: 127.0.0.1:3000
Nginx interpreted the configuration correctly—we simply directed it to the wrong address.
It is like writing an otherwise correct address on an envelope but getting the house number wrong. The postal worker does the job correctly, but the letter still ends up at the wrong address.
Therefore, after a successful sudo nginx -t we separately check the address specified in proxy_pass: curl http://127.0.0.1:3000
If the application responds locally and nginx -t also completes successfully, the problem is not an obvious configuration error.
The next step is to see exactly what happens during the request. To do this, check the Nginx logs and the application logs.
Checking the Nginx and application logs
The main Nginx error log is located here: /var/log/nginx/error.log
View the last lines: sudo tail -n 50 /var/log/nginx/error.log
To monitor errors in real time: sudo tail -f /var/log/nginx/error.log
After that, you can open the website in your browser again.
If Nginx cannot connect to the application, the log will often contain a message like this: connect() failed (111: Connection refused) while connecting to upstream
The key point here is: Connection refused
This means that Nginx did attempt to connect to the upstream—our application—but nothing accepted the connection.
The term upstream in this case refers to the server or application to which Nginx forwards the request.
In our setup, the upstream is 127.0.0.1:3000
However, checking only the Nginx logs is not enough.
The application itself may start, accept the connection, and then fail while processing the request.
That is why we also check the application’s own logs.
- For the systemd service:
sudo journalctl -u myapp --no-pager -n 100 - For PM2:
pm2 logs - For Docker Compose:
docker compose logs
It is therefore useful to follow the same sequence when troubleshooting 502 errors:

The key is not to treat a 502 Bad Gateway error as a standalone Nginx failure.
In most cases, this message means one specific thing:
Nginx received a request from the user, but something prevented it from properly forwarding the request to the next component in the chain. When we check the request path one segment at a time, we can usually identify the cause fairly quickly.
This completes the main configuration. All that remains is one final end-to-end check of the entire request path—from a running Nginx instance and a valid configuration through HTTPS to the application’s response.
Final Check

The main setup is complete. Now, let’s quickly go through the entire chain and make sure that each part works as intended.
Let’s start with Nginx itself:
| What to check | Command | Expected result |
| The configuration is valid | sudo nginx -t | test is successful |
Nginx is running | systemctl is-active nginx | active |
Nginx is enabled to start automatically | systemctl is-enabled nginx | enabled |
Now, let’s check the path from the application to the user:
| What to check | Command / method | Expected result |
| The application responds locally | curl http://127.0.0.1:3000 | Application response |
| HTTP redirects to HTTPS | curl -I http://example.com | Redirect to https://example.com |
| HTTPS works | curl -I https://example.com | Successful HTTP response |
| WebSocket works | DevTools → Network → WS | 101 Switching Protocols |
If all checks pass, the entire setup is working:

In other words, Nginx accepts external traffic and handles HTTPS and WebSocket connections, while the application itself continues to run on a local port within the VPS.
Conclusion

The reverse proxy is now fully configured.
We installed Nginx, connected the domain to the application using proxy_pass, forwarded the required headers, added WebSocket support, and enabled HTTPS with Certbot. We also covered how to validate the configuration and what to do if a 502 Bad Gateway error appears instead of the application.
The application can now run on a local port, while Nginx handles all external operations, including the domain, HTTPS, and request routing. This is a simple, convenient setup that can later be extended to support multiple applications, APIs, dedicated subdomains, and other services.
FAQ
Can Nginx be used as a reverse proxy for multiple applications at the same time?
Yes. This is one of the most common use cases.
For example:
- api.example.com → 127.0.0.1:3000
- admin.example.com → 127.0.0.1:4000
- example.com → 127.0.0.1:5000
You can create a separate server block for each domain or subdomain and specify a different proxy_pass directive for each one. Users will see regular domain names, while Nginx determines which application should receive each request.
What happens if the application restarts while Nginx keeps running?
Nginx will remain available, but requests to the application may receive a 502 Bad Gateway response until the application is back online.
This is why the application itself is typically run using systemd, PM2, Docker, or another process manager configured to start automatically and restart after a failure.
Nginx is responsible for forwarding requests, but it cannot start the application for you.
Do I need to open the application’s port, such as port 3000, in the firewall?
If the application should be accessible only through Nginx and listens on 127.0.0.1:3000, there is generally no need to expose this port externally.
Users connect to Nginx over port 80 or 443, and Nginx then connects to the application within the VPS.
The external connection flow is as follows:

Port 3000 remains an internal part of the infrastructure.
Why might the application’s logs show the Nginx IP address instead of the user’s IP address?
Because from the application’s perspective, the direct connection is established by Nginx.
To pass information about the original client downstream, we use headers such as:
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
The $proxy_add_x_forwarded_for variable preserves the existing X-Forwarded-For chain and appends the client’s address.
However, the application itself must also be configured to trust proxy headers only when they come from a known reverse proxy.
Why might a WebSocket connection drop after about a minute even though it initially works normally?
A timeout may be the cause.
According to the Nginx documentation, if the proxied WebSocket server does not transmit any data for a period of time, the connection may be closed by default after approximately 60 seconds. For long-lived connections, you can increase proxy_read_timeout or configure the application to periodically send WebSocket ping frames.
For example: proxy_read_timeout 300s;
However, you should not increase the value blindly—first make sure that the issue is actually related to the timeout.
Why must the Upgrade and Connection headers be forwarded explicitly for WebSocket?
Because these headers apply to the connection between two adjacent nodes in the HTTP chain and are not automatically forwarded by a reverse proxy.
Therefore, Nginx must be explicitly instructed to forward the protocol upgrade request to the application:
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
If the application agrees to switch to WebSocket, a successful handshake typically concludes with a 101 Switching Protocols response.
Do I need to obtain a new HTTPS certificate manually every few months?
Usually not.
Certbot supports automatic certificate renewal, and you can test the process in advance using: sudo certbot renew --dry-run
The renew command is also designed for automated use and renews certificates as they approach expiration. The –dry-run option lets you test the renewal process without replacing the current certificate.
