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 proxy | With 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 routing | Route many apps by domain or path |
| App crashes leak ugly errors to users | Nginx returns clean error pages |
| No buffering, slow clients tie up app workers | Nginx 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 not0.0.0.0. If it listens on0.0.0.0:3000the 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.
| Header | What it does | Why your app needs it |
|---|---|---|
Host $host | Sends the original domain the user typed | So your app knows which site was requested |
X-Real-IP $remote_addr | The visitor’s actual IP address | Logging, rate limiting, geolocation |
X-Forwarded-For | Full chain of client + proxy IPs | Standard way to trace the real client |
X-Forwarded-Proto $scheme | Whether the user used http or https | So your app builds correct https:// links and cookies |
Upgrade / Connection | Allows the connection to switch to WebSocket | Without 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, never0.0.0.0, so only Nginx can reach it. - Always run
sudo nginx -tbeforesudo systemctl reload nginx. - Set
X-Forwarded-Protoand configure your app to trust the proxy, so HTTPS redirects and secure cookies work correctly. - Include the
UpgradeandConnectionheaders 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
upstreamblock 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.