generated from ttipo/project-base
ff6f757fa4
임시 저장(POST·PUT /api/drafts)은 발송과 같은 본문이라 nginx 도 4MB·90초로 받는다(백엔드도 같은 파서). /api 접근 로그는 쿼리 없이 남겨 메일 검색·받는 사람 추천의 검색어가 요청마다 남지 않게 한다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
15 KiB
Nginx Configuration File
232 lines
15 KiB
Nginx Configuration File
# ==========================================
|
|
# 프론트엔드 nginx — 화면(SPA) 정적 호스팅 + /api 를 백엔드로 넘기는 프록시
|
|
#
|
|
# 같은 주소(origin) 배포 — 브라우저는 화면과 같은 주소의 /api 를 부른다 (docs/mail/api-contract.md 머리말).
|
|
# 그래서 CORS 가 필요 없고 CSP connect-src 'self' 로 충분하다. 운영의 입구는 이 nginx 하나다.
|
|
#
|
|
# ⚠️ 이 파일은 템플릿이다. 컨테이너가 뜰 때 nginx 이미지의 entrypoint 가 이 파일
|
|
# (/etc/nginx/templates/default.conf.template)의 ${NGINX_*} 만 envsubst 로 채워 /etc/nginx/conf.d/default.conf 를 만든다.
|
|
# 값은 nginx-env.envsh 가 루트 env(MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB 등)에서 계산한다.
|
|
# nginx 변수($uri 등)는 NGINX_ 로 시작하지 않아 그대로 남는다 (Dockerfile 의 NGINX_ENVSUBST_FILTER).
|
|
# ==========================================
|
|
|
|
# 요청 추적 — rid 는 백엔드로 넘기는 X-Request-Id 이자 실패 응답의 error.traceId 와 같은 값이다
|
|
log_format main_rid '$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent '
|
|
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
|
|
|
|
# /api 용 — main_rid 와 같되 요청 줄에서 쿼리를 뺀다: 검색어(q) 등이 요청마다 남지 않게 (계약 2절 "받는 사람 추천" — 로그에는 개수만)
|
|
log_format api_rid '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent '
|
|
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
|
|
|
|
server {
|
|
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
|
|
listen 8080;
|
|
server_name localhost;
|
|
|
|
access_log /var/log/nginx/access.log main_rid;
|
|
|
|
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장 경로만 아래 /api 에서 늘린다
|
|
client_max_body_size 1m;
|
|
|
|
# ----------------------------------------
|
|
# 보안 헤더 (화면용)
|
|
#
|
|
# nginx 는 하위 블록에 add_header 가 하나라도 있으면 상위 블록의 add_header 를 물려주지 않는다.
|
|
# (정적 파일 location 의 Cache-Control 때문에 JS·CSS 응답에 아래 헤더가 모두 빠져 있었다)
|
|
# merge 로 상위 것과 합친다 — nginx 1.29.3 이상 (nginx-unprivileged:stable-alpine 은 1.30).
|
|
# /api 는 아래에서 상속을 끈다 — API 응답의 보안 헤더는 백엔드(helmet)가 정한다.
|
|
# ----------------------------------------
|
|
add_header_inherit merge;
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
|
|
|
# Content-Security-Policy — XSS 방어의 핵심 헤더
|
|
#
|
|
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
|
|
# - default-src 'self' : 기본적으로 같은 출처만 허용
|
|
# - style-src 'unsafe-inline' : 웹메일 본문 iframe(srcdoc) 때문이다 (아래). srcdoc 문서는 이 정책을 상속하는데,
|
|
# 그 안에는 앱이 넣는 <style>(본문 기본 서식)과 받은 메일의 style 속성이 있다 — 둘 다 인라인 스타일이다.
|
|
# (메일 원문의 <style> 요소는 서버·클라이언트 정화에서 지운다. 2026-10-27 운영 이미지로 확인:
|
|
# style 속성·data: 이미지 표시, CSP 위반 0건)
|
|
# 앱 화면 자체는 인라인 스타일이 필요 없다 — Vue 의 :style 은 CSSOM 으로 바꿔 CSP 대상이 아니고,
|
|
# 컴포넌트 CSS 는 /assets 의 파일로 나간다
|
|
# - connect-src 'self' : API 는 같은 주소의 /api 다 (같은 주소 배포). API 를 다른 주소로 옮기지 말 것 —
|
|
# CSP 뿐 아니라 쿠키(Path=/api, SameSite)와 출처 검사가 같은 주소를 전제로 한다
|
|
# - img-src data: : 메일 본문의 인라인 이미지(cid → data:)와 빌드가 인라인한 작은 이미지.
|
|
# blob: 은 쓰는 곳이 없어 뺐다 — 미리보기 등으로 createObjectURL 이미지를 쓰게 되면 그때 넣는다
|
|
# - font-src 'self' : 글꼴(Pretendard)은 npm 패키지를 번들에 넣어 /assets 의 파일로 내려준다 — 외부 CDN·data: 글꼴 없음
|
|
# (빌드가 4KB 미만 자산을 data: 로 인라인하는데, Pretendard 조각은 가장 작은 것도 8KB 라 해당 없다)
|
|
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
|
|
#
|
|
# 웹메일 본문 iframe(MailBodyFrame, srcdoc)과의 관계 — 값을 바꿀 때 함께 확인할 것:
|
|
# - srcdoc 문서는 이 정책을 그대로 상속하고, 문서 안 meta CSP
|
|
# (default-src 'none'; img-src data:; style-src 'unsafe-inline')가 그 위에 겹쳐 적용된다 (둘 다 만족해야 한다).
|
|
# - 그래서 img-src 에서 data: 를, style-src 에서 'unsafe-inline' 을 빼면 메일 본문의 인라인 이미지·서식이 깨진다.
|
|
# - srcdoc 은 네트워크로 불러오는 문서가 아니라 frame-src·frame-ancestors 의 영향을 받지 않는다 (frame-src 추가 불필요).
|
|
#
|
|
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
|
|
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
|
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
|
|
|
# ----------------------------------------
|
|
# 압축 (Gzip)
|
|
# ----------------------------------------
|
|
gzip on;
|
|
gzip_vary on;
|
|
gzip_min_length 1024;
|
|
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
|
|
|
|
# ----------------------------------------
|
|
# SPA 라우팅
|
|
# ----------------------------------------
|
|
location / {
|
|
root /usr/share/nginx/html;
|
|
index index.html index.htm;
|
|
try_files $uri $uri/ /index.html;
|
|
|
|
# 정적 자원 캐싱 — 여기의 add_header 는 위 보안 헤더와 합쳐진다 (add_header_inherit merge)
|
|
#
|
|
# Vite 빌드 산출물(/assets/)은 파일명에 내용 해시가 있어 장기 캐싱(immutable)해도 된다.
|
|
# ^~ 라 아래 정규식 location 보다 먼저 정해진다.
|
|
location ^~ /assets/ {
|
|
expires 30d;
|
|
add_header Cache-Control "public, immutable";
|
|
}
|
|
|
|
# 해시가 없는 공개 파일(frontend/public → 루트: favicon.ico 등) — 같은 이름으로 바뀌므로
|
|
# immutable 로 두면 바꿔도 최대 30일 옛 파일이 보인다 → 매번 재검증 (바뀌지 않았으면 304)
|
|
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico|webp)$ {
|
|
add_header Cache-Control "no-cache";
|
|
}
|
|
}
|
|
|
|
# index.html — 직접 요청, "/" 의 index, SPA 폴백(try_files … /index.html)이 모두 내부적으로 여기로 온다.
|
|
# 배포 뒤 브라우저가 옛 index.html 을 캐시에서 꺼내 쓰면 이미 지워진 해시 자산을 부르다 화면이 깨진다 → 매번 서버에 확인하게 한다.
|
|
# no-cache = 저장은 하되 쓰기 전에 재검증 (바뀌지 않았으면 304 로 가볍게 끝난다). 해시 자산의 immutable 은 위에서 그대로 둔다.
|
|
# 보안 헤더는 add_header_inherit merge 로 여기에도 함께 붙는다.
|
|
location = /index.html {
|
|
root /usr/share/nginx/html;
|
|
add_header Cache-Control "no-cache";
|
|
}
|
|
|
|
# ----------------------------------------
|
|
# /api → 백엔드 (같은 주소 배포)
|
|
# ----------------------------------------
|
|
# 백엔드 주소는 요청 때마다 도커 DNS 로 다시 푼다 — 백엔드 컨테이너를 다시 만들어 IP 가 바뀌어도 nginx 를 재시작할 필요가 없다.
|
|
# 주소는 서비스 이름(backend)이 아니라 고유 컨테이너 이름(<PROJECT_NAME>-backend)이다 (compose 가 준다) —
|
|
# 이 컨테이너는 다른 프로젝트와 함께 쓰는 proxy 네트워크에도 붙어 있어, 거기 backend 라는 이름이 있으면 그쪽으로 풀릴 수 있다.
|
|
resolver ${NGINX_LOCAL_RESOLVERS} valid=10s ipv6=off;
|
|
set $backend_upstream ${NGINX_BACKEND_UPSTREAM};
|
|
|
|
# 아래 proxy_* 는 /api 의 모든 location 이 물려받는다.
|
|
# ⚠️ 하위 location 에 proxy_set_header 를 하나라도 쓰면 여기 것을 물려받지 않는다 (add_header 와 같은 규칙)
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Connection "";
|
|
proxy_set_header Host $host;
|
|
# 앞 단계(외부 리버스 프록시)가 보낸 값 뒤에 이어 붙인다 — 덮어쓰지 않는다 (백엔드 TRUST_PROXY 가 홉 수로 읽는다)
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
# 클라이언트가 보낸 값은 버리고 nginx 의 요청 ID 로 바꾼다 → 백엔드 traceId 와 이 nginx 로그의 rid 가 같다
|
|
proxy_set_header X-Request-Id $request_id;
|
|
# Origin·Sec-Fetch-Site(출처 검사)·Idempotency-Key(중복 발송 방지)·Cookie 는 손대지 않고 그대로 넘긴다.
|
|
# X-Forwarded-Proto 도 외부 프록시가 보낸 값을 그대로 둔다 (Secure 쿠키는 백엔드가 NODE_ENV 로 정한다).
|
|
|
|
location ^~ /api/ {
|
|
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다
|
|
add_header_inherit off;
|
|
proxy_pass $backend_upstream;
|
|
# 쿼리 없는 로그 형식 — 하위 location 은 물려받는다. 오류로 넘어가는 이름 있는 location(아래 @api_*)은 따로 적는다
|
|
access_log /var/log/nginx/access.log api_rid;
|
|
|
|
# nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식(JSON)으로 답한다 — 계약 머리말 "프록시가 직접 내는 오류"
|
|
# (= 없이 쓰면 상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 proxy_intercept_errors 가 꺼져 있어 손대지 않는다)
|
|
error_page 413 @api_payload_too_large;
|
|
error_page 500 502 503 504 @api_unavailable;
|
|
|
|
# 헬스체크는 내부 전용 — 바깥에서 오면 없는 경로와 같은 404 (실패하면 DB 오류 문자열 등 원인이 담기기 때문이다).
|
|
# 대소문자·끝 / 를 가리지 않는 백엔드 라우팅에 맞춰 막는다. 내부 확인 방법은 docker 스킬 문서 "운영 환경".
|
|
location ~* ^/api/health(?:/|$) {
|
|
default_type application/json;
|
|
add_header Cache-Control "no-store" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header X-Request-Id $request_id always;
|
|
return 404 '{"success":false,"error":{"code":"RES_001","message":"","traceId":"$request_id"}}';
|
|
}
|
|
|
|
# 메일 첨부 업로드 — 파일당 MAIL_ATTACHMENT_MAX_SIZE_MB + 멀티파트 여유 1MB (nginx-env.envsh).
|
|
# 본문을 다 받은 뒤 백엔드가 메일 엔진에 올리는 시간까지 기다린다 (프론트의 업로드 한도는 5분)
|
|
location ^~ /api/mail-attachments {
|
|
client_max_body_size ${NGINX_MAIL_ATTACHMENT_BODY_LIMIT};
|
|
proxy_read_timeout 150s;
|
|
proxy_pass $backend_upstream;
|
|
}
|
|
|
|
# 일반 파일 업로드 — UPLOAD_MAX_SIZE_MB + 1MB
|
|
location ^~ /api/files {
|
|
client_max_body_size ${NGINX_UPLOAD_BODY_LIMIT};
|
|
proxy_pass $backend_upstream;
|
|
}
|
|
|
|
# 메일 조회·수정·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간 (본문 한도는 기본 1m)
|
|
location ^~ /api/messages {
|
|
proxy_read_timeout 90s;
|
|
proxy_pass $backend_upstream;
|
|
}
|
|
|
|
# 메일 발송(POST /api/messages)은 JSON 본문 4MB — 서식 본문·긴 인용 때문 (계약 2절 413 표, 백엔드도 이 경로와 아래 임시 저장만 4MB 파서).
|
|
# 계약이 정한 고정값이라 env 로 바꾸지 않는다 (나머지 /api 의 1m 도 백엔드 JSON_BODY_LIMIT 고정값과 같다 — 값이 같아 여유를 두지 않는다).
|
|
# 정확히 이 경로만 — /api/messages/{id}(PATCH·DELETE·첨부 내려받기)는 위 location 의 1m 그대로다. 같은 경로의 GET(목록)은 본문이 없다.
|
|
location = /api/messages {
|
|
client_max_body_size 4m;
|
|
proxy_read_timeout 90s;
|
|
proxy_pass $backend_upstream;
|
|
}
|
|
|
|
# 메일 임시 저장 — 처음 저장(POST /api/drafts)·바꿔 저장(PUT /api/drafts/{id})도 JSON 본문 4MB. 본문이 발송과 같은 필드·한도라서다
|
|
# (계약 2절 413 표·"임시 저장", 백엔드도 이 두 요청은 발송과 같은 4MB 파서). 발송과 같은 이유로 env 로 바꾸지 않는다.
|
|
# 조회(GET /api/drafts/{id})는 본문이 없어 같은 블록에 둔다. 저장도 메일 엔진에 만들고·다시 읽고·옛것을 지우는 요청이 이어져 90s 를 기다린다
|
|
location ^~ /api/drafts {
|
|
client_max_body_size 4m;
|
|
proxy_read_timeout 90s;
|
|
proxy_pass $backend_upstream;
|
|
}
|
|
}
|
|
|
|
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
|
|
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끄고 로그 형식(api_rid)도 다시 정한다
|
|
# (내부 이동 뒤에는 이 location 의 access_log 가 쓰인다).
|
|
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(413·502·504 등)이 그대로 나간다 (2026-10-27 확인)
|
|
location @api_payload_too_large {
|
|
add_header_inherit off;
|
|
access_log /var/log/nginx/access.log api_rid;
|
|
default_type application/json;
|
|
add_header Cache-Control "no-store" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header X-Request-Id $request_id always;
|
|
return 200 '{"success":false,"error":{"code":"FILE_002","message":"","traceId":"$request_id"}}';
|
|
}
|
|
|
|
location @api_unavailable {
|
|
add_header_inherit off;
|
|
access_log /var/log/nginx/access.log api_rid;
|
|
default_type application/json;
|
|
add_header Cache-Control "no-store" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header X-Request-Id $request_id always;
|
|
return 200 '{"success":false,"error":{"code":"SYS_001","message":"","traceId":"$request_id"}}';
|
|
}
|
|
|
|
error_page 500 502 503 504 /50x.html;
|
|
location = /50x.html {
|
|
root /usr/share/nginx/html;
|
|
}
|
|
|
|
# 숨김 파일 접근 차단 (/api 는 위 ^~ 로 이 검사를 거치지 않고 백엔드가 판단한다)
|
|
location ~ /\. {
|
|
deny all;
|
|
access_log off;
|
|
log_not_found off;
|
|
}
|
|
}
|