Get your free server today! View Plans →
Home Plans Blog About Contact Panel Join Discord
Hosting

How to set up an Nginx reverse proxy

Put Nginx in front of your local apps so visitors reach them on a real domain over port 80. This guide covers proxy_pass, the headers that matter, WebSockets, multiple sites, and the fixes for common errors.

How to set up an Nginx reverse proxy

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:

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:

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.

Common questions

Why do I need a reverse proxy if my app already runs on port 3000?

A reverse proxy lets visitors use the normal port 80 or 443 and your domain name while your app keeps running on its high port. It also handles SSL, lets you host several apps on one server, and means you never expose your app to the internet directly or run it as root.

Why am I getting a 502 Bad Gateway error?

A 502 means Nginx reached you but could not reach the app behind it. Check that the app is actually running with curl http://127.0.0.1:3000, confirm the port in proxy_pass matches the app's port, and on RHEL or Fedora run setsebool -P httpd_can_network_connect 1 to let SELinux allow the connection.

Why don't my WebSockets work through Nginx?

Nginx does not pass the WebSocket upgrade by default. Add proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade, and proxy_set_header Connection "upgrade" inside your location block, then test and reload. If a proxy like Cloudflare sits in front, enable WebSocket support there too.

How do I host more than one app behind the same Nginx?

Create one server block per app, each with its own server_name domain and its own proxy_pass port. Nginx reads the Host header and routes each request to the matching block. Every domain needs an A record pointing at the server's IP.

Do I add SSL before or after setting up the proxy?

Set up the plain HTTP proxy first, then add SSL. Run certbot --nginx -d yourdomain.com and Certbot rewrites your server block to listen on 443. Your proxy_pass still points at plain 127.0.0.1 internally, and because you set X-Forwarded-Proto your app correctly sees https.

SA
Sofia Almeida
Systems Engineer at Bytte.cloud

Part of the Bytte.cloud team. We run game servers, bots and websites for a living, and we write these guides from what we see day to day in support and on our own servers.

Want to try this on real hardware?

Bytte.cloud has free plans for game servers, bots and websites. No credit card, set up in seconds.

Start for free See the plans