[feat] deploy,solution/frontend: 공개 진입점을 한 오리진으로 통합 — nginx 가 앱·사이트·API 를 가른다

개발은 Vite 프록시가 `/s`·`/assets` 를 :3001 로 넘겨 한 오리진을 만드는데, 운영에는 그
몫을 받는 자리가 없었다. site.conf 가 정적 서빙 전용이라 `/`(빌더)와 `/v1`(API)이
전부 404 였다 — 실측(w4ai.o2o.kr): DNS·TLS·프록시는 정상인데 우리 404 페이지만 나왔다.

- nginx/site.conf.example: `/`→solution-frontend:3000, `/v1|healthz|docs`→solution-backend:9800
  프록시 추가. `/s/`·`/assets/`·`/fonts/` 는 `^~` 로 잡아 정규식 location 이 못 끼어들게 한다.
  업스트림을 변수+resolver 로 둔 건 기동 시점 이름풀이를 피하려는 것 — 프론트가 아직
  안 떴을 때 nginx 자체가 죽는다. IndexNow 키 파일(`<key>.txt`)도 루트에서 받는다.
- vite.config.ts: allowedHosts 에 VITE_PUBLISH_HOST. Vite 6 는 모르는 Host 를 403
  "Blocked request" 로 막는다 — 프록시는 정상인데 앱만 전부 403 이라 원인이 안 보인다.

nginx -t 통과
This commit is contained in:
Mina Choi 2026-09-01 11:39:32 +09:00
parent 45cc80b60a
commit 36b9e25b92
2 changed files with 73 additions and 19 deletions

View File

@ -1,11 +1,22 @@
# 발행 사이트 정적 서빙.
# 공개 진입점 하나. 사장님 앱 · 발행 사이트 · API 가 **같은 오리진**을 쓴다.
#
# ★ 산출물은 named volume(site-out)으로 들어온다. 프리렌더 컨테이너가 쓰고 여기서 읽기만 한다 —
# / → 사장님 앱(solution-frontend:3000)
# /s/<slug> → 발행 사이트 (site-out 볼륨에서 정적)
# /assets/ → 발행본 공용 번들 (정적)
# /robots.txt · /sitemap.xml → 크롤러가 읽는 파일 (정적)
# /v1/... /healthz → API(solution-backend:9800)
#
# ★ 오리진을 가르지 않는 이유: robots.txt·sitemap.xml 은 RFC 9309 상 **오리진 루트에서만**
# 읽힌다. 앱과 사이트를 다른 호스트에 두면 인증서도 DNS 도 두 벌이 되고 CORS 가 붙는다.
# 개발에서는 Vite 프록시가 같은 일을 한다(solution/frontend/vite.config.ts).
#
# ★ 산출물은 named volume(site-out)으로 들어온다. 프리렌더가 쓰고 여기서 읽기만 한다 —
# 호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
#
# ★ 규칙은 하나뿐이다: 디렉토리 요청 → index.html.
# `/s/butter` (끝 슬래시 없음)가 사장님이 주소창에 치는 형태다. 이게 404 면
# "발행했는데 안 나온다"가 된다.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
@ -18,11 +29,15 @@ server {
# 그게 크롤러에 잡힌다. 빈 경로에 마운트하면 복사될 것이 없다.
root /srv/sites;
# index 지시를 끈다. 오리진 루트에는 페이지가 없다(사이트는 /s/<slug> 아래에 있다).
index index.html;
charset utf-8;
server_tokens off;
client_max_body_size 20m;
# ★ Docker 내장 DNS. 업스트림을 변수로 두면 nginx 가 **기동할 때** 이름을 풀지 않는다 —
# 안 그러면 solution-frontend 가 아직 안 떴을 때 nginx 자체가 죽는다.
resolver 127.0.0.11 valid=10s ipv6=off;
set $builder http://solution-frontend:3000;
set $api http://solution-backend:9800;
# 텍스트 산출물은 압축이 크게 먹는다(HTML 55KB → 10KB 안팎).
gzip on;
@ -34,17 +49,23 @@ server {
application/javascript application/json application/xml
image/svg+xml;
# ── 공용 번들 ──────────────────────────────────────────────
# ── 발행 사이트 ────────────────────────────────────────────
# ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다.
location ^~ /s/ {
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
# 한 번 더 타야 하므로 index.html 을 바로 준다.
try_files $uri $uri/index.html =404;
add_header Cache-Control "public, max-age=300, must-revalidate";
}
# 파일명에 해시가 박혀 있다. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
# ★ expires 와 add_header 를 함께 쓰면 Cache-Control 헤더가 두 줄로 나간다.
# 합쳐서 읽히긴 하지만 의도가 흐려지므로 add_header 하나로 통일한다.
location /assets/ {
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
location /fonts/ {
location ^~ /fonts/ {
add_header Cache-Control "public, max-age=604800";
access_log off;
try_files $uri =404;
@ -55,18 +76,47 @@ server {
# 크롤러가 옛 파일을 계속 본다.
location = /robots.txt {
add_header Cache-Control "public, max-age=300, must-revalidate";
try_files $uri =404;
}
location = /sitemap.xml {
add_header Cache-Control "public, max-age=300, must-revalidate";
try_files $uri =404;
}
# ── 발행 사이트 ────────────────────────────────────────────
# ── API ────────────────────────────────────────────────────
# 앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다.
location ~ ^/(v1/|healthz$|openapi\.json$|docs|redoc) {
proxy_pass $api;
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 $http_x_forwarded_proto;
# 발행·수집 잡은 분 단위다. 기본 60s 면 게이트웨이가 먼저 끊는다.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# IndexNow 키 파일. 프리렌더가 루트에 <key>.txt 를 굽고 검색엔진이 대조한다.
location ~ ^/[A-Za-z0-9_-]{8,128}\.txt$ {
try_files $uri =404;
}
# ── 사장님 앱 (그 외 전부) ─────────────────────────────────
# ★ Vite dev 서버다. Host 헤더를 그대로 넘기므로 vite.config.ts 의 allowedHosts 에
# 발행 호스트가 들어 있어야 한다 — 없으면 전부 403 "Blocked request" 다.
location / {
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
# 한 번 더 타야 하므로 index.html 을 바로 준다.
try_files $uri $uri/index.html =404;
add_header Cache-Control "public, max-age=300, must-revalidate";
proxy_pass $builder;
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 $http_x_forwarded_proto;
# HMR 웹소켓. 없으면 화면은 뜨는데 콘솔이 재연결 실패로 계속 시끄럽다.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
}
# 발행되지 않은 주소. 사장님이 오타를 냈을 때 흰 화면 대신 이유를 보여준다.

View File

@ -19,6 +19,10 @@ export default defineConfig({
},
},
server: {
// ★ Vite 6 는 모르는 Host 헤더를 403 "Blocked request" 로 막는다(DNS rebinding 방어).
// 운영은 앞단 nginx 가 Host 를 그대로 넘기므로 발행 호스트를 여기 넣어야 화면이 뜬다.
// 빠뜨리면 프록시는 정상인데 앱만 전부 403 이다.
allowedHosts: [process.env.VITE_PUBLISH_HOST ?? 'w4ai.o2o.kr'],
// 발행 사이트는 별도 정적 서버(:3001)가 만들고 서빙하지만, 사용자는 admin 과 같은
// origin(:3000)의 `/s/<slug>` 로 접근한다. 프로세스 포트만 분리하고 공개 URL은 하나로 둔다.
proxy: {