generated from ttipo/project-base
7c70862647
APK 받기가 일반 /api 로 가면 X-Accel-Buffering 이 외부 프록시로 넘어가지 않아 받는 사람마다 임시 파일이 쌓이고, 버퍼링을 끈 받기에 둘 동시 연결 상한도 없다(리뷰). 앱 안 대용량 받기와 같은 location(대소문자를 가리지 않는 정규식 · 같은 그릇)을 두고, CI 의 받기 경로 시험에 변형 · 정보 경로 · 그릇 공유를 더했다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
637 lines
50 KiB
Nginx Configuration File
637 lines
50 KiB
Nginx Configuration File
# ==========================================
|
||
# 프론트엔드 nginx — 화면(SPA) 정적 호스팅 + /api 를 백엔드로 넘기는 프록시
|
||
#
|
||
# 같은 주소(origin) 배포 — 브라우저는 화면과 같은 주소의 /api 를 부른다 (docs/mail/api-contract.md 머리말).
|
||
# 그래서 CORS 가 필요 없고 CSP connect-src 'self' 로 충분하다. 운영의 입구는 이 nginx 하나다.
|
||
#
|
||
# server 가 둘이다 (11단계 — 대용량 첨부의 공개 내려받기, 계약 4절 "공개 호스트"). 어느 쪽인지는 Host 로 가른다 —
|
||
# 외부 리버스 프록시는 두 호스트를 모두 이 8080 으로 넘기며 Host 를 그대로 둔다 (mail/README.md "Nginx Proxy Manager 설정"):
|
||
# 포털 default_server — 웹메일 화면과 /api. 공개 내려받기(/s/ · /api/public-links/)는 공개 호스트를 따로 두면 404 로 닫는다
|
||
# 공개 내려받기 SHARE_PUBLIC_URL 의 호스트 이름 — /s/<토큰>(화면) · 화면 자산 · /api/public-links/ 만 연다. 그 밖은 404
|
||
# 공개 호스트를 따로 두지 않으면(SHARE_PUBLIC_URL 이 비었거나 그 호스트 이름이 CORS_ORIGIN 에 있다) 포털이 둘 다 연다 — nginx-env.envsh
|
||
# 메일 프로그램 자동 설정(13단계) autoconfig. · autodiscover.<MAIL_DOMAIN> — 고정 XML 만 낸다(백엔드로 넘기지 않는다). 파일 끝의 셋째 server
|
||
#
|
||
# ⚠️ 백엔드(Express)는 경로의 대소문자를 가리지 않는다 — /api/Public-Links/… · /api/messages/x/LARGE-FILES/… 도 처리기에 닿는다.
|
||
# 그래서 /api 아래에서 경로로 무엇을 정하는 location(받기의 동시 연결 수 · 버퍼링 끔, 포털의 공개 API 닫기, 조각 본문 한도)은
|
||
# 대소문자를 가리지 않는 정규식(~*)으로 /api/ 블록 안에 둔다 — 대소문자를 가리는 prefix location(^~ /api/messages 등)에 기대면 변형이 비켜 간다
|
||
# (2026-10-30 리뷰 — 받기 limit_conn 을 /FILES/ · /Messages/ 로 비켜 갔다). 백엔드의 대소문자 구분(겹 방어)에는 기대지 않는다.
|
||
# /API/… 처럼 /api/ 자체가 다르면 /api/ 블록에 들지 않아 백엔드로 가지 않는다(포털은 SPA 화면, 공개 내려받기는 404).
|
||
#
|
||
# ⚠️ 이 파일은 템플릿이다. 컨테이너가 뜰 때 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·SHARE_PUBLIC_URL·CORS_ORIGIN 등)에서 계산한다.
|
||
# nginx 변수($uri 등)는 NGINX_ 로 시작하지 않아 그대로 남는다 (Dockerfile 의 NGINX_ENVSUBST_FILTER).
|
||
# 두 server 가 함께 쓰는 location 은 /etc/nginx/snippets/ 의 파일이다(템플릿이 아니라 그대로 쓴다):
|
||
# share-open.conf · share-closed.conf server 단계 — 공개 화면 /s/ (연다 · 닫는다)
|
||
# share-api-open.conf · share-api-closed.conf /api/ 블록 안 — 공개 API /api/public-links/ (연다 · 닫는다)
|
||
# internal-api-closed.conf /api/ 블록 안 — 엔진만 부르는 /api/internal/ (늘 닫는다 — 12단계 엔진 웹훅)
|
||
# api-errors.conf server 단계 — nginx 가 직접 끝내는 /api 오류의 JSON(@api_*)
|
||
# 이 파일은 http 단계에 들어간다(이미지의 /etc/nginx/nginx.conf 가 conf.d 를 http 안에서 읽는다) — 아래 map · proxy_* · gzip 은 두 server 가 물려받는다.
|
||
# ==========================================
|
||
|
||
# 접근 로그 — 쿼리를 남기지 않는다. 검색어(q)가 /api 주소뿐 아니라 화면 주소(/mail/search?q=…)에도 들어간다
|
||
# (계약 2절 "검색"·"받는 사람 추천", 로깅 규칙). 형식은 main_rid 하나이고 server 단계에만 건다 — 모든 location
|
||
# (/api·오류용 이름 있는 location 포함)과 location 을 고르기 전에 거부된 요청(헤더가 너무 큰 400 등)이 물려받는다.
|
||
# - 요청 줄: $request 에서 ? 부터 다음 빈칸 앞까지 뺀다 ("GET /mail/search?q=… HTTP/1.1" → "GET /mail/search HTTP/1.1").
|
||
# $uri 로 다시 짜지 않는다 — SPA 폴백(try_files … /index.html) 뒤에는 $uri 가 /index.html 이라 어느 화면이었는지 사라진다.
|
||
# - Referer: ? 앞까지만 남긴다. 화면 응답이 Referrer-Policy strict-origin 이라(아래 보안 헤더) 브라우저는 같은 주소의 요청에도
|
||
# origin 만 보내지만, 배포 전에 연 탭은 옛 정책(보던 화면 주소를 쿼리째)으로 계속 보내고 브라우저가 아닌 클라이언트는 무엇이든 보낸다.
|
||
# - 내려받기 토큰도 가린다 (11단계 — 계약 4절 "로그"). 토큰을 가진 누구나 파일을 받으므로 로그를 읽는 사람이 받지 못하게 — 요청 줄 · Referer 둘 다,
|
||
# 두 가지로 겹쳐 가린다:
|
||
# (1) 경로 모양 — /s/<…> · /api/public-links/<…> 의 그 칸을 길이와 상관없이 ***(대소문자 무시). 잘린 토큰(몇 글자 모자란 것)도 가린다
|
||
# (2) 토큰 모양 — 토큰은 base64url 43자다(backend modules/file-sharing/share-token.ts). 앞뒤가 [A-Za-z0-9_-] 가 아닌 딱 43자 덩어리,
|
||
# 그리고 %XY(퍼센트 인코딩) 바로 뒤의 43자 덩어리를 *** 로 — 경로 모양이 어긋나도(//s/T · /s//T · /./s/T · /%73/T · /x/../s/T ·
|
||
# /api//public-links/T · /api/public%2Dlinks/T · /api/public-links%2FT — nginx 는 정규화해 받지만 로그는 받은 그대로다) 가린다.
|
||
# 43자가 아닌 값(UUID 36자 · 메일 id · 엔진의 첨부 id 처럼 더 긴 것)은 가리지 않는다. 요청 줄은 두 번 본다(한 줄에 토큰이 둘이어도)
|
||
# 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴(/s/AbC%44ef…)은 가리지 못한다 — 그 토큰을 가진 본인만 만들 수 있는 꼴이다
|
||
# - 값이 없으면 지금처럼 "-" 다 (map 의 정규식은 빈 값에는 돌지 않아 "" 를 따로 적는다).
|
||
# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째(토큰째) 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그").
|
||
map $request $log_request_unqueried {
|
||
"" "-";
|
||
"~^(?<log_request_head>[^?]*)\?[^ ]*(?<log_request_tail>.*)" $log_request_head$log_request_tail;
|
||
default $request;
|
||
}
|
||
|
||
map $log_request_unqueried $log_request_pathmasked {
|
||
"~*^(?<log_path_head>[^ ]+ (?:https?://[^/ ]+)?/(?:s|api/public-links)/)[^/ ]+(?<log_path_tail>.*)$" $log_path_head***$log_path_tail;
|
||
default $log_request_unqueried;
|
||
}
|
||
|
||
map $log_request_pathmasked $log_request_tokenmasked {
|
||
"~^(?<log_token1_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_token1_tail>.*)$" $log_token1_head***$log_token1_tail;
|
||
default $log_request_pathmasked;
|
||
}
|
||
|
||
map $log_request_tokenmasked $log_request_line {
|
||
"~^(?<log_token2_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_token2_tail>.*)$" $log_token2_head***$log_token2_tail;
|
||
default $log_request_tokenmasked;
|
||
}
|
||
|
||
map $http_referer $log_referer_unqueried {
|
||
"" "-";
|
||
"~^(?<log_referer_head>[^?]*)\?" $log_referer_head;
|
||
default $http_referer;
|
||
}
|
||
|
||
map $log_referer_unqueried $log_referer_pathmasked {
|
||
"~*^(?<log_referer_path_head>(?:https?://[^/]+)?/(?:s|api/public-links)/)[^/]+(?<log_referer_path_tail>.*)$" $log_referer_path_head***$log_referer_path_tail;
|
||
default $log_referer_unqueried;
|
||
}
|
||
|
||
map $log_referer_pathmasked $log_referer {
|
||
"~^(?<log_referer_token_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_referer_token_tail>.*)$" $log_referer_token_head***$log_referer_token_tail;
|
||
default $log_referer_pathmasked;
|
||
}
|
||
|
||
# 새 메일 확인(GET /api/mail-state — 화면이 보이는 동안 탭마다 1분에 한 번)은 성공(2xx·304)하면 접근 로그를 남기지 않는다 —
|
||
# 로깅 규칙 "반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다". 실패(401·5xx 등, 오류용 이름 있는 location 포함)는 남긴다.
|
||
# 0 이면 server 단계 access_log 의 if= 가 그 줄을 쓰지 않는다. $uri 는 쿼리를 뺀 경로다 (이 요청은 내부 이동이 없다)
|
||
# 실시간 알림(GET /api/mail-events, SSE)은 성공도 남긴다 — 줄은 연결이 끝날 때 하나(브라우저마다 대략 1시간에 한 번 — 1분 확인의 수십분의 1)이고
|
||
# rt= 가 연결이 이어진 시간이다. 스트림이 시작된 뒤에는 어떻게 끝나도(서버의 end · 탭 닫기 · 백엔드가 중간에 끊김) 상태가 200 이라,
|
||
# 200 을 빼면 일찍 끊기는 연결의 흔적까지 사라진다 (2026-10-28 확인). 연결 전 실패(401·429·503·502 등)는 물론 남는다.
|
||
map "$request_method:$uri:$status" $log_access {
|
||
"~^GET:/api/mail-state:(?:2[0-9][0-9]|304)$" 0;
|
||
default 1;
|
||
}
|
||
|
||
# 요청 추적 — rid 는 백엔드로 넘기는 X-Request-Id 이자 실패 응답의 error.traceId 와 같은 값이다
|
||
log_format main_rid '$remote_addr - $remote_user [$time_local] "$log_request_line" $status $body_bytes_sent '
|
||
'"$log_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
|
||
|
||
# Content-Security-Policy — XSS 방어의 핵심 헤더. 두 server(포털 · 공개 내려받기)와 공개 화면(/s/)이 이 값 하나를 쓴다 (map 은 늘 같은 값)
|
||
#
|
||
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
|
||
# - 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)와 출처 검사가 같은 주소를 전제로 한다.
|
||
# 공개 내려받기 화면은 자기 호스트의 /api/public-links/ 만 부른다 — 'self' 그대로 맞다 (계약 4절 "공개 호스트")
|
||
# - 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 의 의미가 사라지므로 피할 것.
|
||
map $host $page_csp {
|
||
default "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'";
|
||
}
|
||
|
||
# ----------------------------------------
|
||
# 대용량 첨부 받기의 동시 연결 수 (11단계) — 공개 받기(/api/public-links/<토큰>/files/…) · 앱 안 받기(/api/messages/<id>/large-files/…)
|
||
# 휴대폰 앱(APK) 받기(/api/app-releases/<플랫폼>/latest/file — 2026-11-08, 계약 1절 "휴대폰 앱 받기")도 같은 꼴로 흘려보내며 앱 안 받기의 그릇을 함께 쓴다
|
||
#
|
||
# 받기는 버퍼링을 끄고 흘려보낸다 — 받는 쪽이 느리면 연결 하나가 nginx 연결 둘(클라이언트 · 백엔드)과 백엔드의 파일을 그동안 잡는다.
|
||
# 토큰을 가진 사람이 느린 받기를 수백 개 열어 이 nginx(포털과 함께)를 멈추지 못하게 센다 — 넘으면 429 → @api_too_many_requests 의 REQ_001 JSON:
|
||
# 공개 받기 클라이언트 주소마다 10 · 공개 받기 전체 400
|
||
# 앱 안 받기 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200 — 휴대폰 앱 받기도 여기서 함께 센다
|
||
# 전체 한도를 둘로 나눈 까닭 — 하나를 함께 쓰면 바깥 주소 몇십 개(IPv6 면 /64 하나)로 공개 받기가 그릇을 채워 직원의 앱 안 받기까지 모두 429 가 된다.
|
||
# 휴대폰 앱 받기를 따로 세지 않는 까닭 — 앱 안 받기처럼 포털에 로그인한 직원의 받기라 막으려는 것(느린 받기 여러 개로 그릇 채우기)이 같고,
|
||
# 그릇(받기 600 개 — 아래 worker_connections 셈)을 늘리지 않는다. APK(30MB 남짓)는 판이 나올 때 직원마다 한 번 받는다 — 사무실 휴대폰이 한꺼번에 받다
|
||
# 대용량 받기와 합쳐 주소마다 30 에 닿으면 넘은 받기만 429(잠시 뒤 다시)다.
|
||
# 세는 것은 처리 중인 요청이다(쉬는 keep-alive 연결은 세지 않는다). 세 location 모두 대소문자를 가리지 않는다(위 머리말).
|
||
#
|
||
# 클라이언트 주소 — 이 nginx 앞에는 늘 외부 리버스 프록시가 있어 $remote_addr 은 프록시다. 프록시는 X-Forwarded-For 에 자기가 본
|
||
# 클라이언트 주소를 이어 붙인다(docker 스킬 "외부 리버스 프록시 요구사항") — 그 마지막 칸을 쓴다. 머리글이 없으면(프록시 없이 바로) 접속 주소.
|
||
# 프록시를 건너뛰고 바로 붙어 머리글을 꾸미면 주소마다 한도는 피하지만 경로마다의 전체 한도(400 · 200)는 그대로 걸린다(8080 은 ACG · proxy 네트워크가 프록시에만 연다).
|
||
# IPv6 는 주소 그대로 센다(백엔드처럼 /64 로 묶지 않는다 — 표기가 여러 꼴이라 map 으로 줄이지 않았다) — 그것도 전체 한도가 막는다.
|
||
# 거절은 접근 로그(429 · 토큰은 가려짐)로 본다 — 오류 로그에는 남기지 않는다(info — 이미지의 error_log 는 notice 부터. 남기면 토큰째 요청 줄이 쏟아진다).
|
||
# ⚠️ 연결 수의 그릇은 이미지 nginx.conf 의 worker_connections 다 — Dockerfile 이 워커마다 4096 으로 올린다(compose 의 nofile 8192).
|
||
# 받기 600 개(연결 1,200) + 실시간 알림(브라우저마다 연결 둘) + 포털 요청이 워커 하나에 몰려도 들어간다
|
||
# ----------------------------------------
|
||
map $http_x_forwarded_for $download_client {
|
||
"~(?:^|,) *(?<download_client_ip>[^ ,]+) *$" $download_client_ip;
|
||
default $remote_addr;
|
||
}
|
||
map $host $download_total_key {
|
||
default all;
|
||
}
|
||
limit_conn_zone $download_client zone=download_public_ip:10m;
|
||
limit_conn_zone $download_client zone=download_app_ip:10m;
|
||
limit_conn_zone $download_total_key zone=download_public_total:1m;
|
||
limit_conn_zone $download_total_key zone=download_app_total:1m;
|
||
limit_conn_status 429;
|
||
limit_conn_log_level info;
|
||
|
||
# ----------------------------------------
|
||
# 원문 요청 대상 검사 (12단계 리뷰 — 두 server 의 server 단계 if 가 쓴다, 400 VAL_001 JSON — nginx-api-errors.conf)
|
||
#
|
||
# 요청 줄의 대상(원문 $request_uri)에 아래가 있으면 location 을 고르기 전에 400 으로 끝낸다:
|
||
# - 경로(? 앞)의 날 역슬래시 \ — nginx 는 \ 를 경로 구분자로 보지 않아 /api/internal\… 이 닫는 정규식 location 에 맞지 않고 /api/ 로 넘어간다
|
||
# - 날 # (어디든) — nginx 는 # 뒤를 버리고 location 을 고른다
|
||
# - 0x80 이상 바이트 (어디든) — 퍼센트 인코딩하지 않은 비 ASCII (NBSP 0xA0 등)
|
||
# 백엔드로는 원문이 그대로 간다. 백엔드의 Express(parseurl)는 원문에 # · 0xA0 같은 글자가 있으면 옛 url.parse 로 다시 읽어 경로의 \ 를 / 로 바꾼다 —
|
||
# 그래서 /api/internal\mail-engine-events#x · /api/health\#… · /api/public-links\… 가 nginx 의 닫기를 비켜 백엔드 처리기에 닿았다(2026-10-31 리뷰).
|
||
# 브라우저는 이런 요청을 보내지 않는다 — 경로의 \ 는 / 로 바꾸고, 비 ASCII 는 퍼센트 인코딩하고, # 뒤(조각)는 보내지 않는다.
|
||
# 쿼리의 \ 는 막지 않는다 — 주소창에 친 검색어처럼 브라우저가 그대로 보낼 수 있고, url.parse 는 ? 앞의 \ 만 바꾼다. %5C(인코딩한 \)는 nginx 가 풀어
|
||
# location 을 고르므로 닫는 정규식들이 [/\x5c] 로 받는다(두 겹). 정규식의 \x5c 는 역슬래시, \x23 은 # 이다(설정 파일의 \ · # 을 피하려고)
|
||
# ----------------------------------------
|
||
map $request_uri $request_target_rejected {
|
||
"~^[^?]*\x5c" 1;
|
||
"~[\x23\x80-\xff]" 1;
|
||
default 0;
|
||
}
|
||
|
||
# ----------------------------------------
|
||
# /api → 백엔드 (같은 주소 배포) — 두 server 가 함께 쓴다
|
||
# ----------------------------------------
|
||
# 백엔드 주소는 요청 때마다 도커 DNS 로 다시 푼다 — 백엔드 컨테이너를 다시 만들어 IP 가 바뀌어도 nginx 를 재시작할 필요가 없다.
|
||
# 주소는 서비스 이름(backend)이 아니라 고유 컨테이너 이름(<PROJECT_NAME>-backend)이다 (compose 가 준다) —
|
||
# 이 컨테이너는 다른 프로젝트와 함께 쓰는 proxy 네트워크에도 붙어 있어, 거기 backend 라는 이름이 있으면 그쪽으로 풀릴 수 있다.
|
||
# (map 은 늘 같은 값이다 — server 마다 set 을 쓰지 않으려고)
|
||
resolver ${NGINX_LOCAL_RESOLVERS} valid=10s ipv6=off;
|
||
map $host $backend_upstream {
|
||
default ${NGINX_BACKEND_UPSTREAM};
|
||
}
|
||
|
||
# 아래 proxy_* 는 두 server 의 /api 가 모두 물려받는다.
|
||
# ⚠️ 하위 location 에 proxy_set_header 를 하나라도 쓰면 여기 것을 물려받지 않는다 (add_header 와 같은 규칙)
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Connection "";
|
||
# 원래 Host 를 그대로 넘긴다 — 두 server(포털 · 공개 내려받기) 모두 이 값을 물려받는다(공개 내려받기 server 에서도 바꾸지 않는다).
|
||
# 백엔드가 이 값의 호스트 이름으로 공개 호스트를 가른다(modules/file-sharing/public-host.ts — 공개 호스트에서는 /api/public-links/ 만).
|
||
# ⚠️ 백엔드는 nginx 가 정규화한 $host 에 기댄다: 소문자 · 포트 없음 · 끝 점 없음, 요청 줄이 절대 주소(absolute-form)면 그 호스트가 이긴다 —
|
||
# 그리고 백엔드로 넘기는 요청 줄은 늘 경로만 있는 origin-form 이다. 이 nginx 를 거치지 않고 백엔드에 닿는 길을 만들거나 다른 프록시를 두면 같은 정규화를 해야 한다
|
||
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·Range·Content-Digest 는 손대지 않고 그대로 넘긴다.
|
||
# X-Forwarded-Proto 도 외부 프록시가 보낸 값을 그대로 둔다 (Secure 쿠키는 백엔드가 NODE_ENV 로 정한다).
|
||
|
||
# ----------------------------------------
|
||
# 압축 (Gzip) — 두 server 공통. 실시간 알림 · 대용량 받기는 각 location 에서 끈다
|
||
# ----------------------------------------
|
||
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;
|
||
|
||
# ==========================================
|
||
# 포털 — 웹메일 화면과 /api (Host 가 공개 내려받기 호스트가 아닌 모든 요청)
|
||
# ==========================================
|
||
server {
|
||
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
|
||
listen 8080 default_server;
|
||
# 이름으로 고르지 않는다(default_server) — 공개 내려받기 server 의 이름과 겹치지 않게 비워 둔다
|
||
server_name _;
|
||
|
||
# 접근 로그 — 형식·조건은 위 map·log_format (쿼리 · 토큰 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다
|
||
access_log /var/log/nginx/access.log main_rid if=$log_access;
|
||
|
||
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장·대용량 조각 경로만 아래 /api 에서 늘린다
|
||
client_max_body_size 1m;
|
||
|
||
# 원문 요청 대상에 날 \(경로) · # · 0x80 이상 바이트 → 400 VAL_001(JSON) — location 을 고르기 전에 (위 http 단계의 map, nginx-api-errors.conf)
|
||
if ($request_target_rejected) {
|
||
rewrite ^ /__request-target-rejected last;
|
||
}
|
||
|
||
# ----------------------------------------
|
||
# 보안 헤더 (화면용)
|
||
#
|
||
# 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;
|
||
# Referer 에는 같은 주소의 요청에도 origin 만 싣는다 (strict-origin) — 화면 주소에 검색어(/mail/search?q=…)와
|
||
# 임시 저장 id(?draft=)가 들어가서, 주소째 보내면 그 화면에서 나가는 요청마다 nginx 오류 로그·외부 리버스 프록시 로그에 남는다.
|
||
# Referer 를 읽는 코드는 없다 — 백엔드 출처 검사(OriginGuard)는 Sec-Fetch-Site·Origin 을 보고, 같은 주소 HTTPS 요청의 Origin 은
|
||
# 이 정책과 상관없이 그대로 붙는다. 다른 사이트로 가는 요청은 전과 같다 (origin 만, HTTPS → HTTP 는 보내지 않는다).
|
||
add_header Referrer-Policy "strict-origin" always;
|
||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||
# 값과 설명은 위 http 단계의 map $page_csp
|
||
add_header Content-Security-Policy $page_csp always;
|
||
|
||
# ----------------------------------------
|
||
# 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";
|
||
}
|
||
|
||
# 대용량 첨부의 공개 화면 /s/<토큰>(11단계). 공개 호스트를 따로 두면 여기서는 404 로 닫고(nginx-share-closed.conf),
|
||
# 따로 두지 않으면 공개 내려받기 server 와 같은 것을 연다(nginx-share-open.conf). 고르는 것은 nginx-env.envsh 의 NGINX_PORTAL_SHARE.
|
||
# 공개 API /api/public-links/ 는 아래 /api/ 블록 안에서 같은 값으로 고른다(share-api-*.conf)
|
||
include /etc/nginx/snippets/share-${NGINX_PORTAL_SHARE}.conf;
|
||
|
||
location ^~ /api/ {
|
||
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다 (안쪽 location 도 이 off 를 물려받는다)
|
||
add_header_inherit off;
|
||
proxy_pass $backend_upstream;
|
||
|
||
# nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식(JSON)으로 답한다 — 계약 머리말 "프록시가 직접 내는 오류"
|
||
# (= 없이 쓰면 상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 proxy_intercept_errors 가 꺼져 있어 손대지 않는다)
|
||
error_page 413 @api_payload_too_large;
|
||
error_page 500 502 503 504 @api_unavailable;
|
||
|
||
# ⚠️ 이 안의 정규식 location 은 아래 prefix location(/api/messages 등)보다 먼저 정해진다 — prefix 에 ^~ 가 없을 때.
|
||
# 받기 경로가 걸리는 /api/messages 는 그래서 ^~ 없이 둔다 (^~ 면 /api/messages/… 가 아래 받기 location 을 비켜 간다)
|
||
|
||
# 엔진만 부르는 /api/internal/(엔진 웹훅 — 12단계)은 바깥에 열지 않는다 — 404 RES_001(JSON), 대소문자를 가리지 않는다.
|
||
# 엔진은 app-network 로 backend 에 바로 붙는다(이 nginx 를 거치지 않는다). 공개 내려받기 server 도 같은 파일을 넣는다
|
||
include /etc/nginx/snippets/internal-api-closed.conf;
|
||
|
||
# 헬스체크는 내부 전용 — 바깥에서 오면 없는 경로와 같은 404 (실패하면 DB 오류 문자열 등 원인이 담기기 때문이다).
|
||
# 대소문자·끝 / 를 가리지 않는 백엔드 라우팅에 맞춰 막는다. 내부 확인 방법은 docker 스킬 문서 "운영 환경".
|
||
# [/\x5c] — %5C(nginx 가 \ 로 풀어 고른다)도 막는다. 날 \ · # · 0x80 이상 바이트는 server 단계에서 400 이다(위 $request_target_rejected)
|
||
location ~* ^/api/health(?:[/\x5c]|$) {
|
||
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"}}';
|
||
}
|
||
|
||
# 공개 API /api/public-links/ (11단계) — 공개 호스트를 따로 두면 404 RES_001 로 닫고(nginx-share-api-closed.conf),
|
||
# 두지 않으면 공개 내려받기 server 와 같은 것을 연다(nginx-share-api-open.conf — 파일 받기는 공개 받기의 동시 연결 수 · 버퍼링 끔). 대소문자를 가리지 않는다
|
||
include /etc/nginx/snippets/share-api-${NGINX_PORTAL_SHARE}.conf;
|
||
|
||
# 대용량 첨부 받기 GET·HEAD /api/messages/{id}/large-files/{fileId} (11단계 — 계약 4절 "우리 웹메일에서 읽기") — 공개 받기와 같게 흘려보낸다
|
||
# (nginx-share-api-open.conf 의 파일 받기 · 아래 휴대폰 앱 받기와 같은 줄들 — 한쪽을 고치면 다른 쪽도):
|
||
# - 동시 연결은 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200 (위 http 단계). 넘으면 429 REQ_001
|
||
# - 버퍼링 · 압축을 끄고 X-Accel-Buffering 을 넘긴다 — 외부 프록시(NPM)도 이 응답을 버퍼에 쌓지 않는다(받는 사람마다 1GB 를 디스크에 쌓지 않게).
|
||
# Range 는 손대지 않고 넘긴다(206 그대로)
|
||
# - proxy_buffer_size 16k — 머리글 버퍼. 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이다(Content-Disposition 하나가 2.5KB) — 기본 4k 로는 빠듯하다
|
||
# 버퍼링을 끈 응답은 본문도 이 크기로 받아 넘긴다(기본 4k 보다 시스템 호출이 적다). proxy_busy_buffers_size 는 nginx 가 함께 맞추라고 하는 값이다 —
|
||
# 안 적으면 2 × 16k = 32k 가 proxy_buffers(8 × 4k)의 "하나 뺀 합" 28k 를 넘어 설정 검사가 멈춘다. 버퍼링을 끈 location 이라 proxy_buffers 는 쓰이지 않는다
|
||
# - 대소문자를 가리지 않는다(머리말). id 칸은 [^/]+ 가 아니라 .+ — nginx 는 %2F 를 / 로 풀어 location 을 고르고 백엔드는 한 칸으로 읽는다
|
||
# (지금은 백엔드의 id 검사가 그 꼴을 404 로 막지만 거기에 기대지 않는다)
|
||
# error_page 를 하나라도 쓰면 위의 것을 물려받지 않아 셋을 다 적는다
|
||
location ~* ^/api/messages/.+/large-files/ {
|
||
limit_conn download_app_ip 30;
|
||
limit_conn download_app_total 200;
|
||
error_page 413 @api_payload_too_large;
|
||
error_page 429 @api_too_many_requests;
|
||
error_page 500 502 503 504 @api_unavailable;
|
||
proxy_buffering off;
|
||
proxy_buffer_size 16k;
|
||
proxy_busy_buffers_size 16k;
|
||
gzip off;
|
||
proxy_pass_header X-Accel-Buffering;
|
||
proxy_read_timeout 90s;
|
||
proxy_pass $backend_upstream;
|
||
}
|
||
|
||
# 휴대폰 앱(APK) 받기 GET·HEAD /api/app-releases/android/latest/file (2026-11-08 — 계약 1절 "휴대폰 앱 받기": 로그인한 직원 · 판마다 30MB 남짓 · Range 206) —
|
||
# 머리글이 대용량 첨부 받기와 같아(backend shared/utils/download-headers.ts — X-Accel-Buffering: no) 위 앱 안 받기와 같은 줄들로 흘려보낸다(한쪽을 고치면 다른 쪽도):
|
||
# - 동시 연결은 앱 안 받기의 그릇(download_app_ip 30 · download_app_total 200)을 함께 쓴다 — 까닭은 http 단계 "동시 연결 수". 넘으면 429 REQ_001
|
||
# - 버퍼링 · 압축을 끄고 X-Accel-Buffering 을 넘긴다 — 외부 프록시(NPM)도 받는 사람마다 APK 를 임시 파일에 받아 두지 않는다
|
||
# - 정보 GET /api/app-releases/android/latest(JSON)는 /file 이 없어 이 location 에 들지 않는다 — 위 /api/ 그대로
|
||
# - 대소문자를 가리지 않는다(머리말). 플랫폼 · 판 칸은 .+ — nginx 는 %XX 를 풀고(%2F 도) 겹 / · .. 를 정리한 뒤 location 을 고른다.
|
||
# 끝은 묶지 않는다 — 백엔드가 받는 꼴(끝의 / 등)이 비켜 가지 않게. 없는 이웃 경로가 들어와도 상한 · 흘려보내기가 더해질 뿐이다
|
||
# 공개 내려받기 server 의 /api/ 는 공개 API 밖을 모두 404 RES_001 로 닫는다 — 이 경로도 거기서는 백엔드에 가지 않는다(백엔드도 공개 호스트에서 막는다)
|
||
# error_page 를 하나라도 쓰면 위의 것을 물려받지 않아 셋을 다 적는다
|
||
location ~* ^/api/app-releases/.+/file {
|
||
limit_conn download_app_ip 30;
|
||
limit_conn download_app_total 200;
|
||
error_page 413 @api_payload_too_large;
|
||
error_page 429 @api_too_many_requests;
|
||
error_page 500 502 503 504 @api_unavailable;
|
||
proxy_buffering off;
|
||
proxy_buffer_size 16k;
|
||
proxy_busy_buffers_size 16k;
|
||
gzip off;
|
||
proxy_pass_header X-Accel-Buffering;
|
||
proxy_read_timeout 90s;
|
||
proxy_pass $backend_upstream;
|
||
}
|
||
|
||
# 대용량 첨부의 조각 올리기 PUT /api/file-uploads/{id}/chunks/{n} (11단계 — 계약 4절 "올리기") — 조각 16MiB(계약 상수 — 마지막 조각만 짧다)
|
||
# + 여유 = 17m 고정. 계약 상수라 env 로 바꾸지 않는다(바꾸려면 계약 · 백엔드 · 화면을 함께). 그 밖의 /api/file-uploads(만들기 · 끝내기 · 상태 · 지우기)는
|
||
# JSON 이라 기본 1m 그대로다. 요청 본문은 nginx 가 다 받아 둔 뒤(proxy_request_buffering 기본 켬 — 느린 연결은 nginx 가 받는다, /tmp/client_temp)
|
||
# 백엔드에 넘긴다 — 백엔드가 디스크에 쓰고 내려쓰기(datasync)까지 기다린다. 백엔드 라우팅처럼 대소문자를 가리지 않는다.
|
||
# 오류 로그는 error 부터 — 16MiB 조각은 늘 메모리 버퍼(client_body_buffer_size)를 넘어 임시 파일로 받는데, nginx 는 그때마다
|
||
# "a client request body is buffered to a temporary file" 을 warn 으로 남긴다(조각마다 한 줄 — 2GB 파일이면 128줄). 버퍼를 키우지 않는다(동시 조각 수 × 16MiB 가 메모리에 앉는다)
|
||
location ~* ^/api/file-uploads/[^/]+/chunks/[^/]+$ {
|
||
client_max_body_size 17m;
|
||
proxy_read_timeout 90s;
|
||
error_log /var/log/nginx/error.log error;
|
||
proxy_pass $backend_upstream;
|
||
}
|
||
|
||
# 메일 첨부 업로드 — 파일당 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).
|
||
# ^~ 를 붙이지 않는다 — 대용량 첨부 받기(/api/messages/{id}/large-files/…)는 위 정규식 location 이 가져가야 한다(위 ⚠️)
|
||
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(목록)은 본문이 없고,
|
||
# 여러 통 PATCH(계약 2절 "여러 통 한꺼번에")는 본문이 작아(id 100개까지) 한도와 상관없다. 메서드는 가리지 않는다.
|
||
location = /api/messages {
|
||
client_max_body_size 4m;
|
||
proxy_read_timeout 90s;
|
||
proxy_pass $backend_upstream;
|
||
}
|
||
|
||
# 여러 통 영구 삭제(POST /api/message-deletions, 계약 2절 "여러 통 한꺼번에") — 백엔드가 메일 엔진에 확인한 뒤 조건부로 지우고,
|
||
# 어긋나면 다시 확인한다(합계 CONDITIONAL_DESTROY_ATTEMPTS). 엔진 응답을 여러 번 기다리므로 /api/messages·/api/drafts 와 같은 90s.
|
||
# 본문 한도는 기본 1m 그대로다 (id 100개까지)
|
||
location ^~ /api/message-deletions {
|
||
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;
|
||
}
|
||
|
||
# 실시간 알림(GET /api/mail-events, 계약 2절 "실시간 알림") — SSE(text/event-stream). 브라우저마다 연결 하나가 액세스 토큰이 끝날 때까지
|
||
# (최대 1시간 — 그 30초 전에 서버가 end 를 보내고 닫는다) 열려 있고, 서버가 25초마다 ping 을 보낸다. 아래 동작은 2026-10-28 가짜 백엔드로 확인했다.
|
||
# - 버퍼링을 끈다: 켜 두면 머리글을 첫 이벤트가 올 때까지 붙잡고, 백엔드가 청크 없이 연결을 닫아 끝내는 응답을 보내면 끝날 때까지 전부 붙잡는다.
|
||
# 백엔드가 보내는 X-Accel-Buffering: no 로도 꺼지지만 그 머리글에 기대지 않는다. 캐시는 지금 어디에도 없다 — 나중에 /api 에 생겨도 이 경로는 빼 둔다.
|
||
# - 압축을 끈다: gzip_types 에 text/event-stream 이 없어 지금도 압축하지 않지만, 목록을 넓혀도 이 경로는 압축하지 않게
|
||
# (백엔드가 Cache-Control: no-transform 으로 바꾸지 말라고 해도 nginx 의 gzip 은 압축한다).
|
||
# - 읽기 시간 제한 90s(다른 90s 경로와 같은 값)는 응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 연결이 1시간 이어져도
|
||
# 25초마다 오는 ping 이 매번 새로 세게 한다. 서버 ping 25초 · 화면 감시 75초보다 길어 정상 연결은 끊지 않는다 (ping 이 멈추면 화면이 먼저 끊는다).
|
||
# 머리글을 기다릴 때도 같은 값이라, 백엔드가 머리글을 보내지 않으면 90초에 504 로 끊는다 (@api_unavailable 의 JSON SYS_001).
|
||
# ⚠️ 서버의 ping 간격(계약 2절)을 바꾸면 이 값도 함께 본다 — ping 간격보다 넉넉히 길어야 한다.
|
||
# 클라이언트 쪽 쓰기 제한(send_timeout 60s)은 쓰기가 막혔을 때만 세므로 그대로 둔다.
|
||
# - X-Accel-Buffering 을 넘긴다: nginx 는 이 머리글을 기본으로 지우는데, 외부 리버스 프록시가 nginx 계열이면 이것을 보고 그쪽도 이 응답만
|
||
# 버퍼링을 끈다 — 기본 설정의 nginx 가 머리글을 첫 이벤트까지 붙잡지 않고 바로 보낸다 (docker 스킬 "외부 리버스 프록시 요구사항").
|
||
# 브라우저는 이 머리글을 쓰지 않는다.
|
||
# 머리글(proxy_set_header — 여기 쓰지 않는다) · 오류 응답(@api_unavailable 등) · 접근 로그는 위 /api 와 server 단계 것을 그대로 물려받는다.
|
||
# 열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다 — 수용량은 워커마다 worker_connections 4096
|
||
# (Dockerfile 이 이미지 기본 1024 에서 올린다 — 대용량 받기와 함께 버티게, 위 http 단계 "동시 연결 수"), 워커 수 auto.
|
||
location ^~ /api/mail-events {
|
||
proxy_buffering off;
|
||
proxy_cache off;
|
||
gzip off;
|
||
proxy_read_timeout 90s;
|
||
proxy_pass_header X-Accel-Buffering;
|
||
proxy_pass $backend_upstream;
|
||
}
|
||
}
|
||
|
||
# /api 의 nginx 오류 응답 — @api_not_found(404 RES_001) · @api_payload_too_large(413 FILE_002) · @api_too_many_requests(429 REQ_001) ·
|
||
# @api_unavailable(5xx SYS_001)
|
||
include /etc/nginx/snippets/api-errors.conf;
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
# ==========================================
|
||
# 공개 내려받기 — 대용량 첨부의 공개 호스트 (11단계 — 계약 4절 "공개 호스트", 운영 files.arasystem.it.kr)
|
||
#
|
||
# 로그인 없이 바깥 사람이 여는 곳이다. 여는 것은 셋뿐이다 — 화면 /s/<토큰>(SPA 의 공개 라우트 — 같은 묶음의 index.html) · 화면 자산(/assets/ · /favicon.ico) ·
|
||
# 공개 API /api/public-links/. 그 밖은 화면 · API 모두 404 — 털린 계정이 퍼뜨린 링크가 포털의 화면 · API 로 이어지지 않게 (백엔드도 같은 판정으로 막는다).
|
||
# 공개 호스트를 따로 두지 않으면 이 server 의 이름은 어느 요청에도 맞지 않는 share.invalid 이고(nginx-env.envsh), 포털이 같은 것을 연다.
|
||
# 포털 쿠키는 호스트 한정(Domain 없음)이라 이 호스트로 오지 않는다.
|
||
# ==========================================
|
||
server {
|
||
listen 8080;
|
||
server_name ${NGINX_SHARE_SERVER_NAME};
|
||
|
||
# 접근 로그 — 포털과 같은 형식(쿼리 없음 · 경로의 토큰은 ***)
|
||
access_log /var/log/nginx/access.log main_rid if=$log_access;
|
||
|
||
# 받기만 한다 — 본문을 받는 경로가 없다
|
||
client_max_body_size 1m;
|
||
|
||
# 원문 요청 대상에 날 \(경로) · # · 0x80 이상 바이트 → 400 VAL_001(JSON) — 포털과 같다 (http 단계의 map)
|
||
if ($request_target_rejected) {
|
||
rewrite ^ /__request-target-rejected last;
|
||
}
|
||
|
||
# 보안 헤더 (화면용) — 포털과 같고 둘이 다르다: Referer 를 아예 보내지 않고(no-referrer — 화면 주소에 토큰이 있다),
|
||
# 검색 엔진에 올리지 않는다(X-Robots-Tag). 공개 API 는 location 에서 상속을 끈다(백엔드가 정한다)
|
||
add_header_inherit merge;
|
||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "no-referrer" always;
|
||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||
add_header Content-Security-Policy $page_csp always;
|
||
|
||
root /usr/share/nginx/html;
|
||
|
||
# 화면 /s/<토큰> (포털이 같은 호스트일 때와 같은 파일)
|
||
include /etc/nginx/snippets/share-open.conf;
|
||
|
||
# 화면 자산 — 포털과 같은 캐시 (해시가 든 /assets/ 는 30일 immutable, 해시가 없는 favicon 은 매번 재검증)
|
||
location ^~ /assets/ {
|
||
expires 30d;
|
||
add_header Cache-Control "public, immutable";
|
||
}
|
||
location = /favicon.ico {
|
||
add_header Cache-Control "no-cache";
|
||
}
|
||
|
||
# API — 공개 API /api/public-links/ 만 백엔드로 넘기고(포털이 같은 호스트일 때와 같은 파일 — 대소문자를 가리지 않는다),
|
||
# 그 밖은 없는 경로와 같은 404 RES_001 JSON (@api_not_found). 본문 한도(1m)를 넘는 요청도 JSON 413 FILE_002 다.
|
||
# add_header 는 여기 두지 않는다 — 두면 안쪽의 백엔드로 넘기는 location 이 물려받는다(JSON 머리글은 @api_* 가 붙인다).
|
||
# return 은 안쪽 location 으로 물려지지 않는다 — 안쪽에 맞은 요청은 백엔드로 간다
|
||
location ^~ /api/ {
|
||
add_header_inherit off;
|
||
error_page 404 @api_not_found;
|
||
error_page 413 @api_payload_too_large;
|
||
# 엔진만 부르는 /api/internal/(12단계) — 아래 return 404 로도 닫히지만 포털과 같은 파일로 따로 닫아 둔다(공개 API 파일이 바뀌어도 열리지 않게)
|
||
include /etc/nginx/snippets/internal-api-closed.conf;
|
||
include /etc/nginx/snippets/share-api-open.conf;
|
||
return 404;
|
||
}
|
||
|
||
# 그 밖의 화면 · 파일 (/ · /index.html · /mail/… 등) — 404
|
||
location / {
|
||
return 404;
|
||
}
|
||
|
||
# API 의 nginx 오류 응답 (포털과 같은 파일)
|
||
include /etc/nginx/snippets/api-errors.conf;
|
||
}
|
||
|
||
# ==========================================
|
||
# 메일 프로그램 자동 설정 (13단계 — mail/README.md "메일 프로그램 자동 설정") — autoconfig.<MAIL_DOMAIN> · autodiscover.<MAIL_DOMAIN>
|
||
#
|
||
# 주소만 치면 메일 프로그램이 서버 · 포트를 찾아 채운다. 고정 응답이다 — 사용자가 보낸 값(질의의 emailaddress · Outlook 이 POST 한 주소)을 되돌려 쓰지 않는다
|
||
# (엔진의 같은 경로는 그 값을 이스케이프 없이 XML 에 넣고, 열지 않은 POP3 · DAV 까지 알려서 쓰지 않는다 — 엔진 443 은 그대로 닫아 둔다).
|
||
# 알리는 것은 IMAP 993(SSL) · SMTP 465(SSL) 둘뿐 — 호스트는 MAIL_HOSTNAME(엔진 인증서의 이름), 사용자 이름은 메일 주소(%EMAILADDRESS% 는 프로그램이 채운다).
|
||
# GET /mail/config-v1.1.xml Thunderbird · Thunderbird for Android(K-9) · Evolution 등 (IETF draft-ietf-mailmaint-autoconfig 의 첫 자리)
|
||
# GET /.well-known/autoconfig/mail/config-v1.1.xml 같은 내용 (그 초안의 둘째 자리 — 원래는 도메인 자체의 주소지만 이 호스트에서도 같게)
|
||
# POST /autodiscover/autodiscover.xml 클래식 Outlook(POX) — 대소문자를 가리지 않는다. LoginName 을 주지 않는다(되돌려 쓰지 않으려고) —
|
||
# 그러면 Outlook 은 @ 앞부분으로 로그인하는데 엔진은 그것도 받는다(IMAP · SMTP · 앱 비밀번호 — 2026-10-31 임시 엔진)
|
||
# 값은 nginx-env.envsh 가 루트 env 의 MAIL_DOMAIN · MAIL_HOSTNAME 에서 만든다 — 비었으면 이 server 의 이름은 어느 요청에도 맞지 않는 autoconfig.invalid 다.
|
||
# 백엔드로 넘기는 것이 없다(/api 도 404). 외부 리버스 프록시는 이 두 호스트를 포털과 같은 8080 으로 넘기고 Host 를 그대로 둔다 — DNS · Proxy Host 는 전환 때
|
||
# (전환 전 DNS 는 옛 서버를 가리킨다 — mail/README.md "메일 프로그램 자동 설정")
|
||
# ==========================================
|
||
server {
|
||
listen 8080;
|
||
server_name ${NGINX_AUTOCONFIG_SERVER_NAMES};
|
||
|
||
# 접근 로그 — 포털과 같은 형식(쿼리 없음 — 질의에 메일 주소가 실린다)
|
||
access_log /var/log/nginx/access.log main_rid;
|
||
# Outlook 이 POST 하는 요청은 1KB 안팎의 XML 이다
|
||
client_max_body_size 64k;
|
||
|
||
default_type text/xml;
|
||
charset utf-8;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "no-referrer" always;
|
||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||
add_header Cache-Control "no-cache" always;
|
||
|
||
location = /mail/config-v1.1.xml {
|
||
return 200 '<?xml version="1.0" encoding="UTF-8"?>
|
||
<clientConfig version="1.1">
|
||
<emailProvider id="${NGINX_MAIL_DOMAIN}">
|
||
<domain>${NGINX_MAIL_DOMAIN}</domain>
|
||
<displayName>${NGINX_MAIL_DOMAIN}</displayName>
|
||
<displayShortName>${NGINX_MAIL_DOMAIN}</displayShortName>
|
||
<incomingServer type="imap">
|
||
<hostname>${NGINX_MAIL_CLIENT_HOST}</hostname>
|
||
<port>993</port>
|
||
<socketType>SSL</socketType>
|
||
<authentication>password-cleartext</authentication>
|
||
<username>%EMAILADDRESS%</username>
|
||
</incomingServer>
|
||
<outgoingServer type="smtp">
|
||
<hostname>${NGINX_MAIL_CLIENT_HOST}</hostname>
|
||
<port>465</port>
|
||
<socketType>SSL</socketType>
|
||
<authentication>password-cleartext</authentication>
|
||
<username>%EMAILADDRESS%</username>
|
||
</outgoingServer>
|
||
</emailProvider>
|
||
</clientConfig>
|
||
';
|
||
}
|
||
|
||
location = /.well-known/autoconfig/mail/config-v1.1.xml {
|
||
rewrite ^ /mail/config-v1.1.xml last;
|
||
}
|
||
|
||
location ~* ^/autodiscover/autodiscover\.xml$ {
|
||
return 200 '<?xml version="1.0" encoding="utf-8"?>
|
||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
||
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
|
||
<Account>
|
||
<AccountType>email</AccountType>
|
||
<Action>settings</Action>
|
||
<Protocol><Type>IMAP</Type><Server>${NGINX_MAIL_CLIENT_HOST}</Server><Port>993</Port><DomainRequired>off</DomainRequired><SPA>off</SPA><SSL>on</SSL><AuthRequired>on</AuthRequired></Protocol>
|
||
<Protocol><Type>SMTP</Type><Server>${NGINX_MAIL_CLIENT_HOST}</Server><Port>465</Port><DomainRequired>off</DomainRequired><SPA>off</SPA><SSL>on</SSL><AuthRequired>on</AuthRequired></Protocol>
|
||
</Account>
|
||
</Response>
|
||
</Autodiscover>
|
||
';
|
||
}
|
||
|
||
location / {
|
||
return 404;
|
||
}
|
||
}
|