You can run an internal API on the same VPS as Nginx and never expose it directly: the API listens on 127.0.0.1 or a private network address, and Nginx is the only thing the internet can reach. This guide builds that private reverse proxy with Nginx, publishes a single route prefix, and closes the backend port to the outside world.
The result is a setup where the API port cannot be scanned from outside, TLS terminates at Nginx, and you control exactly which paths are public.
Before you start
- A Linux VPS running Ubuntu 22.04/24.04, Debian 12, AlmaLinux 8–9 or Rocky 8–9, with root or
sudoaccess. - Nginx installed and running. If you have not done that yet, follow Install Nginx and host your first website.
- An API or app that already starts on the server. This guide assumes it listens on
127.0.0.1:8080; replace that with your real port. - A DNS record such as
api.example.compointing at your server IP. The examples use203.0.113.10. - Familiarity with editing files in
/etc/nginxand reloading the service.
Note: You can check the server IP, reboot it or open a browser console from your VPS provider's control panel if SSH is unavailable. Support is at Tickets or Contact.
How a private reverse proxy with Nginx works
Three things make the backend private:
- The API binds to
127.0.0.1(same host) or to a private interface such as10.0.0.5(separate host on a private network). It never binds to a public IP. - Nginx listens on ports 80 and 443 and forwards requests to that internal address with
proxy_pass. - The firewall allows 80, 443 and SSH only. The API port stays closed to the internet.
If someone requests the API port directly, nothing answers. If they request https://api.example.com/api/..., Nginx handles it and passes it on.
Step 1: Bind the internal API to loopback or a private address
First, check what your API is doing now:
sudo ss -tulpn | grep -E '8080|node|python'If the output shows 0.0.0.0:8080 or *:8080, the API is listening on every interface, including the public one. Change the bind address in your application.
# Gunicorn
gunicorn app:app --bind 127.0.0.1:8080
# Flask development server
flask run --host 127.0.0.1 --port 8080// Node.js / Express
app.listen(8080, '127.0.0.1');For a separate backend host on a private network, bind to the private address instead, for example --bind 10.0.0.5:8080. Restart the app and confirm the new state:
sudo systemctl restart myapi
sudo ss -tulpn | grep 8080You want to see 127.0.0.1:8080 or 10.0.0.5:8080 — not 0.0.0.0.
Warning: Rebinding and restarting the API drops open connections and may interrupt any client currently using it. Do this in a maintenance window if the API serves production traffic.
Step 2: Publish only the API path through Nginx
Create a server block. On Debian and Ubuntu the convention is /etc/nginx/sites-available/; on AlmaLinux and Rocky use /etc/nginx/conf.d/.
sudo nano /etc/nginx/sites-available/api.example.comPaste this and adjust server_name and the backend port:
server {
listen 80;
listen [::]:80;
server_name api.example.com;
access_log /var/log/nginx/api.access.log;
error_log /var/log/nginx/api.error.log;
location /api/ {
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_read_timeout 60s;
}
# Everything else is not published.
location / {
return 404;
}
}Two details matter:
- The trailing slash in
proxy_pass http://127.0.0.1:8080/;strips the/api/prefix before forwarding. A request to/api/usersreaches the backend as/users. Remove the trailing slash if the backend expects the full path. - The catch-all
location /returns 404 so no other route leaks by accident.
Enable the site and reload:
sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxPoint Nginx at an API on a private network
If the backend runs on another machine, use an upstream block so you can add keepalive and more backends later:
upstream internal_api {
server 10.0.0.5:8080;
keepalive 16;
}
server {
listen 80;
server_name api.example.com;
location /api/ {
proxy_pass http://internal_api/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Restrict a route to specific source IPs
Admin routes can stay reachable only from your office or VPN address. Put the allow/deny rules before proxy_pass — Nginx evaluates them in order:
location /api/internal/ {
allow 203.0.113.10;
deny all;
proxy_pass http://127.0.0.1:8080/internal/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}Requests from any other address get a 403 before Nginx contacts the backend.
Step 3: Keep the API port closed with the firewall
On Ubuntu or Debian with UFW:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verboseOn AlmaLinux or Rocky with firewalld:
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-allDo not add a rule for port 8080. That is the point of the whole setup.
Warning: Enable the SSH rule before turning the firewall on, or you will lock yourself out of the server.
If your API runs on a second host behind a private network, the firewall on that host should allow the API port only from the Nginx server's private address.
Step 4: Add TLS and tighten the proxy
Serve the API over HTTPS. The fastest route is a free certificate with Certbot — the full procedure is in Secure Nginx with a free Let's Encrypt SSL certificate. Once the certificate exists, Nginx terminates TLS on 443 and speaks plain HTTP to the loopback backend, which never leaves the machine.
A few settings worth adding to the server block:
server_tokens off;
client_max_body_size 10m;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;client_max_body_sizecaps upload size so a large POST does not fill memory.- The timeouts stop a stalled backend from holding worker connections open.
- If the API is public-facing, add throttling as described in Advanced rate limiting with limit_req.
HTTPS only? Add a permanent redirect from port 80 to 443 using Redirect HTTP to HTTPS in Nginx.
Check it works
From your laptop:
curl -i https://api.example.com/api/healthYou should get HTTP/2 200 or a normal JSON response. Then try the backend port directly from the same machine:
curl -i --max-time 5 http://203.0.113.10:8080/healthThis should time out or be refused. On the server itself:
curl -i http://127.0.0.1:8080/health # works: loopback
sudo ss -tulpn | grep 8080 # shows 127.0.0.1, not 0.0.0.0
sudo tail -f /var/log/nginx/api.access.logThe access log should show your request with the client IP, not 127.0.0.1, if the backend is separate — that confirms X-Forwarded-For is being sent.
Troubleshooting
- 502 Bad Gateway in the browser. Nginx cannot reach the backend. Check that the app is running (
systemctl status myapi), that it listens on the address inproxy_pass(sudo ss -tulpn | grep 8080), and read/var/log/nginx/api.error.log. - 404 for every path under
/api/. The trailing slash inproxy_passis stripping or keeping the prefix differently than the app expects. Test bothproxy_pass http://127.0.0.1:8080/;andproxy_pass http://127.0.0.1:8080;, then reload withsudo nginx -t && sudo systemctl reload nginx. - The API still answers on port 8080 from the internet. Either the app binds to
0.0.0.0(fix it in Step 1) or the firewall has an extra rule. Runsudo ufw statusorsudo firewall-cmd --list-alland remove any rule opening 8080. - Backend logs show
127.0.0.1as the client IP. TheX-Forwarded-ForandX-Real-IPheaders are missing, or the app does not read them. Add the headers to the location block and enable trusted-proxy handling in the framework. - Connection refused only on AlmaLinux or Rocky. SELinux blocks outbound connections from Nginx. Allow them with
sudo setsebool -P httpd_can_network_connect 1. - Long API calls return 504. Raise
proxy_read_timeoutandproxy_send_timeout, and check whether the backend itself is slow.
Next steps
- New to proxying? Start with Set up Nginx as a reverse proxy for any backend app.
- Streaming endpoints need extra headers — see Proxy WebSocket connections through Nginx.
- Running several API instances behind one name? See Load balance backends with Nginx upstream.
- Behind Cloudflare, restore the real visitor IP with Show real visitor IPs in Nginx behind Cloudflare.
