DevLogTechnical writing
Tutorials

Set up Nginx as a reverse proxy

September 20, 2026

A reverse proxy does a simple, invaluable job: it listens on a public port like 80 or 443, receives the client's request, and hands it to your application running somewhere else — usually on the same machine but on a private port like 3000. The application never talks to the internet directly; it only ever sees requests forwarded by Nginx. That buys you one public entry point, a single place to add TLS later, and the freedom to run several apps on one box without fighting over ports.

In this guide you'll put Nginx in front of a small app listening on localhost:3000, so visiting the proxy's address behaves exactly like visiting the app.

Start an app on a private port

Anything that serves HTTP works here. The point is an app that currently listens on the loopback interface. For a quick test, run Python's built-in server on port 3000:

python3 -m http.server 3000

Leave it running. If you curl http://localhost:3000 you'll get the directory listing. That's your upstream.

Install Nginx

On Ubuntu or Debian:

sudo apt update
sudo apt install nginx

The service starts automatically, and Nginx serves a default page on port 80 while it's running.

Write the site configuration

On Debian-family systems the layout is two directories: /etc/nginx/sites-available/ holds configured-but-disabled site files, and /etc/nginx/sites-enabled/ holds enabled ones. WordPress-style conventions run deep here — Nginx includes a default server block in sites-available, symlinked into sites-enabled. Rather than edit the default, you create your own site file.

Create /etc/nginx/sites-available/mysite with this reverse-proxy server block:

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1: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;
    }
}

proxy_pass is the heart of it: every request that reaches this location is sent on to http://127.0.0.1:3000, and the response is returned to the client. If the address includes a URI, that URI replaces the part of the request matching the location — leave it as a bare address here and Nginx passes the full request URI through unchanged.

The proxy_set_header lines matter for the app's sake. By default Nginx connects to the app from 127.0.0.1, so the app would think every visitor was localhost. Forwarding Host and the client's real IP through X-Real-IP and X-Forwarded-For lets the app see who is actually asking. X-Forwarded-Proto tells it whether the original connection was http or https, which matters once you add TLS.

Enable the site

Create a symlink from sites-available into sites-enabled. Use absolute paths so the link is unambiguous:

sudo ln -s /etc/nginx/sites-available/mysite /etc/nginx/sites-enabled/mysite

If Nginx's default virtual host is still listening on the same port and clashing, you can disable it by removing its symlink from sites-enabled — on Ubuntu that file is /etc/nginx/sites-enabled/default.

Test, then reload

Never skip the check. nginx -t parses every configuration file and reports syntax errors before you change anything live:

sudo nginx -t

You should see syntax is ok and test is successful. Only then apply the change with a graceful reload, which restarts the worker processes without dropping connections:

sudo systemctl reload nginx

Now curl http://localhost/ should return the same content your Python server gives on port 3000. You have a proxy.

Gotchas

What's next

Once the proxy works, add a real domain and TLS. For production traffic you'll switch to listen 443 ssl; with certificates — on Nginx versions older than 1.25.1, write it as listen 443 ssl http2;. With HTTPS in place, your single public endpoint is both the gateway to every app and the single place security lives.

← More Tutorials