/인프라/Nginx Reverse Proxy Config: Copy-Paste Examples for Routing API, Admin, and Frontend on One Domain
Infrastructurenginx 리버스 프록시proxy_pass 설정

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

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 / {
        # 들어온 요청을 내부 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:

Bash
# 설정 문법 검사 (반드시 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.

Nginx
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 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 백엔드 응답?
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.

Nginx
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

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 핸드셰이크 필수 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 예시)
sudo apt install certbot python3-certbot-nginx

# 인증서 발급 + nginx 설정 자동 수정
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로 정상 응답?
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.

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.

확인 정보
✦ ✦ ✦
편집 검토 · Editorial Review

Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 · ·업데이트 ·

Comments

Be the first to comment.