Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions infra/nginx/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# nginx 설정

2026-09-27 이전까지 이 설정은 **서버에만** 있었다. 레포에 없으니 누가 언제 무엇을
바꿨는지 추적할 수 없었고, 서버가 날아가면 복원할 근거도 없었다. 이 디렉토리가 원본이다.

| 파일 | 설치 위치 |
|---|---|
| `stack-up.shop.conf` | `/etc/nginx/sites-available/stack-up.shop` (→ `sites-enabled` 심볼릭 링크) |
| `conf.d-stackup-ratelimit.conf` | `/etc/nginx/conf.d/stackup-ratelimit.conf` |
| `stack-up.shop.conf.orig` | 2026-09-27 하드닝 이전 원본 (참고용, 설치 안 함) |

## 배포는 자동이 아니다

`deploy-app.yml` 은 레포를 `~/stackup` 으로 rsync 할 뿐 `/etc/nginx` 는 건드리지 않는다
(sudo 가 필요하고, nginx 는 다른 사이트와 공유되기 때문). **수동으로 적용한다:**

```bash
sudo cp ~/stackup/infra/nginx/conf.d-stackup-ratelimit.conf /etc/nginx/conf.d/stackup-ratelimit.conf
sudo cp ~/stackup/infra/nginx/stack-up.shop.conf /etc/nginx/sites-available/stack-up.shop
sudo nginx -t # 반드시 먼저. 실패하면 reload 하지 않는다
sudo systemctl reload nginx
```

바꾸기 전 항상 백업하고, `nginx -t` 통과 후에만 reload 한다.

## 이 호스트는 nginx 를 공유한다

`/etc/nginx/nginx.conf` 는 `beontteuk.com`, `udangtang.site`, `udangtangames.cloud`,
`warurulab.site` 와 공유된다. **전역(http 컨텍스트) 설정을 고치면 남의 사이트가 같이 바뀐다.**
그래서 하드닝을 전부 `server` 블록 안으로 한정했다 — `ssl_protocols`, `server_tokens`,
보안 헤더는 server 컨텍스트에서도 재정의된다. rate limit zone 만 http 컨텍스트가 필요해
`conf.d` 로 뺐다(zone 선언은 메모리만 잡고 제한은 `limit_req` 를 붙인 곳에만 걸린다).

## 2026-09-27 하드닝에서 바꾼 것

| 항목 | 이전 | 이후 |
|---|---|---|
| `/ai/` 프록시 | AI 서버가 인터넷에 공개(`/ai/docs`·`/ai/redoc`·`/ai/openapi.json` 포함) | **제거** — 쓰는 곳이 없었다 |
| TLS | 전역 설정이 TLSv1·1.1 허용, 서버가 실제로 수락 | 이 사이트만 **TLSv1.2/1.3** |
| 보안 헤더 | 전무 | HSTS·nosniff·X-Frame-Options·Referrer-Policy·Permissions-Policy |
| rate limit | 없음 | API 20r/s(burst 60), 인증 2r/s(burst 20) |
| 버전 노출 | `nginx/1.18.0 (Ubuntu)` | `server_tokens off` |

바꾸지 않은 것(이미 올바름): `client_max_body_size 25m`(백엔드 25MB 상한과 일치),
`/realtime/` 의 `proxy_buffering off` + 1h 타임아웃(SSE·WS 가 이게 없으면 깨진다).

### `/ai/` 를 지운 근거

- 프론트 운영 번들에 `/ai/` 참조 **0건**
- RealTime 은 도커 네트워크로 직접 연결(`REALTIME_AI_WS_URL=ws://ai:8000/internal/voice/stream`)
- `ai/CLAUDE.md` 가 이 서버를 "내부 통신만, 사용자 인증 없음"으로 설계했다고 명시

