generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정
웹메일 1단계를 개발 환경에서 띄우고 운영에 올리기 위한 인프라 구성이다. - 메일 엔진 Stalwart(v0.16.27)와 개발용 Mailpit 을 compose profile `mail` 로 추가한다. 초기 설정은 mail/scripts/setup.mjs 가 맡는다 — 여러 번 실행해도 안전하고, 운영에서는 사전 검사가 실패하면 아무것도 바꾸지 않고 멈춘다 - 화면과 API 를 한 주소로 배포한다: 프론트 nginx 가 /api 를 백엔드로 넘기고, 본문 한도·시간 제한·헤더 전달을 env 와 맞춘다. nginx 가 직접 내는 오류도 표준 실패 형식이다 - 인터넷을 향한 nginx 는 edge 네트워크로 백엔드에만 닿고, DB·메일 엔진은 app-network 에 둔다 (메일 엔진의 신뢰 대역도 app-network 뿐) - Node 24.22.1·npm 11.6.2·nginx 1.30.6 을 Dockerfile 과 CI 에 한 값으로 고정한다 - 개발 호스트 포트는 127.0.0.1 로 한정하고, data/ 는 클론 때부터 사용자 소유가 되게 .gitkeep 을 추적한다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# ==========================================
|
||||
# 이미지 빌드 컨텍스트 제외 목록 (frontend)
|
||||
#
|
||||
# 이미지에는 소스와 lock 파일만 들어가야 한다. 산출물은 이미지 안에서 `npm ci` → `vite build` 로 만든다.
|
||||
#
|
||||
# node_modules : 호스트(Windows)에서 설치한 node_modules 가 `COPY . .` 로 들어가면
|
||||
# `npm ci` 가 설치한 Linux 용 의존성(rollup·esbuild 등 네이티브 바이너리)을 덮어쓴다.
|
||||
# dev 의 익명 볼륨(/app/node_modules)도 이미지 내용으로 초기화되므로 같은 문제가 생긴다.
|
||||
# dist : 이미지 안에서 다시 빌드한다 — 호스트 산출물이 섞이면 어느 소스로 만든 번들인지 알 수 없다.
|
||||
# .env* : VITE_ 값은 번들에 그대로 박히고 이미지 레이어에도 남는다.
|
||||
# API 주소는 같은 주소 배포라 build arg VITE_API_BASE_URL=/api 로 고정이다 (Dockerfile · compose).
|
||||
# ==========================================
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.log
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
.vscode
|
||||
.idea
|
||||
__screenshots__
|
||||
@@ -1,15 +1,16 @@
|
||||
# ==========================================
|
||||
# 프론트엔드 환경변수 (development)
|
||||
#
|
||||
# ⚠️ VITE_API_BASE_URL 은 루트 .env.development 의 BACKEND_API_URL 이
|
||||
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
|
||||
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
|
||||
# 보통 이 파일은 필요 없다.
|
||||
# - API 주소는 같은 주소(origin)의 /api 다 (docs/mail/api-contract.md 머리말).
|
||||
# 개발은 vite 프록시가 /api 를 백엔드로 넘긴다 — 포트·대상은 루트 .env.development 의
|
||||
# FRONTEND_PORT·BACKEND_PORT 를 vite.config.ts 가 직접 읽는다 (SSOT: 루트 .env — .claude/rules/env-structure.md).
|
||||
# - 운영 이미지는 compose 가 VITE_API_BASE_URL=/api 로 고정해 빌드한다.
|
||||
#
|
||||
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
|
||||
# cp .env.development.example .env.development
|
||||
# ⚠️ VITE_API_BASE_URL 에 다른 주소(예: http://localhost:3000/api)를 넣지 않는다.
|
||||
# 쿠키(Path=/api, SameSite)·CSP connect-src 'self'·출처 검사가 같은 주소를 전제로 한다.
|
||||
#
|
||||
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
|
||||
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
|
||||
# cp .env.development.example .env.development
|
||||
# ==========================================
|
||||
|
||||
# VITE_API_BASE_URL=http://localhost:3000/api
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# ==========================================
|
||||
# 프론트엔드 환경변수 (production)
|
||||
#
|
||||
# ⚠️ VITE_API_BASE_URL 은 루트 .env.production 의 BACKEND_API_URL 이
|
||||
# docker-compose build arg 로 주입된다 — 여기에 작성할 필요가 없다.
|
||||
# (SSOT: 루트 .env — .claude/rules/env-structure.md)
|
||||
# 보통 이 파일은 필요 없다.
|
||||
# - 운영 이미지는 compose 가 VITE_API_BASE_URL=/api 로 고정해 빌드한다 (같은 주소 배포 — docs/mail/api-contract.md 머리말).
|
||||
# 브라우저는 포털 주소의 /api 를 부르고, 프론트 nginx 가 백엔드로 넘긴다.
|
||||
# - 본문 한도 등 nginx 값은 루트 .env.production 에서 온다 (frontend/nginx-env.envsh).
|
||||
#
|
||||
# Docker 없이 vite dev 서버를 직접 띄울 때만 아래 값을 사용한다.
|
||||
# cp .env.production.example .env.production
|
||||
# ⚠️ VITE_API_BASE_URL 에 다른 주소를 넣지 않는다.
|
||||
# 쿠키(Path=/api, SameSite)·CSP connect-src 'self'·출처 검사가 같은 주소를 전제로 한다.
|
||||
#
|
||||
# 모든 키는 VITE_ 접두어가 있어야 클라이언트 번들에 포함된다.
|
||||
# ⚠️ 번들에 그대로 노출되므로 시크릿을 넣지 말 것.
|
||||
# cp .env.production.example .env.production
|
||||
# ==========================================
|
||||
|
||||
# VITE_API_BASE_URL=http://localhost:3000/api
|
||||
|
||||
+32
-8
@@ -1,9 +1,18 @@
|
||||
FROM node:20-alpine AS build-stage
|
||||
# lock(package-lock.json)을 쓰고 검사하는 npm 은 한 버전으로 고정한다.
|
||||
# npm 은 버전마다 lock 의 중첩 항목을 다르게 정리해서, 섞어 쓰면 lock 이 매번 바뀐다.
|
||||
# 바꿀 때는 backend/Dockerfile · .github/workflows/ci.yml 의 NPM_VERSION · docker 스킬 문서를 함께 고친다.
|
||||
ARG NPM_VERSION=11.6.2
|
||||
|
||||
# 빌드는 Node 24 LTS — 패치까지 고정한다 (backend/Dockerfile · ci.yml 의 NODE_VERSION 과 같은 값).
|
||||
# 갱신 규칙과 함께 고치는 곳은 docker 스킬 문서 "Node 버전" 참조.
|
||||
ARG NODE_VERSION=24.22.1
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine3.24 AS build-stage
|
||||
ARG NPM_VERSION
|
||||
WORKDIR /app
|
||||
|
||||
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
|
||||
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
|
||||
RUN npm install -g npm@11
|
||||
# 전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행
|
||||
RUN npm install -g npm@${NPM_VERSION}
|
||||
|
||||
# 개발 환경(docker-compose.dev.yml)은 이 스테이지에서 vite dev 서버를 구동하므로 비-root 로 전환한다.
|
||||
# /app 소유권을 넘겨야 익명 볼륨(node_modules)이 node 소유로 생성되어 vite 캐시(.vite) 쓰기가 가능하다.
|
||||
@@ -16,17 +25,32 @@ RUN npm ci
|
||||
COPY --chown=node:node . .
|
||||
|
||||
ARG BUILD_MODE=production
|
||||
ARG VITE_API_BASE_URL
|
||||
# 같은 주소 배포 — 브라우저는 화면과 같은 주소의 /api 를 부르고 nginx 가 백엔드로 넘긴다 (compose 도 /api 를 준다)
|
||||
ARG VITE_API_BASE_URL=/api
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
RUN npm run build-only -- --mode ${BUILD_MODE}
|
||||
|
||||
# 비-root 전용 nginx 이미지 (보안 규칙: 컨테이너 root 실행 금지)
|
||||
# 1024 미만 포트는 바인드할 수 없으므로 8080 을 사용한다 — nginx.conf 의 listen 과 일치해야 한다.
|
||||
FROM nginxinc/nginx-unprivileged:stable-alpine AS production-stage
|
||||
# 버전까지 고정한다 — nginx.conf 가 add_header_inherit(1.29.3 이상)를 쓰므로, 떠 있는 태그(stable-alpine)의
|
||||
# 옛 이미지가 남은 호스트에서는 기동에 실패한다. 갱신 규칙·함께 고치는 곳은 docker 스킬 문서 "이미지 버전 고정".
|
||||
#
|
||||
# nginx 설정만 담은 단계 — 화면 빌드와 떼어 두어, CI 가 vite 빌드 없이 설정을 검사할 수 있다
|
||||
# (docker build --target nginx-config frontend → nginx -t). 운영 이미지는 이 단계 위에 화면만 얹는다.
|
||||
FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS nginx-config
|
||||
|
||||
# 보안 헤더 포함된 nginx 설정 적용
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# nginx.conf 는 템플릿이다 — 컨테이너가 뜰 때 entrypoint 가 ${NGINX_*} 만 채워 /etc/nginx/conf.d/default.conf 를 만든다.
|
||||
# NGINX_ENVSUBST_FILTER : NGINX_ 로 시작하는 변수만 바꾼다 (nginx 의 $uri·$request_id 등을 건드리지 않게)
|
||||
# NGINX_ENTRYPOINT_LOCAL_RESOLVERS : /etc/resolv.conf 의 DNS(도커 내장 127.0.0.11)를 NGINX_LOCAL_RESOLVERS 로 내보낸다
|
||||
ENV NGINX_ENVSUBST_FILTER=^NGINX_ \
|
||||
NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
# 템플릿 값 계산 (루트 env 의 첨부·업로드 한도 → nginx 본문 한도). 실행 권한이 있어야 entrypoint 가 source 한다
|
||||
COPY --chmod=755 nginx-env.envsh /docker-entrypoint.d/16-nginx-env.envsh
|
||||
|
||||
# 운영 이미지 — compose 는 target 없이 빌드하므로 이 단계가 파일의 마지막이어야 한다
|
||||
FROM nginx-config AS production-stage
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
|
||||
# 이미지 기본 사용자(nginx, uid 101)로 구동 — root 로 되돌리지 말 것
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/sh
|
||||
# ==========================================
|
||||
# nginx 템플릿(nginx.conf)에 넣을 값을 만든다 — nginx 이미지의 docker-entrypoint 가 envsubst 전에 source 한다
|
||||
# (Dockerfile 이 /docker-entrypoint.d/16-nginx-env.envsh 로 넣는다. 실행 권한이 있어야 읽힌다)
|
||||
#
|
||||
# 본문 한도는 백엔드 한도와 같은 루트 env 에서 만든다 → 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다.
|
||||
# compose 는 정의되지 않은 변수를 빈 문자열로 넣으므로 빈 값은 백엔드 기본값으로 대신한다.
|
||||
# 값이 잘못됐으면 기본값으로 조용히 넘어가지 않고 컨테이너 기동을 멈춘다 (fail-fast — .claude/rules/env-structure.md).
|
||||
#
|
||||
# ⚠️ source 되는 파일이라 entrypoint 셸과 이름·옵션을 함께 쓴다 — set -e/-u 를 걸지 않고, 이름은 nginx_env_ 로 시작한다.
|
||||
# exit 하면 entrypoint 가 끝나 컨테이너가 뜨지 않는다 (그게 의도다).
|
||||
# ==========================================
|
||||
|
||||
nginx_env_fail() {
|
||||
echo "nginx-env.envsh: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# $1(변수 이름)의 값이 양의 십진 정수면 그 값, 비어 있으면 $2(기본값). 그 밖은 기동 중단.
|
||||
# 앞자리 0 은 받지 않는다 — 셸 산술이 8진수로 읽어(010 → 8) 백엔드와 한도가 어긋나거나 08 에서 오류가 난다.
|
||||
# 6자리(999999MB)를 넘는 값도 받지 않는다 — 실수로 넣은 값이 산술에서 넘치지 않게.
|
||||
nginx_env_positive_int() {
|
||||
eval "nginx_env_value=\${$1:-}"
|
||||
if [ -z "$nginx_env_value" ]; then
|
||||
echo "$2"
|
||||
return
|
||||
fi
|
||||
case "$nginx_env_value" in
|
||||
0* | *[!0-9]*) nginx_env_fail "$1 는 0 으로 시작하지 않는 양의 정수(MB)여야 합니다 (현재: \"$nginx_env_value\")." ;;
|
||||
esac
|
||||
if [ "${#nginx_env_value}" -gt 6 ]; then
|
||||
nginx_env_fail "$1 가 너무 큽니다 (현재: \"$nginx_env_value\", 최대 999999)."
|
||||
fi
|
||||
echo "$nginx_env_value"
|
||||
}
|
||||
|
||||
# 멀티파트 경계·헤더 몫의 여유 (MB) — 백엔드 한도에 딱 맞는 파일도 nginx 에서 막히지 않게
|
||||
nginx_body_margin_mb=1
|
||||
|
||||
# 백엔드 기본값과 같다: MAIL_ATTACHMENT_MAX_SIZE_MB=20 (modules/mail/mail.settings.ts) · UPLOAD_MAX_SIZE_MB=5
|
||||
# (명령 치환 안에서 실패하면 그 서브셸만 끝나므로, 값을 먼저 받아 실패를 여기서 확인한다)
|
||||
nginx_env_mail_mb="$(nginx_env_positive_int MAIL_ATTACHMENT_MAX_SIZE_MB 20)" || exit 1
|
||||
nginx_env_upload_mb="$(nginx_env_positive_int UPLOAD_MAX_SIZE_MB 5)" || exit 1
|
||||
NGINX_MAIL_ATTACHMENT_BODY_LIMIT="$((nginx_env_mail_mb + nginx_body_margin_mb))m"
|
||||
NGINX_UPLOAD_BODY_LIMIT="$((nginx_env_upload_mb + nginx_body_margin_mb))m"
|
||||
|
||||
# 백엔드 주소 — compose 가 http://<PROJECT_NAME>-backend:<BACKEND_PORT> 를 준다.
|
||||
# 서비스 이름(backend)은 쓰지 않는다: proxy 네트워크를 다른 프로젝트와 함께 쓰면 그쪽 backend 로 풀릴 수 있다.
|
||||
if [ -z "${NGINX_BACKEND_UPSTREAM:-}" ]; then
|
||||
[ -n "${PROJECT_NAME:-}" ] || nginx_env_fail "NGINX_BACKEND_UPSTREAM(예: http://<PROJECT_NAME>-backend:3000) 또는 PROJECT_NAME 이 필요합니다."
|
||||
NGINX_BACKEND_UPSTREAM="http://${PROJECT_NAME}-backend:3000"
|
||||
fi
|
||||
# http://호스트[:포트] 만 받는다. 경로(끝의 / 포함)가 붙으면 proxy_pass 가 모든 /api 요청을 그 경로 하나로 보내고,
|
||||
# 공백·; 등은 설정 문법을 깨거나 지시어를 끼워 넣을 수 있다.
|
||||
nginx_env_upstream_host="${NGINX_BACKEND_UPSTREAM#http://}"
|
||||
if [ "$nginx_env_upstream_host" = "$NGINX_BACKEND_UPSTREAM" ] || [ -z "$nginx_env_upstream_host" ]; then
|
||||
nginx_env_fail "NGINX_BACKEND_UPSTREAM 은 http://호스트[:포트] 형식이어야 합니다 (현재: \"$NGINX_BACKEND_UPSTREAM\")."
|
||||
fi
|
||||
case "$nginx_env_upstream_host" in
|
||||
*[!A-Za-z0-9.:_-]*) nginx_env_fail "NGINX_BACKEND_UPSTREAM 에는 경로·쿼리·공백을 넣지 않습니다 — http://호스트[:포트] 만 (현재: \"$NGINX_BACKEND_UPSTREAM\")." ;;
|
||||
:* | *:*:* | *:*[!0-9]* | *:) nginx_env_fail "NGINX_BACKEND_UPSTREAM 의 호스트·포트가 올바르지 않습니다 (현재: \"$NGINX_BACKEND_UPSTREAM\")." ;;
|
||||
esac
|
||||
|
||||
# 도커 내장 DNS — 15-local-resolvers.envsh 가 /etc/resolv.conf 에서 읽어 둔다 (Dockerfile 의 NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1)
|
||||
NGINX_LOCAL_RESOLVERS="${NGINX_LOCAL_RESOLVERS:-127.0.0.11}"
|
||||
|
||||
export NGINX_MAIL_ATTACHMENT_BODY_LIMIT NGINX_UPLOAD_BODY_LIMIT NGINX_BACKEND_UPSTREAM NGINX_LOCAL_RESOLVERS
|
||||
+151
-9
@@ -1,11 +1,38 @@
|
||||
# ==========================================
|
||||
# 프론트엔드 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';
|
||||
|
||||
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;
|
||||
@@ -15,15 +42,29 @@ server {
|
||||
#
|
||||
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
|
||||
# - default-src 'self' : 기본적으로 같은 출처만 허용
|
||||
# - style-src 'unsafe-inline' : Vue 의 동적 스타일 바인딩(:style)이 인라인 스타일을 생성하므로 필요
|
||||
# - connect-src 'self' : API 가 다른 도메인이면 그 origin 을 반드시 추가할 것
|
||||
# (예: connect-src 'self' https://api.example.com)
|
||||
# - img-src data: blob: : 인라인 아이콘·캔버스/파일 미리보기 대응
|
||||
# - 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: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
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)
|
||||
@@ -41,11 +82,112 @@ server {
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# 정적 자원 캐싱 (SPA 빌드 산출물은 해시 파일명이라 장기 캐싱 가능)
|
||||
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
|
||||
# 정적 자원 캐싱 — 여기의 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;
|
||||
|
||||
# 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;
|
||||
}
|
||||
|
||||
# 메일 발송·조회·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간
|
||||
location ^~ /api/messages {
|
||||
proxy_read_timeout 90s;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
}
|
||||
|
||||
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
|
||||
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다.
|
||||
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(413·502·504 등)이 그대로 나간다 (2026-10-27 확인)
|
||||
location @api_payload_too_large {
|
||||
add_header_inherit off;
|
||||
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;
|
||||
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;
|
||||
@@ -53,7 +195,7 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
# 숨김 파일 접근 차단
|
||||
# 숨김 파일 접근 차단 (/api 는 위 ^~ 로 이 검사를 거치지 않고 백엔드가 판단한다)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
|
||||
Reference in New Issue
Block a user