By the end of this guide you'll have Nginx sitting in front of one or more local apps, taking requests on port 80 and quietly passing them to a Node, Python, or any other app running on its own port. This is for anyone who has an app working on something like localhost:3000 and now wants to serve it on a real domain, cleanly, with room to add more apps and SSL later. You don't need to be a Linux expert, but you should be comfortable editing a file over SSH.
What a reverse proxy actually does
A reverse proxy is a server that takes requests from the outside world and forwards them to another server behind it. The visitor only ever talks to Nginx. Nginx then talks to your app and hands the response back. Your app never gets exposed to the internet directly.
So why bother? A few solid reasons:
- Your app can keep running on a high port like 3000 or 8000, while visitors use the normal port 80 (or 443 for HTTPS). You don't run your app as root just to bind to port 80.
- You can host several apps on one machine, each on its own port, and route traffic to the right one based on the domain name.
- SSL termination happens in one place. Nginx handles the certificate, and your app stays plain HTTP internally.
- You get gzip, caching, rate limits, and clean logging for free, all in Nginx config, without touching your app code.
Picture it like a receptionist. Visitors walk up to the front desk (Nginx). The receptionist knows which office handles what and walks the request back there. The offices (your apps) never deal with the public directly.
Before you start
You'll need a Linux server you can reach over SSH, with sudo access. The examples here use Ubuntu or Debian, since those are the most common. If you run a different distro, the package names and paths shift a little, but the config itself is identical.
You also need an app already running locally. For this guide we'll assume you have something listening on 127.0.0.1:3000. A tiny Node app works fine for testing:
const http = require('http');
http.createServer((req, res) => {
res.end('Hello from the app on port 3000\n');
}).listen(3000, '127.0.0.1');
Run that, and a quick curl http://127.0.0.1:3000 on the server should print the hello line. If that works, Nginx has something to talk to.
Step 1: Install Nginx
Update your package list and install Nginx:
sudo apt update
sudo apt install nginx -y
Nginx starts on its own after installing. Check that it's running:
sudo systemctl status nginx
You're looking for a green active (running) line. Press q to exit that view. Now open your server's IP in a browser. You should see the default Nginx welcome page. That page confirms Nginx is alive and serving on port 80. We're about to replace what it serves.
Step 2: Create a server block that proxies to your app
On Debian and Ubuntu, site configs live in two folders. You write the real file in /etc/nginx/sites-available/, then create a symlink to it in /etc/nginx/sites-enabled/. Nginx only loads what's in sites-enabled. This split lets you keep a config around without it being live.
Create a new file for your site:
sudo nano /etc/nginx/sites-available/myapp
Paste this in, and change app.example.com to your own domain:
server {
listen 80;
server_name app.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;
}
}
Save and close. In nano that's Ctrl+O, Enter, then Ctrl+X.
Let's walk through what each line does, because these proxy headers are where most people get tripped up.
The headers that matter
proxy_pass is the heart of it. It tells Nginx where to send the request. Here it's your local app on port 3000. Note there's no trailing slash after 3000 in this setup, which keeps the path passing through unchanged.
Host passes the original domain the visitor asked for. Without it, your app sees the request as coming to 127.0.0.1, which breaks anything that depends on the hostname, like generating absolute URLs or routing by domain.
X-Real-IP and X-Forwarded-For carry the visitor's real IP address. By default your app would only ever see 127.0.0.1, because that's who's actually connecting (Nginx). These headers let your app log and rate-limit by the true client IP. X-Forwarded-For is the standard chain, X-Real-IP is the simpler single value.
X-Forwarded-Proto tells your app whether the original request was http or https. This matters a lot once you add SSL. Nginx talks HTTPS to the visitor but plain HTTP to your app, so without this header your app thinks every request is insecure and may build the wrong redirect URLs.
Step 3: Enable the site and reload
Create the symlink to switch the site on:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
You probably want to remove the default site so it doesn't shadow yours on requests that don't match your domain:
sudo rm /etc/nginx/sites-enabled/default
Always test your config before reloading. This one command saves you from typos taking the whole server down:
sudo nginx -t
A good result looks like this:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
If the test passes, reload Nginx to apply the change:
sudo systemctl reload nginx
Reload is gentler than restart. It picks up the new config without dropping live connections. Now visit your domain in a browser, or curl it from anywhere:
curl http://app.example.com
You should see your app's response, the hello line from earlier, served on the normal port 80 through your domain. If you get that, the proxy works.
Step 4: Support WebSockets
Plenty of apps use WebSockets, including anything with live chat, real time dashboards, or Socket.IO. WebSockets start as a normal HTTP request and then ask to "upgrade" the connection. Nginx won't pass that upgrade through unless you tell it to.
Add these lines inside your 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;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
The proxy_http_version 1.1 line is required, because the upgrade mechanism doesn't exist in HTTP 1.0. The two Upgrade and Connection headers are what carry the handshake through to your app. Test and reload again:
sudo nginx -t && sudo systemctl reload nginx
If your live features suddenly start working and stop falling back to slow polling, the WebSocket pass-through is doing its job.
Step 5: Host several apps by server_name
One server can run many apps, each on its own port, each on its own domain. The trick is one server block per app, distinguished by server_name. Nginx reads the Host header on each request and routes to the matching block.
Say you have a second app on port 4000. Make another file:
sudo nano /etc/nginx/sites-available/secondapp
And give it its own domain and port:
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:4000;
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;
}
}
Enable it the same way, test, and reload:
sudo ln -s /etc/nginx/sites-available/secondapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Now app.example.com hits port 3000 and api.example.com hits port 4000, both on the same box. Both domains need to point at this server's IP through an A record in your DNS. As long as the Host header matches a server_name, Nginx knows where to go. We host stacks like this on Bytte.cloud VPS plans all the time, one Nginx instance fronting several small services, and it stays tidy as long as each app keeps to its own file and port.
If you'd rather repeat less, you can pull the shared proxy headers into a snippet file and include it. Put the four header lines in /etc/nginx/snippets/proxy.conf and reference it inside each location with include snippets/proxy.conf;. That keeps every site consistent.
Where SSL fits in next
Right now everything runs on plain HTTP. The natural next step is HTTPS, and the good news is your proxy setup barely changes. The usual route is Certbot from Let's Encrypt:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d app.example.com
Certbot reads your existing server block, asks Let's Encrypt for a certificate, and rewrites the config to listen on 443 with SSL. It can also add a redirect so http requests bounce to https automatically. Because you already set X-Forwarded-Proto $scheme, your app will correctly see https once the certificate is in place. Your proxy_pass still points at plain 127.0.0.1:3000 internally, which is exactly right. Nginx does the encryption, your app stays simple.
Troubleshooting
502 Bad Gateway
This is the one you'll meet most. It means Nginx is up and took the request, but couldn't reach the app behind it. Work through these:
- Is the app actually running? Run
curl http://127.0.0.1:3000on the server. If that fails, Nginx was never the problem. Start your app. - Right port? Make sure the port in proxy_pass matches the port your app listens on. A mismatch here is the classic cause.
- Listening on localhost, not a socket your app blocks? If your app binds only to
127.0.0.1, your proxy_pass must use127.0.0.1, notlocalhostin edge cases where localhost resolves oddly. Keeping both as 127.0.0.1 avoids surprises. - SELinux on RHEL or Fedora? SELinux blocks Nginx from making outbound connections by default. Run
sudo setsebool -P httpd_can_network_connect 1to allow it.
When you hit a 502, check the Nginx error log. It usually names the cause directly:
sudo tail -f /var/log/nginx/error.log
WebSocket connection keeps dropping or won't upgrade
If real time features fail, the browser console often shows a failed upgrade or a connection that closes right after opening. Confirm you added all three WebSocket lines: proxy_http_version 1.1, the Upgrade header, and the Connection "upgrade" header. Missing any one of them breaks the handshake. After editing, run sudo nginx -t and reload. If you use Cloudflare in front, make sure WebSocket support is enabled there too, since a proxy further upstream can strip the upgrade.
nginx -t fails
The test output tells you the exact file and line number. A missing semicolon or a stray brace is almost always it. Open the file at that line, fix it, and test again. Never reload on a failed test, the running config stays in place until a reload succeeds, so a broken file won't take you down unless you push it.
Wrong site loads, or you see the default page
If requests land on the wrong app, two things to check. First, did you remove or rename the default site in sites-enabled? It can catch requests that don't match a server_name. Second, confirm your domain's DNS A record points at this server's IP, and that the server_name matches the domain exactly. A trailing typo in the domain sends Nginx looking for a block that doesn't exist, so it falls back to the first one it has.
Changes don't take effect
If edits seem ignored, you almost certainly forgot to reload, or you edited the file in sites-available without symlinking it into sites-enabled. Check the symlink exists with ls -l /etc/nginx/sites-enabled/, then reload. Browsers also cache hard, so test with curl or a private window to rule that out.
Wrapping up
You now have Nginx accepting public traffic and forwarding it to your apps, with the right headers so each app sees the real visitor IP and protocol. You can add as many apps as your server can handle by giving each one a file, a port, and a domain. The next move is almost always SSL with Certbot, which slots in without disturbing any of the proxy work you just did. Keep each site in its own file, run nginx -t before every reload, and this setup will stay easy to reason about as it grows.