즉 순수한 잉여 노출이었다. 나중에 외부에서 AI 서버에 직접 붙어야 한다면 인증을 먼저 설계한다.
14 changes: 14 additions & 0 deletions infra/nginx/conf.d-stackup-ratelimit.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# /etc/nginx/conf.d/stackup-ratelimit.conf
#
# limit_req_zone 은 http 컨텍스트에만 둘 수 있는데, 이 호스트의 nginx.conf 는
# 다른 사이트 4개와 공유된다. 그래서 nginx.conf 를 고치는 대신 conf.d 로 분리했다
# (nginx.conf 가 conf.d/*.conf 를 include 한다).
#
# zone 선언 자체는 다른 사이트에 영향이 없다 — 공유 메모리만 잡을 뿐, 실제 제한은
# limit_req 를 붙인 stack-up.shop 의 location 에만 걸린다.

# 일반 API. 페이지 한 번 열 때 여러 요청이 나가므로 burst 로 흡수한다(사이트 설정 참고).
limit_req_zone $binary_remote_addr zone=stackup_api:10m rate=20r/s;

# 인증 경로. 자격증명 대입·OAuth 남용을 늦춘다.
limit_req_zone $binary_remote_addr zone=stackup_auth:10m rate=2r/s;
129 changes: 129 additions & 0 deletions infra/nginx/stack-up.shop.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# StackUp 운영 nginx 사이트 설정 (stack-up.shop)
#
# 이 파일이 원본이다. 서버의 /etc/nginx/sites-available/stack-up.shop 은 사본이며
# 설치 방법은 infra/nginx/README.md 참고. 2026-09-27 이전까지 이 설정은 서버에만
# 존재했고 레포에 없었다 — 누가 언제 무엇을 바꿨는지 추적할 방법이 없었다.
#
# **중요**: /etc/nginx/nginx.conf 는 이 호스트의 다른 사이트 4개와 공유된다.
# 그래서 전역(http 컨텍스트) 설정은 건드리지 않고, 모든 하드닝을 이 server 블록
# 안으로 한정했다. rate limit zone 만 http 컨텍스트가 필요해
# /etc/nginx/conf.d/stackup-ratelimit.conf 로 분리했다(다른 사이트에 영향 없음 —
# zone 선언은 공유 메모리만 잡고, 실제 제한은 여기 limit_req 가 붙은 곳에만 걸린다).

server {
if ($host = www.stack-up.shop) {
return 301 https://$host$request_uri;
} # managed by Certbot

if ($host = stack-up.shop) {
return 301 https://$host$request_uri;
} # managed by Certbot

listen 80;
server_name stack-up.shop www.stack-up.shop;

return 301 https://$host$request_uri;
}

