Home/Infrastructure/Nginx Reverse Proxy Config: Copy-Paste Examples for Routing API, Admin, and Frontend on One Domain
Infrastructureletsencrypt ssl

Nginx Reverse Proxy Config: Copy-Paste Examples for Routing API, Admin, and Frontend on One Domain

Copy-paste examples for splitting an API (:3000), admin app (:4000), and static frontend behind Nginx reverse proxy on a single server. Covers proxy_pass slash rules, WebSocket, Let's Encrypt SSL, 80→443 redirects, and verification commands

Nginx Reverse Proxy Config: Copy-Paste Examples for Routing API, Admin, and Frontend on One Domain
Contents
  1. The backend is fine on port 3000 — wiring it to a domain is the hard part
  2. Step 1. The minimal form: proxy one backend with proxy_pass
  3. Step 2. Split multiple backends by location path
  4. Step 3. Production must-haves: original-request headers + WebSocket
  5. Step 4. Let's Encrypt SSL termination (443) + 80→443 redirect
  6. Common-mistakes checklist
  7. References: official docs
  8. FAQ

Complete Nginx Reverse Proxy Guide: Routing API, Admin, and Frontend on One Domain

The backend is fine on port 3000 — wiring it to a domain is the hard part

Say you've got an API server (:3000), an admin back office (:4000), and a built static frontend all on one VPS. Locally, curl http://127.0.0.1:3000 responds just fine. Then you try to hang it off https://example.com/api and have no idea where to start.

That's what a reverse proxy is for. Nginx accepts incoming 80/443 requests in one place and forwards them to the right internal port based on the path. Bind backend ports to 127.0.0.1 only — don't expose them publicly — and open the firewall for Nginx alone. That's the standard solo-dev / side-project setup these days.

The goal of this post is clear: serve the API, admin, and frontend on a single domain (example.com), including WebSocket and HTTPS, on your own. We'll go copy-paste → confirm it works → understand why.

⚠️ This post is about writing the config correctly. If the config is right but you still get 502 Bad Gateway, the backend is down or the port is wrong — see the separate troubleshooting post (👉 Fixing nginx 502 Bad Gateway). Splitting those roles makes debugging much faster.


Step 1. The minimal form: proxy one backend with proxy_pass

Start with the smallest server block that proxies a single backend. Save it as /etc/nginx/sites-available/example.com and symlink it.

Nginx
# /etc/nginx/sites-available/example.com
server {
    listen 80;
    server_name example.com;

    location / {
 # Request 3000 backend as-is
        proxy_pass http://127.0.0.1:3000;
    }
}

That one line — proxy_pass http://127.0.0.1:3000; — is the whole point. It means "forward the client's request as-is to 127.0.0.1:3000." Nginx sits in the middle as a relay between client and backend.

▸ Why you need this: so the backend is never exposed directly, and Nginx is the single place that accepts traffic.

▸ Verify:

Bash
# settings (when OK reload)
sudo nginx -t

#
sudo systemctl reload nginx

# reply check (200 or backend )
curl -I http://example.com/

If curl -I shows the Server and Content-Type headers the backend sent, the proxy is working.


Step 2. Split multiple backends by location path

Now the main event. Route /api to port 3000, /admin to 4000, and everything else / to the static frontend.

Nginx
server {
    listen 80;
    server_name example.com;

 # ( Result )
    root /var/www/frontend/dist;
    index index.html;

 # /api/* → API (3000)
    location /api/ {
 proxy_pass http://127.0.0.1:3000/; # when !
    }

 # /admin/* → (4000)
    location /admin/ {
        proxy_pass http://127.0.0.1:4000/;
    }

 # path → SPA ( index.html)
    location / {
        try_files $uri $uri/ /index.html;
    }
}

One trailing slash changes the path — the #1 cause of 404s

About 90% of "I pasted it and got a 404 / broken path" incidents come from the slash (/) at the end of proxy_pass. Here's how a request to /api/users arrives at the backend when location /api/ is in play.

proxy_pass settingRequest URLPath that arrives at the backendWhat it does
proxy_pass http://127.0.0.1:3000; (no slash)/api/users/api/usersForwards the location path as-is
proxy_pass http://127.0.0.1:3000/; (with slash)/api/users/usersStrips the matched location prefix (/api) before forwarding

The rule is simple. If proxy_pass includes a URI (including a trailing slash), Nginx strips the part that matched location and appends the rest. If your API's internal routes start at /users, keep the slash. If they're defined as /api/users, drop the slash. Both are valid configs — they just do the opposite thing, which is why this trips people up.

▸ Verify:

Bash
sudo nginx -t && sudo systemctl reload nginx

curl -I http://example.com/api/health # API backend reply?
curl -I http://example.com/admin/ # reply?
curl -I http://example.com/ # index.html reply?

Step 3. Production must-haves: original-request headers + WebSocket

This already works, but from the backend's point of view every request came from 127.0.0.1 (Nginx). Real client IP, original host, and whether it was http or https are all gone. You have to pass those through as headers.

Nginx
location /api/ {
    proxy_pass http://127.0.0.1:3000/;

 # ( required)
    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;
}

What each header decides on the backend

