Skip to content
DevOps devops webservers 5 min read

Setting Up an Nginx Reverse Proxy

Most applications you build — a Node.js API, a Python Flask app, a Java Spring Boot service — listen on a port like 3000 or 8080. You should almost never expose that port to the internet directly. Instead, you put Nginx in front as a reverse proxy (a server that sits in front of your app, receives requests from the outside world, and forwards them to your app behind the scenes). This single setup is the most useful deployment skill in all of DevOps: it gives you a clean public URL on port 80/443, lets you add TLS (encryption), and shields your app from raw internet traffic.

This page assumes you already have Nginx installed on Ubuntu 22.04 or 24.04 and an app running locally. If you don’t have Nginx yet, see Installing Nginx first.

Why use a reverse proxy

When your app listens on localhost:3000, only the server itself can reach it. A reverse proxy bridges that gap and gives you a lot more for free.

Without a reverse proxyWith Nginx in front
App exposed on a weird port (:3000)Clean port 80/443 with a real domain
App handles TLS itself (slow, fiddly)Nginx handles TLS — app stays plain HTTP
One app per port, no clean routingRoute many apps by domain or path
App crashes leak ugly errors to usersNginx returns clean error pages
No buffering, slow clients tie up app workersNginx buffers slow connections

When to use this: any time you deploy a web app written in Node, Python, Java, Ruby, Go, or PHP-FPM to a Linux server. When NOT to: for a purely static website (plain HTML/CSS/JS), you don’t need a proxy — let Nginx serve the files directly. See Serving a static site with Nginx for that.

Step 1 — Confirm your app is running

First make sure your app is actually listening on localhost. Here we assume a Node app on port 3000.

curl http://localhost:3000

Output:

Hello from my app on port 3000!

If you want to see which process owns the port, use ss (the modern replacement for netstat):

sudo ss -ltnp | grep :3000

Output:

LISTEN 0 511 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=1842,fd=20))

Tip: Bind your app to 127.0.0.1 (localhost) and not 0.0.0.0. If it listens on 0.0.0.0:3000 the port is reachable from the public internet, completely bypassing Nginx. Lock it to localhost and let only Nginx talk to it.

Step 2 — Write the server block

On Ubuntu, site configs live in /etc/nginx/sites-available/ and are activated by symlinking them into /etc/nginx/sites-enabled/. Create a new file for your app.

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

Paste this server block. Replace myapp.example.com with your domain (or your server’s IP for testing).

server {
    listen 80;
    listen [::]:80;
    server_name myapp.example.com;

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

        # Pass the real client info through to your app
        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;

        # Required for WebSockets (e.g. socket.io, live reload)
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

The proxy_pass line is the heart of it: every request that hits this server block gets forwarded to your app at 127.0.0.1:3000.

Step 3 — Understand the headers (this is the part tutorials skip)

When Nginx forwards a request, your app no longer sees the real visitor — it sees Nginx. These proxy_set_header lines repair that. Without them, your app gets the wrong host, the wrong client IP, and may think every request is plain HTTP.

HeaderWhat it doesWhy your app needs it
Host $hostSends the original domain the user typedSo your app knows which site was requested
X-Real-IP $remote_addrThe visitor’s actual IP addressLogging, rate limiting, geolocation
X-Forwarded-ForFull chain of client + proxy IPsStandard way to trace the real client
X-Forwarded-Proto $schemeWhether the user used http or httpsSo your app builds correct https:// links and cookies
Upgrade / ConnectionAllows the connection to switch to WebSocketWithout these, WebSockets silently fail

The $proxy_add_x_forwarded_for variable is special: it automatically appends the client IP to any existing X-Forwarded-For header, so chained proxies all work correctly. Make sure your app framework is configured to trust the proxy (for example, app.set('trust proxy', 1) in Express, or ForwardedHeaders middleware in ASP.NET) — otherwise it will ignore these headers for security reasons.

Step 4 — Enable the site and test the config

Symlink the file into sites-enabled, then test the syntax before reloading. Always run nginx -t first — a broken config can take down every site on the server.

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t

Output:

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. reload applies the new config with zero downtime, unlike restart which drops connections.

sudo systemctl reload nginx

Step 5 — Open the firewall and verify

If you use UFW (Ubuntu’s simple firewall), allow web traffic:

sudo ufw allow 'Nginx Full'

Now hit your domain (or server IP) on port 80 — no :3000 needed:

curl http://myapp.example.com

Output:

Hello from my app on port 3000!

The request went to Nginx on port 80, which quietly forwarded it to your app on port 3000. That is a working reverse proxy.

Using an upstream block for cleaner config

When you run several copies of your app (or plan to add load balancing), define an upstream block. It names a group of backend servers so you only edit one place.

upstream myapp_backend {
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
}

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

    location / {
        proxy_pass http://myapp_backend;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Nginx will spread requests across both backends round-robin. For health checks and balancing methods, see Nginx load balancing.

Best Practices

  • Bind your app to 127.0.0.1, never 0.0.0.0, so only Nginx can reach it.
  • Always run sudo nginx -t before sudo systemctl reload nginx.
  • Set X-Forwarded-Proto and configure your app to trust the proxy, so HTTPS redirects and secure cookies work correctly.
  • Include the Upgrade and Connection headers if your app uses WebSockets — otherwise live features break with no obvious error.
  • Add TLS with Certbot (sudo certbot --nginx) right after the proxy works, so all traffic is encrypted.
  • Use an upstream block from day one; it makes scaling to multiple app instances a one-line change.
  • Keep app logs and Nginx access logs separate so you can tell a proxy problem from an app problem.
Last updated June 15, 2026
Was this helpful?