server {
listen 443 ssl http2;
server_name stack-up.shop www.stack-up.shop;

ssl_certificate /etc/letsencrypt/live/stack-up.shop/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/stack-up.shop/privkey.pem; # managed by Certbot

# 전역 nginx.conf 는 TLSv1·TLSv1.1 까지 켜 두고 있고(다른 사이트와 공유라 못 건드림),
# 서버가 실제로 그 핸드셰이크를 수락하는 것을 확인했다. RFC 8996(2021)이 폐기를 권고했고
# PCI DSS 도 금지한다 — 이 사이트만 1.2 이상으로 올린다.
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;

# 응답에 nginx 버전을 싣지 않는다(전역 설정은 주석 처리돼 있어 켜져 있었다).
server_tokens off;

# ── 보안 헤더 ────────────────────────────────────────────────────────────
# 전부 미설정 상태였다. always 를 붙여야 4xx/5xx 응답에도 붙는다.
# HSTS 는 preload 없이 시작한다 — preload 는 등록 후 되돌리기가 매우 어렵다.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 이 앱은 카메라·마이크를 직접 쓴다(면접 녹화·음성 답변) — self 로 허용하고 나머지는 차단.
add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=()" always;

# 음성 답변 업로드 상한. 백엔드 VoiceAnswerSubmitService.MAX_BYTES(25MB)와 맞춘다 —
# 여기가 더 작으면 백엔드 검증에 닿기도 전에 413 이 난다.
client_max_body_size 25m;

access_log /var/log/nginx/stack-up.shop.access.log;
error_log /var/log/nginx/stack-up.shop.error.log;

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

# OAuth 시작·콜백·토큰 재발급. 자격증명 대입을 늦추되 사용자가 몇 번 다시 눌러도
# 막히지 않을 만큼은 준다.
location ~ ^/api/auth/ {
limit_req zone=stackup_auth burst=20 nodelay;
limit_req_status 429;

proxy_pass http://127.0.0.1:38010;
proxy_http_version 1.1;
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 https;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}

location /api/ {
# 페이지 한 번 열 때 API 가 여러 개 나가므로 burst 를 넉넉히 준다.
# 목적은 정상 사용 제한이 아니라 스크래핑·LLM 할당량 소진 같은 남용 차단이다
# (2026-09-17 에 실제로 게이트웨이 크레딧이 소진돼 운영이 402 로 죽은 적 있다).
limit_req zone=stackup_api burst=60 nodelay;
limit_req_status 429;

proxy_pass http://127.0.0.1:38010;
proxy_http_version 1.1;
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 https;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}

# SSE + WebSocket. 버퍼링을 끄지 않으면 SSE 이벤트가 묶여 늦게 도착한다.
# rate limit 을 걸지 않는다 — 재연결 폭주가 정상 동작인 경우가 있어 끊으면 더 나빠진다.
location /realtime/ {
proxy_pass http://127.0.0.1:38020;

proxy_http_version 1.1;
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 https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}

# location /ai/ 는 제거했다.
#
# AI 서버(FastAPI)를 인터넷에 그대로 노출하고 있었다 — /ai/docs, /ai/redoc,
# /ai/openapi.json 이 공개로 열렸고, 내부 전용 WS(/internal/voice/stream)도
# 인터넷에서 닿았다. ai/CLAUDE.md 는 이 서버를 "내부 통신만, 사용자 인증 없음"
# 으로 설계했다고 적고 있다.
#
# 쓰는 데도 없었다: 프론트 번들에 /ai/ 참조 0건, RealTime 은 도커 네트워크로
# 직접 붙는다(REALTIME_AI_WS_URL=ws://ai:8000/...). 순수한 잉여 노출이었다.

location / {
try_files $uri $uri/ /index.html;
}
}
84 changes: 84 additions & 0 deletions infra/nginx/stack-up.shop.conf.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
server {
if ($host = www.stack-up.shop) {
return 301 https://$host$request_uri;
} # managed by Certbot


if ($host = stack-up.shop) {
return 301 https://$host$request_uri;
} # managed by Certbot


listen 80;
server_name stack-up.shop www.stack-up.shop;

return 301 https://$host$request_uri;




}

server {
listen 443 ssl http2;
server_name stack-up.shop www.stack-up.shop;
ssl_certificate /etc/letsencrypt/live/stack-up.shop/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/stack-up.shop/privkey.pem; # managed by Certbot

client_max_body_size 25m;

access_log /var/log/nginx/stack-up.shop.access.log;
error_log /var/log/nginx/stack-up.shop.error.log;

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

location /api/ {
proxy_pass http://127.0.0.1:38010;

proxy_http_version 1.1;
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 https;

proxy_read_timeout 300s;
proxy_send_timeout 300s;
}

location /realtime/ {
proxy_pass http://127.0.0.1:38020;

proxy_http_version 1.1;
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 https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}

location /ai/ {
proxy_pass http://127.0.0.1:38030/;

proxy_http_version 1.1;
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 https;

proxy_read_timeout 300s;
proxy_send_timeout 300s;
}

location / {
try_files $uri $uri/ /index.html;
}


}
Loading