Files
arasystem/frontend/nginx.conf
T
ttipo 7c70862647 feat(docker): 휴대폰 앱 받기 경로에 앱 안 받기와 같은 상한 · 흘려보내기를 둔다
APK 받기가 일반 /api 로 가면 X-Accel-Buffering 이 외부 프록시로 넘어가지 않아 받는 사람마다 임시 파일이 쌓이고,
버퍼링을 끈 받기에 둘 동시 연결 상한도 없다(리뷰). 앱 안 대용량 받기와 같은 location(대소문자를 가리지 않는 정규식 ·
같은 그릇)을 두고, CI 의 받기 경로 시험에 변형 · 정보 경로 · 그릇 공유를 더했다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:29:05 +09:00

637 lines
50 KiB
Nginx Configuration File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ==========================================
# 프론트엔드 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;
}
}