Blog » Linux » How to Configure Nginx as a Reverse Proxy on Linux
› nginx-reverse-proxy-linux Nginx reverse proxy configuration with proxy_pass directives displayed on a Linux terminal screen

How to Configure Nginx as a Reverse Proxy on Linux

Table of Contents

The first time I set up an nginx reverse proxy on Linux, it was for a scrappy little Node.js app running in my homelab. I had this dashboard humming away on localhost:3000, and I wanted to reach it from my phone with a real domain and a padlock in the address bar. Nginx made that happen in about fifteen lines of config. That moment hooked me, and I’ve been reaching for the same pattern ever since.

Nginx reverse proxy configuration with proxy_pass directives displayed on a Linux terminal screen

If you’re running any backend app on Linux — Node.js, Python, Java, Go, whatever — putting Nginx in front of it is one of the highest-leverage moves you can make. And it’s popular for good reason: Nginx holds roughly 32.8% of the global web server market as of 2026, serving over 5.4 million websites, according to W3Techs web server statistics.

Quick answer: what does a reverse proxy do?

An Nginx reverse proxy sits between the public internet and your backend app. Clients talk to Nginx; Nginx forwards requests to your app on localhost, then relays the response back. You get SSL, security, caching, and load balancing in one layer — without touching your app’s code. The core directive is proxy_pass, and we’ll use it a lot.

What Is an Nginx Reverse Proxy (And Why It Changes Everything)

A regular proxy works on behalf of the client. A reverse proxy works on behalf of the server. Nginx stands in front of your backend, takes incoming requests, and decides where they go.

RackNerd Mobile Leaderboard Banner

Get a VPS from as low as $11/year! WOW!

Here’s why I put one in front of nearly everything I run:

  • Security: Your backend never faces the public internet directly. It listens on loopback; only Nginx is exposed.
  • SSL termination: Nginx handles all the TLS work, so your app doesn’t have to deal with certificates at all.
  • Load balancing: One proxy can spread traffic across several backend instances.
  • Caching and rate limiting: You can absorb abuse and speed up responses at the proxy layer.

As the F5/NGINX official glossary puts it:

“A reverse proxy provides an additional level of abstraction and control to ensure the smooth flow of network traffic between clients and servers.”

This pattern shines with containerized apps too. If you’re running services with Docker on Linux, Nginx is the natural front door. And if you’re an Apache shop, you can still do this — though for reverse proxy work specifically, I reach for Nginx over Apache web server almost every time. There’s one header most people forget that causes an infinite redirect loop — we’ll get to that in Step 2.

Prerequisites

Before we write any config, make sure you’ve got these in place:

  • Nginx installed and running. If you haven’t done that yet, start with my guide on how to set up Nginx on Linux first.
  • A backend app listening on a local port like localhost:3000 or localhost:8080. I’ll use a Node.js app on port 3000 as the running example — here’s how to install Node.js on Linux if you need it.
  • sudo or root access on the server.
  • A domain name (optional, but required for SSL).

One more thing from experience: your backend should be managed as a service so it restarts on boot and on crash. See how to create a systemd service to keep it running reliably. For the full directive reference, the official NGINX reverse proxy documentation is worth bookmarking.

Step 1 — Write the Basic proxy_pass Configuration

Create a new server block. On Ubuntu and Debian, put it in /etc/nginx/sites-available/. On RHEL and CentOS, use /etc/nginx/conf.d/. The file locations differ, but the directives are identical.

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

    location / {
        proxy_pass http://localhost:3000;
    }
}

On Ubuntu/Debian, symlink it into sites-enabled to activate it. That’s the whole skeleton: listen on port 80, match a domain with server_name, and forward everything in the location block to your backend with proxy_pass.

The Trailing Slash Gotcha You Must Know

This one bit me hard early on, and I’ve watched it bite half the people I’ve helped since. The trailing slash on proxy_pass completely changes how Nginx forwards the path.

Without a trailing slash — proxy_pass http://localhost:3000; — a request to /app/health reaches your backend as /app/health. The full path is preserved.

With a trailing slash — proxy_pass http://localhost:3000/; — that same request reaches your backend as /health. The location prefix gets stripped off.

Neither is wrong. They’re tools for different jobs. Just know which one you’re using, because a misplaced slash will have you staring at 404s wondering why your routes vanished.

Step 2 — Pass the Right Headers to Your Backend

Out of the box, your backend has no idea who the real client is or what domain they typed. Nginx hides all that unless you explicitly pass it along. Here’s the header set I add to every reverse proxy I build:

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

Let me break down why each one matters:

  • Host $host: Tells your backend the original domain. Skip this and Nginx sends host=127.0.0.1, so your app generates broken absolute URLs in emails, redirects, and OAuth callbacks.
  • X-Real-IP $remote_addr: Passes the client’s real IP, instead of your server seeing every request as coming from itself.
  • X-Forwarded-For $proxy_add_x_forwarded_for: Appends the full chain of client IPs — useful behind multiple proxies.
  • X-Forwarded-Proto $scheme: The big one. Without it, your app thinks every request is plain HTTP, fires a 301 redirect to HTTPS, Nginx forwards it again as HTTP, and you get a beautiful infinite redirect loop.

That redirect loop is the single most common SSL-termination headache I see. If your site is stuck in “too many redirects,” check this header first.

Step 3 — Enable HTTPS with SSL Termination

SSL termination means Nginx does all the TLS heavy lifting. Your backend keeps talking plain HTTP on the loopback interface, blissfully unaware that the outside world is encrypted. No cert handling in your app code, and you manage certificates in exactly one place.

