Quick Summary – Nginx Reverse Proxy Setup
Nginx can accept public HTTP and HTTPS traffic while keeping your application on a private localhost port. Reliable operation depends on correct DNS, trusted forwarded headers, tested reloads, and separate checks for TLS and WebSockets.
- Check DNS first — Confirm the domain's A or AAAA record points to the VPS before changing Nginx.
- Keep backends private — Bind applications to localhost or restrict remote backends to the proxy server's private IP.
- Forward identity — Pass Host, client IP, and protocol headers while trusting them only from known proxy layers.
- Test before reload — Run nginx -t before every reload and keep a copy of the previous configuration.
- Handle WebSockets — Forward upgrade headers only where the application needs them and set timeouts deliberately.
- Measure each layer — Use curl, Nginx logs, application logs, and timing variables to separate backend, proxy, and TLS failures.
An Nginx reverse proxy gives you one controlled HTTP or HTTPS endpoint while your application stays on a private address such as 127.0.0.1:3000. The configuration is not difficult, but DNS, forwarded headers, WebSockets, certificate renewal, and reload testing are where a working proxy becomes a dependable one.
Table of Contents
- What does an Nginx reverse proxy do?
- What should you prepare before configuring the proxy?
- Setting up an Nginx reverse proxy on Ubuntu
- Forwarding the client IP and proxy headers
- Adding HTTPS and hostname redirects
- WebSockets and long-lived connections
- Static files, path prefixes, and multiple applications
- Investigating errors and measuring performance
- Security checks before going live
- Check These Before Going Live
- Frequently Asked Questions
- Sources
What does an Nginx reverse proxy do?
I usually notice a reverse proxy when it is missing. An application is listening on a public port, TLS is configured inside the application, logs are scattered across several services, and one small change requires touching three places. Nginx gives me a single public entry point while the application stays on 127.0.0.1:3000, 127.0.0.1:8000, or a private Unix socket.
Nginx accepts HTTP requests on ports 80 and 443, then forwards them to the backend. That makes one place for TLS termination, hostname routing, access logs, and selected security rules.
The installation looks like a few file edits. The parts that usually bite are forwarding the client IP correctly, keeping WebSocket connections alive, pointing DNS at the right server, and testing the configuration before reloading it.
The distinction explained in Domain vs URL: What Is the Difference? is useful here: Nginx chooses the server block from the hostname, while the path portion is usually handled by location rules.
Keep the backend private. If the application and Nginx share a VPS, bind the application to localhost whenever possible.
What should you prepare before configuring the proxy?
The examples use Ubuntu 22.04 or 24.04, Nginx, and an application listening on port 3000. Package names and file layouts differ on other distributions. My guide How to Install and Configure Nginx on Ubuntu covers the initial package setup; here I am focusing on the reverse proxy layer.
First, verify that the domain’s A or AAAA record points to the VPS. I normally start in the terminal, not in a hosting panel:
dig +short example.com A
dig +short example.com AAAA
The output should contain the VPS’s IPv4 or IPv6 address. If an old address comes back, changing Nginx will not fix the request path because traffic may still be reaching another server. DNS caching and propagation are discussed in What Is DNS Propagation? How Long Does It Take?.
Test the backend before putting Nginx in front of it:
ss -ltnp | grep ':3000'
curl --fail --silent --show-error http://127.0.0.1:3000/
The ss output should show the application process listening on port 3000. The curl command should return a 2xx response, or another status code that the application intentionally uses. If nothing is listening, fix the application service first.
Start with the backend. A proxy cannot repair an application that is not answering locally.
Tip
Use dig +short and curl against localhost before editing the Nginx site file. If DNS or the backend is wrong, a proxy change only moves the symptom.
Setting up an Nginx reverse proxy on Ubuntu
Install and enable the package
Install Nginx from Ubuntu’s repositories and check the service:
sudo apt update
sudo apt install nginx
sudo systemctl enable --now nginx
sudo systemctl status nginx --no-pager
The default installation listens on port 80. If you use UFW, allow both HTTP and HTTPS:
sudo ufw allow 'Nginx Full'
sudo ufw status verbose
This permits TCP ports 80 and 443. There is no reason to expose port 3000 publicly when the backend is bound to localhost.
Create the site configuration
On Debian-based systems, Nginx loads enabled sites through symbolic links in /etc/nginx/sites-enabled/. Create the file in sites-available:
sudo vim /etc/nginx/sites-available/example.com
This configuration forwards requests for example.com and www.example.com to port 3000:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log warn;
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_read_timeout 60s;
proxy_connect_timeout 5s;
}
}
proxy_pass identifies the backend. The Host header preserves the hostname used by the client, while X-Real-IP and X-Forwarded-For carry address information upstream. These headers affect what the application believes about the request, so configure the application to trust forwarded values only from known proxy layers.
proxy_read_timeout controls how long Nginx waits for a response from the backend. Match it to the application’s real operations. Setting it to an arbitrary number of minutes can leave dead connections consuming resources.
Create the symbolic link, remove the default site if you do not need it, test the configuration, and reload:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Do not run reload until nginx -t succeeds. Successful output normally includes syntax is ok and test is successful. A reload normally applies the new configuration without stopping existing worker processes; restart stops and starts the service instead.
Test first. I run sudo nginx -t before every reload. That habit has saved me from turning a typo into an outage.
Forwarding the client IP and proxy headers
An application behind a reverse proxy sees the TCP connection as coming from Nginx. Forward the relevant headers when the application needs the original client address in logs, rate limits, redirects, or audit records.
$proxy_add_x_forwarded_for appends the address Nginx sees to an existing X-Forwarded-For chain. Using only $remote_addr can discard information from an earlier trusted proxy.
If Nginx terminates HTTPS and sends plain HTTP to the backend, X-Forwarded-Proto: https matters. Without it, an application may generate http:// URLs, omit the Secure attribute from session cookies, or create an endless HTTPS redirect.
When Nginx sits behind a CDN or load balancer, define trusted proxy addresses separately. Do not let the application accept every incoming X-Forwarded-For value as fact. A header supplied directly by an internet client is not proof of identity.
RFC 7239 defines the standardized Forwarded header. Many applications still expect X-Forwarded-For, X-Forwarded-Proto, and X-Real-IP, so I match the format to the application’s documentation instead of adding every possible header.
Define the trust boundary. Client IP headers are useful only when the application knows which proxy inserted them.
Caution
Do not trust every X-Forwarded-For value from the public internet. Define which proxy addresses are trusted, or your logs and access controls may use an attacker-supplied client IP.
Adding HTTPS and hostname redirects
Once the HTTP path works, add TLS. Before requesting a certificate, DNS must point to the correct server and TCP port 80 must be reachable for an ACME HTTP-01 challenge. If Certbot or another tool edits Nginx automatically, I back up the existing configuration first.
A basic HTTPS configuration looks like this:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
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;
}
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
The 301 redirect sends HTTP requests to HTTPS while preserving the path and query string. If the certificate files do not exist yet, nginx -t will fail, so do not enable the TLS block before issuing the certificate.
Check that every hostname appears in the certificate. After renewal, configuration testing and an Nginx reload should be part of the renewal workflow. Renewing the files does not mean that an already-running Nginx process has loaded them.
Test the whole path. Check the certificate, hostname, redirect, and renewal job together instead of trusting only the padlock in a browser.
WebSockets and long-lived connections
A basic proxy is enough for ordinary HTTP requests. WebSockets use an HTTP upgrade flow, so Nginx must pass the Upgrade and Connection headers to the backend.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name app.example.com;
location /socket.io/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
}
The map block belongs in the http context, usually inside the http {} section of /etc/nginx/nginx.conf. Putting it inside a site file can produce a context error. The WebSocket path may be /socket.io/, /ws/, or something else in your application.
A long timeout does not prove that a connection is healthy. The application should send heartbeats, and monitoring should track open connection counts and error rates. How to Monitor a VPS with Prometheus and Grafana covers the basic approach for making those metrics visible.
Limit upgrades. Enable the upgrade headers only in locations that genuinely serve WebSockets.
Static files, path prefixes, and multiple applications
You can run several applications on one VPS. Nginx selects a separate server block for each hostname, and different paths on one hostname can go to different upstreams.
upstream api_backend {
server 127.0.0.1:8000;
keepalive 16;
}
server {
listen 443 ssl;
server_name example.com;
root /var/www/example.com/public;
location /assets/ {
try_files $uri =404;
access_log off;
expires 7d;
}
location /api/ {
proxy_pass http://api_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Using proxy_pass with or without a URI changes the path sent upstream. With location /api/, proxy_pass http://127.0.0.1:8000; preserves the request path. Adding a trailing slash, as in proxy_pass http://127.0.0.1:8000/;, changes the matching part of the URI. Test whether the API expects /api/users or /users with a small curl request.
try_files $uri =404; prevents missing static files from being sent needlessly to the application. Set the cache lifetime together with your file-naming strategy; long expires values become painful when changed content keeps the same URL.
Check both paths. Test the path the client requests and the path the backend actually receives for every location block.
Investigating errors and measuring performance
When a proxy fails, I do not restart Nginx immediately. I separate three layers: client to Nginx, Nginx to the backend, and the application’s own logs.
These checks narrow the problem quickly:
curl -I http://127.0.0.1:3000/
curl -I -H 'Host: example.com' http://127.0.0.1/
curl -vk https://example.com/
sudo tail -f /var/log/nginx/example.com.error.log
sudo journalctl -u nginx -n 100 --no-pager
The first command checks the application, the second checks selection of the correct server_name, and the third shows TLS negotiation and redirects. A 502 Bad Gateway usually means Nginx could not connect to the backend, the application is down, or the port or socket is wrong. A 504 Gateway Timeout usually means the backend did not respond in time. Increasing the timeout before reading the application log is not a diagnosis.
I once treated a timeout change as the fix for a slow backend. The application was actually returning errors before the timeout mattered. Since then I read the Nginx and application logs first, then measure the request path again.
Access logs can show request and upstream timing. For more detail, define a format in the http context:
log_format proxy_timing '$remote_addr - $host [$time_iso8601] '
'"$request" $status $body_bytes_sent '
'rt=$request_time '
'uct=$upstream_connect_time '
'urt=$upstream_response_time';
Apply it to the relevant access_log directive. Keep raw logs for too long and the disk eventually becomes part of the incident. I check log rotation and disk alerts together. The two are related.
If you put a CDN in front of Nginx, client IPs, cache headers, and HTTPS redirects can differ across the two proxy layers. What Is a CDN? How It Speeds Up Websites explains the distinction between the cache layer and the origin server.
Measure in order. Test the backend, then Nginx, then TLS and any CDN layer separately.
Example
A 502 usually points to a connection problem between Nginx and the backend, while a 504 usually means the backend did not answer within the configured time. Check the application log before increasing a timeout.
Security checks before going live
A reverse proxy hides the application port, but it is not a firewall by itself. Administration panels, databases, and debug endpoints should not be exposed to the internet. Binding the application to 127.0.0.1 is a good start; for a backend on another server, allow only the Nginx server’s private IP in that server’s firewall.
Do not add security headers as a fixed recipe without checking what the application expects. Define allowed hostnames explicitly in applications that use Host as input. For upload services, if Nginx’s client_max_body_size is lower than the application limit, clients receive 413 Request Entity Too Large. Increasing a PHP or application limit does not change Nginx’s limit automatically.
Keep configuration permissions as narrow as practical. A certificate private key should be readable only by root and any service account that genuinely needs it. Changing the SSH port or running chmod 777 is not a substitute for proper access controls.
Back up the configuration before deployment:
sudo cp -a /etc/nginx /etc/nginx.backup.$(date +%F-%H%M)
sudo nginx -t
sudo systemctl reload nginx
curl --fail --silent --show-error -o /dev/null -w '%{http_code}\n' https://example.com/
The final curl command prints only the HTTP status code. It does not prove that the response body is correct, so test login, static files, API requests, and WebSockets separately for a critical release.
Keep the previous configuration nearby. A rollback you can execute calmly is part of the setup, not an admission that the deployment failed.
From the field
I once increased a proxy timeout before checking the application logs. The timeout was not the problem; the backend was already failing quickly. Since then I test the backend, Nginx, and TLS as separate layers.
Check These Before Going Live
- Confirm that A and AAAA records point to the correct VPS.
- Verify that the backend responds successfully on localhost.
- Install the site configuration and create its symbolic link.
- Run nginx -t before every reload.
- Forward only the headers your application understands and trust.
- Test HTTPS, redirects, certificate renewal, and WebSocket paths.
- Keep a configuration backup and verify the rollback command.
Start with one hostname and one localhost backend. Test each layer, then add HTTPS, WebSockets, or path routing after the simple proxy works. Keep the previous configuration within reach; I still do this even for changes that look too small to fail.
Frequently Asked Questions
What is an Nginx reverse proxy?
An Nginx reverse proxy accepts requests from clients and forwards them to an application server. The application can listen on a private address such as 127.0.0.1:3000, while Nginx handles public ports, hostnames, TLS, access logs, and selected security rules. Clients communicate with Nginx rather than connecting directly to the backend.
Should my application listen on port 3000 publicly?
Usually no. If Nginx and the application run on the same VPS, bind the application to 127.0.0.1 so outside clients cannot reach it directly. If the backend is on another server, restrict its firewall to the Nginx server's private address. Exposing the application port creates another public attack surface and bypasses controls configured at Nginx.
Why does Nginx return 502 Bad Gateway?
A 502 commonly means Nginx cannot connect to the configured backend. Check whether the application is running, whether it is listening on the expected port or socket, and whether the address in proxy_pass is correct. Test the backend directly with curl, then inspect the Nginx error log and the application's own log before changing timeouts.
How do I configure WebSockets through Nginx?
Use HTTP/1.1 and forward the Upgrade and Connection headers in the location that serves the WebSocket endpoint. A map block can select upgrade or close based on the incoming request. Put that map in the http context, not inside a server or location block, and use a read timeout appropriate for the application's heartbeat behavior.
Do I need to configure X-Forwarded-For?
Configure it when the application needs the original client address for logs, redirects, rate limits, or auditing. Use $proxy_add_x_forwarded_for when preserving a trusted proxy chain, and configure the application to trust headers only from known proxies. Never accept arbitrary public X-Forwarded-For values as authenticated client identity.
Can Nginx terminate HTTPS and send HTTP to the application?
Yes. Nginx can terminate TLS on port 443 and proxy plain HTTP to a backend on localhost. Pass X-Forwarded-Proto so the application knows the original request used HTTPS; otherwise it may generate insecure URLs, omit Secure cookies, or redirect repeatedly. Test certificate renewal and reload behavior rather than assuming renewed files are loaded automatically.
Sources
- Nginx Documentation — nginx.org
- RFC 7239 – Forwarded HTTP Extension — rfc-editor.org
- Let's Encrypt – Challenge Types — letsencrypt.org
- Ubuntu Server Documentation – Install Nginx — documentation.ubuntu.com