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