The easiest way to get a free, auto-renewing certificate is to install Let’s Encrypt with Certbot — I’ve covered that in detail separately. Certbot can even modify your Nginx config for you. Here’s roughly what the result looks like:

# Redirect all HTTP to HTTPS
server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

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

Notice the backend still sits on port 3000 over plain HTTP. Only Nginx faces the internet, wearing the HTTPS hat. That separation is the whole point.

Step 4 — Proxy WebSocket Connections (If Your App Needs It)

This is the part most tutorials skip entirely, and it’s where I lost an entire evening once debugging a Socket.io dashboard that just… hung. The problem: WebSocket needs an HTTP/1.1 upgrade handshake, but Nginx defaults to HTTP/1.0 when talking to upstreams. HTTP/1.0 can’t do the upgrade.

The fix is three directives inside your location block:

location / {
    proxy_pass http://localhost:3000;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';

    proxy_set_header Host $host;
}

You need this for Socket.io apps, real-time dashboards, chat apps, and a lot of modern dev tooling. Without these directives, WebSocket connections silently fail or hang forever with no useful error. If your real-time features work locally but die behind the proxy, this is almost always why.

Step 5 — Harden the Reverse Proxy Config

A reverse proxy is a security boundary, so let’s not leave the door propped open. These are the hardening directives I never ship without:

server_tokens off;
proxy_hide_header X-Powered-By;

proxy_connect_timeout 10s;
proxy_send_timeout    30s;
proxy_read_timeout    30s;
  • server_tokens off: Hides the Nginx version from the Server header and error pages. Attackers can’t target version-specific CVEs they can’t see.
  • proxy_hide_header X-Powered-By: Stops your backend from leaking its framework (Express, PHP/8.1, and friends).
  • Timeouts: The defaults are 60s. For fast local backends I drop proxy_connect_timeout to 5-10s so failures surface quickly instead of hanging.

The full list of proxy directives lives in the ngx_http_proxy_module reference if you want to go deeper.

Config-level hardening is only half the story. Lock down the network around it too:

For a broader look at securing your box, see my roundup of the best Linux security tools.

Test Your Config and Reload Nginx

Never reload blind. I’ve taken down a service by reloading a config with a typo, and it’s a humbling way to learn this habit. Always test first:

sudo nginx -t                  # validate syntax FIRST
sudo systemctl reload nginx    # graceful reload, no dropped connections
sudo systemctl status nginx    # confirm it's running
curl -I http://app.example.com # quick sanity check

Use reload, not restart. Reload applies the new config gracefully without dropping live connections, while restart kills the process and starts fresh. For a full breakdown of systemctl commands, including enable, disable, and journal inspection, see my dedicated guide.

Bonus: Load Balance Multiple Backends with the upstream Block

Once you’re running more than one copy of your app, Nginx can balance traffic between them. Define an upstream block in the http context, then point proxy_pass at it by name:

upstream myapp {
    least_conn;
    server localhost:3000 max_fails=3 fail_timeout=30s;
    server localhost:3001 max_fails=3 fail_timeout=30s;
    server localhost:3002 weight=2;
}

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

    location / {
        proxy_pass http://myapp;
    }
}

A few knobs worth knowing:

  • Round-robin (default): Nginx alternates evenly between servers.
  • least_conn: Sends each request to the backend with the fewest active connections. I prefer this when response times vary across endpoints.
  • ip_hash: Sticky sessions — the same client always hits the same backend.
  • weight=: Sends proportionally more traffic to beefier servers.
  • max_fails / fail_timeout: Automatic failover when a backend stops responding.

This is exactly the setup that powers most multi-container apps. If you run services with Docker Compose, Nginx out front load-balancing several replicas is a battle-tested pattern. Nginx supports several algorithms documented in the NGINX load balancing documentation.

Frequently Asked Questions

What’s the difference between a forward proxy and a reverse proxy?

A forward proxy acts for the client, hiding who’s making the request. A reverse proxy acts for the server, hiding your backend infrastructure and routing incoming traffic to the right place. Nginx as a reverse proxy is what we built in this guide.

Why is my Nginx reverse proxy causing an infinite redirect loop?

Almost always a missing X-Forwarded-Proto $scheme header. Your app sees HTTP, redirects to HTTPS, Nginx forwards it as HTTP again, and the loop never ends. Add that header in your location block (see Step 2) and reload.

Do I need a trailing slash on proxy_pass?

It depends. Without a trailing slash, Nginx forwards the full path including the location prefix. With one, it strips the prefix first. Pick based on whether your backend expects the prefix, and test with a real request.

Does the backend need its own SSL certificate?

No. With SSL termination, Nginx handles TLS and talks to your backend over plain HTTP on loopback. That’s the beauty of it — one cert, managed in one place, and your app code stays clean.

Where to Go From Here

You’ve now got a reverse proxy that handles SSL, passes the right headers, supports WebSockets, resists fingerprinting, and can load balance across backends. That’s a genuinely production-grade setup, not a toy.

Next, I’d point you toward two things. Once your proxy is live, monitor it — Prometheus and Grafana make it easy to track upstream response times and error rates so you catch problems before your users do. And if you haven’t locked down the rest of the box, my guide to the best Linux security tools is the natural next read.

Got a tricky proxy setup you’re wrestling with? I read every comment and I genuinely enjoy these puzzles — drop yours below, and while you’re here, browse the rest of the Linux guides to keep leveling up your self-hosting game. Happy proxying, and as always — sudo responsibly.

author avatar
Alexa Velinxs
I'm Alexa Velinxs, a cryptocurrency trading expert passionate about demystifying digital assets for both beginners and seasoned investors. Through my writing, I share actionable strategies, market insights, and practical tips to help you navigate the crypto landscape with confidence. Let's explore the future of finance together.
Related Posts