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.
# /etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com;
location / {
# 들어온 요청을 내부 3000번 백엔드로 그대로 넘긴다
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:
# 설정 문법 검사 (반드시 OK가 떠야 reload)
sudo nginx -t
# 무중단 재적용
sudo systemctl reload nginx
# 응답 헤더 확인 (200 또는 백엔드 상태코드가 떠야 정상)
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.
server {
listen 80;
server_name example.com;
# 정적 프론트엔드 (빌드 결과물 디렉터리)
root /var/www/frontend/dist;
index index.html;
# /api/* → API 서버(3000)
location /api/ {
proxy_pass http://127.0.0.1:3000/; # 끝 슬래시 주의!
}
# /admin/* → 관리자 서버(4000)
location /admin/ {
proxy_pass http://127.0.0.1:4000/;
}
# 그 외 모든 경로 → 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 setting | Request URL | Path that arrives at the backend | What it does |
|---|---|---|---|
proxy_pass http://127.0.0.1:3000; (no slash) | /api/users | /api/users | Forwards the location path as-is |
proxy_pass http://127.0.0.1:3000/; (with slash) | /api/users | /users | Strips 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:
sudo nginx -t && sudo systemctl reload nginx
curl -I http://example.com/api/health # API 백엔드 응답?
curl -I http://example.com/admin/ # 관리자 응답?
curl -I http://example.com/ # index.html 응답?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.
location /api/ {
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;
}What each header decides on the backend
| Header | What the backend uses it for |
|---|---|
Host $host | The request domain the backend sees. Used for multi-domain routing and building redirect URLs |
X-Real-IP $remote_addr | A single real client IP. Used in access logs and block lists |
X-Forwarded-For $proxy_add_x_forwarded_for | The client IP chain through proxies. Used to trace the original IP behind multiple hops |
X-Forwarded-Proto $scheme | Whether 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.
# /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.
location /ws/ {
proxy_pass http://127.0.0.1:3000/;
# WebSocket 핸드셰이크 필수 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.
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.
# certbot 설치 (Ubuntu/Debian 예시)
sudo apt install certbot python3-certbot-nginx
# 인증서 발급 + nginx 설정 자동 수정
sudo certbot --nginx -d example.comRunning certbot --nginx automatically adds a 443 block and an 80→443 redirect to the server block you already wrote.
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:
curl -I https://example.com/api/health # https로 정상 응답?
sudo certbot renew --dry-run # 자동 갱신 시뮬레이션 (에러 없어야 함)Full combined server block
Everything above, merged into one finished config. Paste it as-is and change only the domain, paths, and ports.
# /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
| Symptom | Cause | Fix |
|---|---|---|
/api/users returns 404 | Wrong trailing slash on proxy_pass | Match the slash using the table in Step 2 |
unknown directive "map" | map placed inside server | Move it to the http context (nginx.conf) |
Every IP in logs is 127.0.0.1 | Original-request headers missing | Add X-Real-IP / X-Forwarded-For |
| Infinite redirect over https | Missing X-Forwarded-Proto | Add the header and restart the backend |
| WebSocket connection drops | Upgrade / Connection not set | Apply the WS block from Step 3 |
| Site unreachable because cert expired | Auto-renewal never checked | Run 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.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.