Control panels

Setting up a private reverse proxy with Nginx and an internal API

Keep an internal API off the public internet by binding it to loopback or a private address, then let Nginx publish only the routes you choose. Includes firewall, TLS and SELinux notes.

IntermediateLinux server7 min read

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 sudo access.
  • 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.com pointing at your server IP. The examples use 203.0.113.10.
  • Familiarity with editing files in /etc/nginx and 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:

  1. The API binds to 127.0.0.1 (same host) or to a private interface such as 10.0.0.5 (separate host on a private network). It never binds to a public IP.
  2. Nginx listens on ports 80 and 443 and forwards requests to that internal address with proxy_pass.
  3. 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:

Bash
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.

Bash
# Gunicorn
gunicorn app:app --bind 127.0.0.1:8080

# Flask development server
flask run --host 127.0.0.1 --port 8080
JavaScript
// 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:

Bash
sudo systemctl restart myapi
sudo ss -tulpn | grep 8080

You 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/.

Bash
sudo nano /etc/nginx/sites-available/api.example.com

Paste this and adjust server_name and the backend port:

Nginx
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/users reaches 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:

Bash
sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Point 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:

Nginx
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:

Nginx
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:

Bash
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

On AlmaLinux or Rocky with firewalld:

Bash
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-all

Do 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:

Nginx
server_tokens off;

client_max_body_size 10m;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
  • client_max_body_size caps 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:

Bash
curl -i https://api.example.com/api/health

You should get HTTP/2 200 or a normal JSON response. Then try the backend port directly from the same machine:

Bash
curl -i --max-time 5 http://203.0.113.10:8080/health

This should time out or be refused. On the server itself:

Bash
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.log

The 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 in proxy_pass (sudo ss -tulpn | grep 8080), and read /var/log/nginx/api.error.log.
  • 404 for every path under /api/. The trailing slash in proxy_pass is stripping or keeping the prefix differently than the app expects. Test both proxy_pass http://127.0.0.1:8080/; and proxy_pass http://127.0.0.1:8080;, then reload with sudo 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. Run sudo ufw status or sudo firewall-cmd --list-all and remove any rule opening 8080.
  • Backend logs show 127.0.0.1 as the client IP. The X-Forwarded-For and X-Real-IP headers 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_timeout and proxy_send_timeout, and check whether the backend itself is slow.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.