From 36b9e25b922e5f40b2ebcf4e24fc93599d69750f Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 1 Sep 2026 11:39:32 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20deploy,solution/frontend:=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=EC=A7=84=EC=9E=85=EC=A0=90=EC=9D=84=20=ED=95=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A6=AC=EC=A7=84=EC=9C=BC=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=E2=80=94=20nginx=20=EA=B0=80=20=EC=95=B1=C2=B7?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=C2=B7API=20=EB=A5=BC=20=EA=B0=80?= =?UTF-8?q?=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 개발은 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 키 파일(`.txt`)도 루트에서 받는다. - vite.config.ts: allowedHosts 에 VITE_PUBLISH_HOST. Vite 6 는 모르는 Host 를 403 "Blocked request" 로 막는다 — 프록시는 정상인데 앱만 전부 403 이라 원인이 안 보인다. nginx -t 통과 --- nginx/site.conf.example | 88 +++++++++++++++++++++++++------- solution/frontend/vite.config.ts | 4 ++ 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/nginx/site.conf.example b/nginx/site.conf.example index 9f086c1..6f23320 100644 --- a/nginx/site.conf.example +++ b/nginx/site.conf.example @@ -1,11 +1,22 @@ -# 발행 사이트 정적 서빙. +# 공개 진입점 하나. 사장님 앱 · 발행 사이트 · API 가 **같은 오리진**을 쓴다. # -# ★ 산출물은 named volume(site-out)으로 들어온다. 프리렌더 컨테이너가 쓰고 여기서 읽기만 한다 — +# / → 사장님 앱(solution-frontend:3000) +# /s/ → 발행 사이트 (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/ 아래에 있다). - 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 키 파일. 프리렌더가 루트에 .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; } # 발행되지 않은 주소. 사장님이 오타를 냈을 때 흰 화면 대신 이유를 보여준다. diff --git a/solution/frontend/vite.config.ts b/solution/frontend/vite.config.ts index c6d68d0..58784e4 100644 --- a/solution/frontend/vite.config.ts +++ b/solution/frontend/vite.config.ts @@ -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/` 로 접근한다. 프로세스 포트만 분리하고 공개 URL은 하나로 둔다. proxy: {