
Here’s a situation I’ve run into more times than I can count: you’ve got two web apps running on the same server — maybe a FastAPI backend on port 8000 and a React frontend on port 3000 — and you need them both accessible on the standard web ports (80 and 443) under the same domain. Or maybe you’re running a half-dozen microservices in Docker and want them all behind a single entry point.
That’s where Nginx shines. It’s fast, battle-tested, and powers roughly a third of all websites on the internet. But more importantly for our use case, Nginx as a reverse proxy is one of those skills that separates “I can deploy an app” from “I can run a reliable web service.”
In this tutorial, I’ll walk through setting up Nginx as a reverse proxy from scratch — covering basic proxying, multiple backends, SSL termination, and WebSocket support. Every command here is something I use regularly on the servers I manage for the ICT Division, and I’ve verified each one against a fresh Ubuntu 22.04 install.
What Is a Reverse Proxy (and Why Nginx?)
A reverse proxy sits in front of your backend servers and forwards client requests to them. Think of it as a reception desk at a busy office — visitors walk in, the receptionist figures out who they need to see, and directs them to the right person. The visitor never deals directly with the person they’re visiting.
Why would you want this? A few good reasons:
- Port management — Your backend apps can run on any port internally, while users access everything through ports 80 and 443
- SSL termination — Handle HTTPS at the proxy layer, not in every individual app
- Load balancing — Distribute traffic across multiple backend instances
- Security — Hide your backend architecture from the outside world
- Caching and compression — Offload performance work from your app servers
And Nginx specifically? It’s lightweight (uses about 2.5MB of memory per worker process at idle), handles ten thousand concurrent connections without breaking a sweat, and its configuration syntax, while it has a learning curve, is remarkably consistent once you get the hang of it.
Prerequisites
- A Linux server (Ubuntu 22.04 or similar — I’m using an Ubuntu server for this guide)
- Root or sudo access to install packages
- Basic familiarity with the terminal
- One or more backend applications to proxy traffic to (we’ll use Python’s built-in HTTP server as a test backend)
Step 1 — Install Nginx
On Ubuntu or Debian, installation is straightforward:
sudo apt update
sudo apt install nginx -y
Once installed, Nginx starts automatically. You can verify it’s running:
systemctl status nginx
You should see active (running) in the output. If you point your browser to your server’s IP address, you’ll see the default Nginx welcome page.
Step 2 — Understanding the Configuration Structure
Nginx configuration lives in /etc/nginx/. Here’s the layout that matters:
/etc/nginx/
nginx.conf # Main config file
sites-available/ # Individual site configs (disabled by default)
sites-enabled/ # Symlinks to enabled site configs
conf.d/ # Additional config fragments
The convention is to create a config file in sites-available, then symlink it into sites-enabled. This makes it easy to disable a site without deleting the config:
sudo ln -s /etc/nginx/sites-available/my-site /etc/nginx/sites-enabled/
Step 3 — Basic Reverse Proxy Configuration
Let’s start simple. Say you have a backend running on localhost:8000 and you want it accessible from http://your-server/.
Create a new config file:
sudo nano /etc/nginx/sites-available/reverse-proxy
Add the following:
server {
listen 80;
server_name your-server-ip-or-domain;
location / {
proxy_pass http://localhost:8000;
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 what each line does:
listen 80— Listen on port 80 (HTTP)server_name— Your server’s domain name or IP addressproxy_pass— The URL of your backend. Nginx forwards requests hereproxy_set_header— These headers tell the backend about the original request. Without them, your backend thinks every request came from 127.0.0.1 on HTTP
The X-Forwarded headers are critical. If you’re running a Python web framework like FastAPI or Django behind the proxy and your app doesn’t know the original visitor’s IP, analytics, rate limiting, and logging all break. I covered this in the FastAPI tutorial — your app needs to trust these proxy headers for everything to work right.
Enable the site and reload Nginx:
sudo ln -s /etc/nginx/sites-available/reverse-proxy /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Always run nginx -t (the configuration test) before reloading. It catches syntax errors that would otherwise take your whole site down. I learned this the hard way after spending a stressful afternoon debugging a misplaced semicolon.
Step 4 — Multiple Backend Services Under One Domain
Here’s where Nginx really starts paying off. Say you have three services:
- An API on
localhost:8000 - A dashboard on
localhost:3000 - Static files in
/var/www/blog
You can route them all through one Nginx instance under the same domain:
server {
listen 80;
server_name example.com;
# API backend
location /api/ {
proxy_pass http://localhost:8000/;
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;
}
# Dashboard
location /dashboard/ {
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;
}
# Static blog
location / {
root /var/www/blog;
index index.html;
try_files $uri $uri/ =404;
}
}
Notice the trailing slashes on proxy_pass. When you add a trailing slash to proxy_pass, Nginx strips the matching location prefix from the request path. So a request to /api/users becomes /users when forwarded to the backend. Without the trailing slash, the full path /api/users is passed through. Which one you want depends on how your backend expects the path.
This pattern works beautifully with Docker Compose setups where each service runs in its own container. Pointing Nginx at http://service-name:port (Docker’s internal DNS) instead of localhost makes the proxy configuration portable across environments.
Step 5 — Adding SSL with a Self-Signed Certificate
For production, you’ll want a real certificate from Let’s Encrypt. But for staging or internal services, a self-signed certificate gets the job done:
# Create a directory for the certificates
sudo mkdir -p /etc/nginx/ssl
# Generate a self-signed certificate (valid for 365 days)
sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/nginx/ssl/selfsigned.key -out /etc/nginx/ssl/selfsigned.crt -subj "/CN=your-server-ip-or-domain"
Now update your Nginx config to handle HTTPS:
server {
listen 443 ssl;
server_name your-server-ip-or-domain;
ssl_certificate /etc/nginx/ssl/selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/selfsigned.key;
# Modern SSL settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://localhost:8000;
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;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name your-server-ip-or-domain;
return 301 https://$server_name$request_uri;
}
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Setting up SSL correctly matters especially if you’re handling any kind of sensitive data. If you need to lock down your server more broadly, I wrote about SSH hardening and general Linux security steps that pair well with a properly configured web proxy.
Step 6 — Proxying WebSocket Connections
WebSockets use the Upgrade HTTP header to switch from HTTP to a persistent bidirectional connection. Nginx needs explicit configuration to handle this:
location /ws/ {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket timeout settings
proxy_read_timeout 86400;
}
The key lines are proxy_http_version 1.1 (WebSockets require HTTP/1.1) and the Upgrade and Connection headers. Without these, your WebSocket connections will connect initially but drop after a few seconds.
The proxy_read_timeout 86400 (24 hours) prevents Nginx from closing idle WebSocket connections. Tune this down if you want shorter timeout behavior — 3600 (one hour) is a reasonable middle ground for most applications.
Step 7 — Testing and Troubleshooting
Here’s a quick way to test your reverse proxy setup. Start a simple HTTP server on the backend port:
python3 -m http.server 8000
Then curl the Nginx proxy:
curl -I http://localhost
You should see a 200 response with Nginx in the Server header. If you get a 502 Bad Gateway instead, check these common culprits:
- Backend not running — Verify the backend service is actually listening on the expected port with
ss -tlnp | grep 8000 - Firewall blocking — Check your firewall rules. Nginx needs port 80 and 443 open
- SELinux blocking proxy connections — On RHEL-based systems, SELinux may block Nginx from making outbound connections. Check
ausearch -m avc -ts recent - Permissions — Nginx runs as the
www-datauser. Make sure it has read access to any local files or directories referenced in the config
For more detailed debugging, check the Nginx error log:
sudo tail -f /var/log/nginx/error.log
And keep an eye on your server’s health metrics. Setting up Prometheus and Node Exporter gives you visibility into whether your Nginx proxy is handling traffic without issues.
Bottom Line
Nginx as a reverse proxy is one of those tools that pays for the hour you spend learning it a hundred times over. Whether you’re running a single Flask app, juggling a dozen microservices, or standing up a production API, the pattern is the same: listen on port 80/443, forward to your backend, pass the right headers, and let Nginx handle the infrastructure plumbing.
The configuration I’ve shown here covers about 90% of the reverse proxy setups I encounter in day-to-day work. Once you’re comfortable with the basics, you can explore Nginx’s more advanced features — load balancing across multiple upstream servers, caching static assets at the proxy layer, rate limiting, and access control — all without touching your application code.
If you’re running your backend services in Docker, the combination of Docker Compose and Nginx is especially powerful. My Docker Compose guide shows you how to wire a full stack together, and adding Nginx as a reverse proxy on top of that setup takes about five minutes of configuration.