SKDBLOG

DevOps

Nginx Reverse Proxy: Map Your Domain to a Localhost App

Point public HTTP at an app bound to localhost with proxy_pass, the usual Upgrade headers, and the sites-available → sites-enabled symlink dance on Ubuntu or Debian.

Shashikant Dwivedi
4 min read
Nginx Reverse Proxy: Map Your Domain to a Localhost App
DevOps04 MIN

If your app answers on localhost:3000 (or any other port) but visitors still see the wrong thing on your domain, you are one Nginx reverse proxy away from sanity. Nginx terminates HTTP on port 80, matches your server_name, and forwards the request to the process you already started—without opening that app port to the whole internet.

If the box does not have Nginx yet, grab the copy-paste install and UFW lines in the Nginx on Ubuntu — install and firewall snippet first, or walk through the longer install and configure Nginx on your Linux server piece.

When a reverse proxy is the right tool

A reverse proxy terminates TLS, routes hostnames, and can load-balance. Keep upstream apps bound to localhost, and only expose 80/443 at the edge.

The problem

The usual story looks like this:

  • The app runs on the VPS and you can curl it locally.
  • DNS points at the same machine.
  • You still get the default site—or a timeout—because nothing in Nginx forwards port 80 traffic to that process.

You fix that with a server block that proxy_passes to the loopback address your framework chose.

Drop-in server block for HTTP reverse proxy

Paste something like this into a new file under sites-available (adjust server_name, port, and filename):

nginx
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    location / {
        proxy_pass http://localhost:3000;  # Your app's port
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

That is the same shape I reach for before I get fancy with upstream blocks or caching.

What those directives do

Here is the short version:

  1. listen 80 — accept plain HTTP on the standard port.
  2. server_name — only treat requests for these hostnames as this app (add www only if DNS is ready for it).
  3. proxy_pass — forward the request to whatever is listening on that host/port; change 3000 to match your stack.
  4. proxy_http_version 1.1 plus Upgrade / Connection — keeps WebSockets and other upgrade-heavy stacks (think hot reload in dev-heavy frameworks) from silently breaking.
  5. Host — forwards the public hostname your code or cookies might rely on.
  6. proxy_cache_bypass — tells Nginx not to serve a stale cache entry when the client is mid-upgrade.

Enable and reload Nginx on Ubuntu or Debian

Wire the file into Nginx and reload only after a syntax check:

  1. Open the new config:

    bash
    sudo nano /etc/nginx/sites-available/your-app
    
  2. Symlink it into sites-enabled (same ln -s pattern highlighted in the Ubuntu Nginx snippet):

    bash
    sudo ln -s /etc/nginx/sites-available/your-app /etc/nginx/sites-enabled/
    
  3. Validate:

    bash
    sudo nginx -t
    
  4. Apply:

    bash
    sudo systemctl restart nginx
    

If nginx -t complains, fix the typo first—restarting with a broken config is a bad afternoon.

When it still fails

Work through the boring stuff first; it catches ninety percent of surprises:

  • Is the process actually listening on the port in proxy_pass?
  • Does dig or your DNS panel show the domain aimed at this server’s public IP?
  • Is something else already claiming port 80 (sudo ss -tlnp | grep ':80')?
  • What does /var/log/nginx/error.log say right after you reproduce the issue?

HTTPS and multiple apps

  • HTTPS: once HTTP flows end-to-end, layer TLS with Certbot. The site’s Certbot SSL guide follows the same “get a working vhost, then let Certbot rewrite it” flow I use in production.
  • Multiple apps: give each domain its own server block (still reverse-proxying to different localhost ports). For a third time, the bash-only Nginx helper snippet is the fastest place to copy firewall and reload commands without hunting man pages.

Frequently asked questions

What is the difference between sites-available and sites-enabled?

sites-available holds the configs you author. sites-enabled holds symlinks to the subset Nginx should read at startup. That split lets you keep old experiments on disk without activating them.

Why am I seeing 502 Bad Gateway?

Nginx could reach the port but the upstream closed the connection, refused it, or timed out. Verify the app, confirm the port, and read error.log—it prints the real reason (Connection refused vs upstream timeout, for example).

Do I need the Upgrade and Connection headers for every app?

You need them when the stack relies on WebSockets or long-lived upgraded connections. Plain CRUD APIs often tolerate simpler headers, but leaving this trio in place is a safe default for Node-style dev servers.

Can I proxy_pass to a port other than 3000?

Absolutely—swap the port in proxy_pass to match where your process listens. Everything else in the sample block stays the same.

When should I move from this copy-paste block to something fancier?

Reach for extra location blocks, map-driven Connection headers, buffering tweaks, or upstream keepalives when you are tuning latency under load or splitting static assets from API traffic— not on day one.

That is the whole loop: config, symlink, nginx -t, reload, then iterate from logs when something misbehaves. Happy shipping.

Written by Shashikant Dwivedi

Engineer, occasional writer, full-time noticer. Based in Prayagraj, India. New essays land roughly twice a month.

Keep reading

Adjacent essays.

All writing →

The newsletter

New articles in your inbox.

Occasional articles on engineering, tooling, and software development practices. No marketing, no fluff — just the article, when it's ready.

Unsubscribe with one click. Your email never leaves the list.