How to Set Up an Nginx Reverse Proxy: One Server, Many Services, Zero Port Confusion

There’s a moment in every self-hoster’s life where you look at your bookmarks and realize you’ve got a dozen services running on a dozen different ports. localhost:8080 for your wiki, localhost:3000 for your dashboard, localhost:9090 for Prometheus. It works, but it feels messy. You remember what it’s like to visit a clean domain and just know where you are.

That’s the problem a reverse proxy solves. Instead of remembering ports, you use subdomains (or paths) and let a single piece of software route traffic to the right backend. Nginx is one of the most battle-tested tools for this job — lightweight, fast, and endlessly configurable.

In this tutorial, you’ll set up Nginx as a reverse proxy on a Linux server, forward multiple backend services, and add HTTPS with Let’s Encrypt. By the end, you’ll have a clean, unified entry point for all your self-hosted apps.

What You’ll Need

  • A Linux server (Ubuntu 22.04+ or similar) with a public IP address
  • Root or sudo access
  • At least one backend service already running (a web app on a local port)
  • A domain name you control, with DNS pointing to your server’s IP
  • Basic comfort with the command line and editing config files

Step 1: Install Nginx

Start by installing Nginx from your distribution’s package manager:

sudo apt update
sudo apt install nginx -y

Once installed, Nginx usually starts automatically. Verify it’s running:

sudo systemctl status nginx

You should see active (running). If you visit your server’s IP in a browser, you’ll see the default Nginx welcome page — that’s how you know it’s listening.

Step 2: Understand the Mental Model

A reverse proxy sits between the internet and your backend services. When a request comes in, Nginx looks at the domain name (or URL path) and forwards the request to the right internal service.

Here’s the flow:

Client → Nginx (your-domain.com) → Backend Service (127.0.0.1:3000)

You’ll create a server block (similar to a virtual host) for each service you want to proxy. Each block tells Nginx: “When someone visits this domain, forward their request to this internal address.”

Step 3: Create a Server Block for Your First Service

Let’s say you have a web app running on port 3000 and you want it available at app.yourdomain.com.

Create a new Nginx config file:

sudo nano /etc/nginx/sites-available/app.yourdomain.com

Add the following configuration:

server {
    listen 80;
    server_name app.yourdomain.com;

    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";
    }
}

Let’s break down what each line does:

  • proxy_pass: This is the core directive — it tells Nginx where to forward requests.
  • proxy_http_version 1.1: Required for WebSocket support and keep-alive connections.
  • proxy_set_header Host: Passes the original hostname to the backend so it knows which site to serve.
  • X-Real-IP / X-Forwarded-For: Passes the client’s real IP to the backend, so logs and rate-limiting work correctly.
  • X-Forwarded-Proto: Tells the backend whether the original request was HTTP or HTTPS.
  • Upgrade / Connection: Enables WebSocket proxying (needed by many modern apps).

Step 4: Enable the Site

On Debian/Ubuntu, Nginx uses a two-directory system: sites-available (where configs live) and sites-enabled (what Nginx actually reads). Enable your site by creating a symlink:

sudo ln -s /etc/nginx/sites-available/app.yourdomain.com /etc/nginx/sites-enabled/

Always test your configuration before reloading:

sudo nginx -t

If you see syntax is ok and test is successful, reload Nginx:

sudo systemctl reload nginx

Now visit http://app.yourdomain.com and you should see your backend service!

Step 5: Add More Services

To add another service, just create another server block. For example, if you have a wiki running on port 8080:

sudo nano /etc/nginx/sites-available/wiki.yourdomain.com
server {
    listen 80;
    server_name wiki.yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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";
    }
}

Enable it the same way:

sudo ln -s /etc/nginx/sites-available/wiki.yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

That’s the pattern: create config, symlink, test, reload. Repeat for every service you want to expose.

Step 6: Add HTTPS with Let’s Encrypt

HTTP is fine for testing, but you’ll want HTTPS for anything public. Certbot makes this almost trivially easy:

sudo apt install certbot python3-certbot-nginx -y

Then obtain and install a certificate for one of your domains:

sudo certbot --nginx -d app.yourdomain.com

Certbot will automatically:

  • Obtain a certificate from Let’s Encrypt
  • Modify your Nginx config to use HTTPS
  • Set up automatic redirects from HTTP to HTTPS
  • Configure certificate auto-renewal

For multiple domains, you can chain them:

sudo certbot --nginx -d app.yourdomain.com -d wiki.yourdomain.com

Verify auto-renewal is working:

sudo certbot renew --dry-run

Step 7: Set Up a Default Server (Catch-All)

It’s good practice to add a default server block that catches requests for unknown domains or IPs and returns an error. This prevents random scanners from hitting your backend services directly.

sudo nano /etc/nginx/sites-available/default

Replace the contents with:

server {
    listen 80 default_server;
    server_name _;
    return 444;
}

The 444 code is Nginx-specific — it closes the connection without sending any response. It’s the digital equivalent of a brick wall.

Common Pitfalls

502 Bad Gateway

This means Nginx can’t reach your backend service. Check that the backend is actually running and listening on the port you specified. Use ss -tlnp | grep 3000 to verify.

504 Gateway Timeout

The backend is reachable but too slow. You can increase the timeout:

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_read_timeout 300;
    proxy_connect_timeout 300;
}

WebSocket Connections Fail

If your app uses WebSocket (many real-time dashboards do), make sure you have the Upgrade and Connection headers from Step 3. Without them, WebSocket handshakes will fail silently.

Backend Shows Wrong URL or Broken CSS

This usually means the Host header isn’t being passed correctly. Double-check that proxy_set_header Host $host; is present. Some apps also need the X-Forwarded-Proto header to generate correct HTTPS links.

Config Test Fails

Run sudo nginx -t and read the error carefully. The most common issues are missing semicolons, typos in server_name, or forgetting to close a block with }.

Bonus: Rate Limiting

If you’re exposing services to the public, consider adding rate limiting to prevent abuse. Add this outside your server block (in the http context):

limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

Then use it inside your location block:

location / {
    limit_req zone=general burst=20 nodelay;
    proxy_pass http://127.0.0.1:3000;
}

This allows 10 requests per second with a burst of 20. Adjust based on your needs.

Wrapping Up

You now have a clean, unified entry point for all your self-hosted services. One Nginx instance, multiple subdomains, HTTPS included, and no more remembering port numbers. Your bookmarks will thank you.

The beauty of this setup is its simplicity. Every new service you deploy follows the same three-step pattern: spin up the app, create a server block, enable and reload. Once you’ve done it two or three times, it becomes second nature.

And if you ever need to take a service down for maintenance? Just comment out the proxy_pass line and add return 503;. Nginx will gracefully tell visitors “come back later” instead of throwing confusing errors.

Happy proxying.

Leave a Reply

Your email address will not be published. Required fields are marked *