HeaderWhat the backend uses it for
Host $hostThe request domain the backend sees. Used for multi-domain routing and building redirect URLs
X-Real-IP $remote_addrA single real client IP. Used in access logs and block lists
X-Forwarded-For $proxy_add_x_forwarded_forThe client IP chain through proxies. Used to trace the original IP behind multiple hops
X-Forwarded-Proto $schemeWhether the client came in over http or https. Used for HTTPS detection and redirect URL decisions

Without these headers, every user shows up as 127.0.0.1 in backend logs, and a client that arrived over https can be treated as http — which produces redirect loops. I've spent days on an "infinite redirect after login" bug that turned out to be a missing X-Forwarded-Proto. Put these four lines in as a set from day one.

WebSocket proxy (chat, notifications, HMR dev servers)

WebSocket needs a handshake that upgrades a normal HTTP request via Upgrade. Nginx does not forward those headers to the backend by default, so you have to set them explicitly. First add a map in the http context.

Nginx
# /etc/nginx/nginx.conf http { }
# (server http !)
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Then pass the Upgrade headers on the WebSocket endpoint location.

Nginx
location /ws/ {
    proxy_pass http://127.0.0.1:3000/;

 # WebSocket required 3
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

 #
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

▸ Why you need this: without Upgrade/Connection, the HTTP connection never gets promoted to WebSocket (101 Switching Protocols).

▸ Verify: You can check the handshake response (101) directly with curl.

Bash
curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \
  -H "Sec-WebSocket-Version: 13" \
  http://example.com/ws/
# → HTTP/1.1 101 Switching Protocols

Step 4. Let's Encrypt SSL termination (443) + 80→443 redirect

Last piece: HTTPS. The certbot nginx plugin is the de facto standard — it issues the cert and injects the config in one shot.

Bash
# certbot (Ubuntu/Debian example)
sudo apt install certbot python3-certbot-nginx

# certificate + nginx settings
sudo certbot --nginx -d example.com

Running certbot --nginx automatically adds a 443 block and an 80→443 redirect to the server block you already wrote.

Nginx
server {
 listen 443 ssl http2; # HTTP/2
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
 include /etc/letsencrypt/options-ssl-nginx.conf; # TLS 1.3
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

 # ... ( 2~3 location )
}

server {
    listen 80;
    server_name example.com;
 return 301 https://$host$request_uri; # http → https
}

▸ Verify + check auto-renewal:

Bash
curl -I https://example.com/api/health # https reply?
sudo certbot renew --dry-run # when

Full combined server block

Everything above, merged into one finished config. Paste it as-is and change only the domain, paths, and ports.

Nginx
# /etc/nginx/nginx.conf http { }
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# 80 : https
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

# 443 :
server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    root /var/www/frontend/dist;
    index index.html;

 #
    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;

 # API
    location /api/ {
        proxy_pass http://127.0.0.1:3000/;
    }

 #
    location /admin/ {
        proxy_pass http://127.0.0.1:4000/;
    }

    # WebSocket
    location /ws/ {
        proxy_pass http://127.0.0.1:3000/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }

 # (SPA)
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Common-mistakes checklist

SymptomCauseFix
/api/users returns 404Wrong trailing slash on proxy_passMatch the slash using the table in Step 2
unknown directive "map"map placed inside serverMove it to the http context (nginx.conf)
Every IP in logs is 127.0.0.1Original-request headers missingAdd X-Real-IP / X-Forwarded-For
Infinite redirect over httpsMissing X-Forwarded-ProtoAdd the header and restart the backend
WebSocket connection dropsUpgrade / Connection not setApply the WS block from Step 3
Site unreachable because cert expiredAuto-renewal never checkedRun certbot renew --dry-run regularly

💡 If the config is correct but you still get 502 Bad Gateway, that is not an Nginx problem — it means the backend (3000/4000) is down or the port is wrong. The diagnostic steps are covered separately in Fixing nginx 502 Bad Gateway. Remember this post as "writing the config" and that one as "diagnosing errors" and you'll debug faster.


References: official docs

The primary source for the behavior, settings, and errors in this post is the official documentation below. Check version-specific options and exact behavior there.

FAQ

Q. Should I put a slash on proxy_pass, or leave it off? A. It depends on how the backend routes are defined. If the backend receives routes at /users, put the slash on location /api/ (...:3000/) so /api is stripped before forwarding. If the backend is defined as /api/users, leave the slash off so the path is forwarded as-is.

Q. I put the map block inside server and got an error. A. map is an http-context-only directive. Declare it once in the http { } block of /etc/nginx/nginx.conf, not inside server { }. It is shared across all server blocks.

Q. Do I have to renew Let's Encrypt certificates manually? A. No. On install, certbot registers auto-renewal via a systemd timer or cron. Periodically check that it still works with sudo certbot renew --dry-run. The plugin also reloads Nginx after renewal.

✦ ✦ ✦
Sources and verification

Sources and checks vary by article. Only articles with a recorded check date, scope and sources below were checked, and only within that scope. For other articles, verify against the current docs for your version before applying anything, and email us about errors. Editorial policy · email us

This article has no recorded human check (date and scope) yet.

Published ·
Topic-related official docs (auto-linked · not per-claim sources)NGINX documentation ↗OpenSSL documentation ↗

Comments

Be the first to comment.