From 238d0ba838ade522263a7b721fd5858fd72bd6a4 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 27 Oct 2026 01:31:21 +0900 Subject: [PATCH] =?UTF-8?q?chore(docker):=20=EB=A9=94=EC=9D=BC=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=C2=B7=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=C2=B7=EA=B0=99=EC=9D=80=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EC=99=80=20Node=2024=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 웹메일 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 --- .env.development.example | 93 +- .env.production.example | 111 +- .github/workflows/ci.yml | 77 +- .gitignore | 7 +- backend/.dockerignore | 19 + backend/Dockerfile | 27 +- data/.gitkeep | 0 docker-compose.dev.yml | 86 +- docker-compose.yml | 139 ++- frontend/.dockerignore | 25 + frontend/.env.development.example | 15 +- frontend/.env.production.example | 14 +- frontend/Dockerfile | 40 +- frontend/nginx-env.envsh | 67 ++ frontend/nginx.conf | 160 ++- mail/README.md | 495 +++++++++ mail/scripts/setup.mjs | 1703 +++++++++++++++++++++++++++++ 17 files changed, 2996 insertions(+), 82 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 data/.gitkeep create mode 100644 frontend/.dockerignore create mode 100644 frontend/nginx-env.envsh create mode 100644 mail/README.md create mode 100644 mail/scripts/setup.mjs diff --git a/.env.development.example b/.env.development.example index 0fcef22..e88dc6a 100644 --- a/.env.development.example +++ b/.env.development.example @@ -20,6 +20,18 @@ WEBSOCKET_PORT=3001 DB_PORT=5432 REDIS_PORT=6379 +# ========================================== +# 내부 네트워크 (compose app-network · edge) +# +# 서브넷을 고정한다 — 메일 엔진은 app-network 대역만 신뢰한다 (게이트웨이 로그인·자동 차단 예외, mail/README.md). +# APP_NETWORK_SUBNET : backend·db·redis·mail(·mailpit) — 사설 대역, /24 ~ /28 (mail setup 이 검사한다) +# EDGE_NETWORK_SUBNET : frontend ↔ backend 전용 (frontend 는 app-network 에 붙지 않는다) +# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다. +# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다. +# ========================================== +APP_NETWORK_SUBNET=172.16.71.0/24 +EDGE_NETWORK_SUBNET=172.16.72.0/24 + # ========================================== # Database 설정 # ========================================== @@ -37,10 +49,16 @@ REDIS_PASSWORD=change-me-in-local-env REDIS_DATA_DIR=./data/redis # ========================================== -# API 경로 설정 +# 화면·API 주소 — 같은 주소(origin) 배포 (docs/mail/api-contract.md 머리말) +# +# 브라우저는 화면과 같은 주소의 /api 를 부른다 (개발은 vite 프록시, 운영은 프론트 nginx 가 백엔드로 넘긴다). +# - CORS_ORIGIN : 화면의 주소(origin). 상태 변경 요청의 출처 검사에 쓴다 — FRONTEND_PORT 를 바꾸면 함께 고칠 것 +# - BACKEND_API_URL : 화면 주소 + /api. 앱은 쓰지 않고, 운영 기동 때 공개 주소인지 검사하는 데만 쓴다 +# - TRUST_PROXY : 백엔드 앞 프록시 홉 수. 개발은 vite 프록시 하나라 1 (비우면 1) # ========================================== -BACKEND_API_URL=http://localhost:3000/api +BACKEND_API_URL=http://localhost:5173/api CORS_ORIGIN=http://localhost:5173 +TRUST_PROXY=1 # ========================================== # Rate Limiting (API 남용 방지) @@ -61,21 +79,82 @@ DB_SYNCHRONIZE=false # ========================================== # 선택적 서비스 (Compose profiles) # -# 기본값(비어 있음) = redis 와 frontend 컨테이너를 기동하지 않는다. +# 기본값 = mail — 메일 엔진(Stalwart)과 개발용 Mailpit 을 띄운다. +# 직원 로그인이 메일 엔진에 의존하므로 개발에서도 켜 두는 것을 권장한다. +# (redis 와 frontend 컨테이너는 기동하지 않는다) # -# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=redis -# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=frontend -# 둘 다 → COMPOSE_PROFILES=redis,frontend +# mail 메일 엔진 + Mailpit (기본) → COMPOSE_PROFILES=mail +# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=mail,redis +# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=mail,frontend # # 일회성으로 켜려면: docker compose --profile redis ... up -d # # ⚠️ 개발 환경의 프론트엔드는 호스트에서 띄우는 것이 기본이다 (`cd frontend && npm run dev`). # 컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸린다. # ========================================== -COMPOSE_PROFILES= +COMPOSE_PROFILES=mail # ========================================== # 파일 업로드 (로컬 디스크) +# +# UPLOAD_DATA_DIR 는 개발 전용 — 호스트 디렉터리에 바로 쌓여 열어 볼 수 있다 (운영은 named volume _uploads). +# 리눅스 호스트는 처음 한 번 mkdir -p data/uploads (없으면 도커가 root 소유로 만들어 backend 가 쓰지 못한다) # ========================================== UPLOAD_DATA_DIR=./data/uploads UPLOAD_MAX_SIZE_MB=5 + +# ========================================== +# 메일 엔진 (Stalwart) — profile `mail` +# +# 직원 로그인 = 메일 계정. 백엔드는 내부 JMAP 으로 메일 엔진에 붙는다. +# 처음 띄운 뒤 한 번 실행한다 (여러 번 실행해도 안전하다): +# node --env-file=.env.development mail/scripts/setup.mjs +# 자세한 내용: mail/README.md +# +# 개발 도메인은 외부와 섞이지 않는 예약 TLD(.test)를 쓴다. +# ⚠️ 약한 비밀번호는 Stalwart 의 강도 정책이 거부한다. 복사 후 무작위 값으로 교체할 것. +# ⚠️ 비밀번호는 영문·숫자로만 만든다. # · 따옴표 · $ · 공백은 compose 와 Node(--env-file)가 +# 서로 다르게 읽어(Node 는 # 부터 주석으로 자른다) 컨테이너와 setup 스크립트의 값이 어긋난다. +# ========================================== +# 패치까지 고정한다 — setup 스크립트가 0.16 의 동작(관리 API·bootstrap)에 맞춰져 있다 (docker 스킬 "이미지 버전 고정") +MAIL_IMAGE=stalwartlabs/stalwart:v0.16.27 +MAIL_DOMAIN=arasystem.test +# 서버 호스트명 — 최초 bootstrap 때만 반영된다 (이후 변경은 관리 화면에서) +MAIL_HOSTNAME=mail.arasystem.test +# 관리 화면·setup 스크립트용 HTTP 포트 (127.0.0.1 로만 열린다) +MAIL_ADMIN_PORT=8180 +# JMAP·OAuth 디스커버리 기준 주소. 개발은 관리 화면 주소 — MAIL_ADMIN_PORT 를 바꾸면 함께 고칠 것 +MAIL_PUBLIC_URL=http://localhost:8180 +# 영구 관리자 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 admin) — setup 스크립트가 이 이름으로 관리자를 만들고, +# 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 (두 쪽이 이 값 하나를 읽는다). +# ⚠️ bootstrap 전에만 정할 수 있다. bootstrap 뒤에 이 값만 바꾸면 엔진 관리자는 그대로 남고 백엔드는 새 이름만 막게 되므로, +# setup 이 중단한다 (되돌리는 절차: mail/README.md "문제 해결"). 옮겨 올 메일함에 같은 주소가 있으면 다른 이름으로 정한다. +# 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (백엔드와 같은 규칙) +MAIL_ADMIN_LOCAL_PART=admin +# 영구 관리자 비밀번호 — setup 스크립트가 이 값으로 맞춘다 +MAIL_ADMIN_PASSWORD=change-me-in-local-env +# 최초 기동(bootstrap)용 임시 관리자 "아이디:비밀번호". ⚠️ 값이 있는 동안은 정상 모드에서도 관리자로 로그인된다 +# (개발은 127.0.0.1 에만 열려 있어 이 파일에 둔다 — 운영은 파일에 두지 않고 일회성 override 로만 쓴다) +MAIL_RECOVERY_ADMIN=admin:change-me-in-local-env +# 백엔드가 직원 메일함을 대리 접근(impersonate)할 때 쓰는 서비스 계정 (주소 전체). +# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다 +MAIL_GATEWAY_USER=mail-gateway@arasystem.test +MAIL_GATEWAY_PASSWORD=change-me-in-local-env +# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다 +MAIL_JMAP_URL=http://mail:8080 +# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다 +# (그 앞에 외부 리버스 프록시를 두면 그쪽 한도도 맞춘다 — docker 스킬 "운영 환경") +MAIL_ATTACHMENT_MAX_SIZE_MB=20 +# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다 +MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25 +# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분). +# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다 +MAIL_TRUSTED_NETWORKS= +# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다. +# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다) +# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.test +# 개발용 테스트 직원 계정(ara.kim@ 등)의 공통 비밀번호 — 개발 환경에서만 만들어진다 +MAIL_DEV_USER_PASSWORD=change-me-in-local-env +# 개발 전용 메일 수집기 — 사내 도메인 밖으로 보낸 메일이 여기에만 쌓인다 +MAILPIT_IMAGE=axllent/mailpit:v1.32.0 +MAILPIT_UI_PORT=8025 diff --git a/.env.production.example b/.env.production.example index 7aae564..dd593db 100644 --- a/.env.production.example +++ b/.env.production.example @@ -16,6 +16,19 @@ WEBSOCKET_PORT=3001 DB_PORT=5432 REDIS_PORT=6379 +# ========================================== +# 내부 네트워크 (compose app-network · edge) +# +# 서브넷을 고정한다 — 메일 엔진은 app-network 대역만 신뢰한다 (게이트웨이 로그인·자동 차단 예외, mail/README.md). +# APP_NETWORK_SUBNET : backend·db·redis·mail — 사설 대역, /24 ~ /28 (mail setup 이 검사한다) +# EDGE_NETWORK_SUBNET : frontend(nginx) ↔ backend 전용. 인터넷을 향한 frontend 는 app-network 에 붙지 않는다. +# TRUST_PROXY 를 주소 목록으로 쓸 때 이 대역을 넣는다 (아래) +# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다. +# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다. +# ========================================== +APP_NETWORK_SUBNET=172.16.71.0/24 +EDGE_NETWORK_SUBNET=172.16.72.0/24 + # ========================================== # Database 설정 # ========================================== @@ -33,21 +46,31 @@ REDIS_PASSWORD=CHANGE-ME-BEFORE-DEPLOY REDIS_DATA_DIR=./data/redis # ========================================== -# API 경로 설정 +# 화면·API 주소 — 같은 주소(origin) 배포 (docs/mail/api-contract.md 머리말) # -# ⚠️ 운영에서는 **반드시 실제 공개 도메인**으로 교체한다. -# - BACKEND_API_URL : 브라우저가 호출할 API 주소 (프론트엔드 번들에 그대로 박힌다) -# - CORS_ORIGIN : 백엔드가 허용할 오리진 (scheme+host[+port], 여러 개면 콤마 구분) +# 브라우저는 포털 주소의 /api 를 부르고 프론트 nginx 가 백엔드로 넘긴다. 운영의 입구는 프론트 nginx 하나다. +# - CORS_ORIGIN : 화면의 공개 주소(origin, 여러 개면 콤마). 상태 변경 요청의 출처 검사에 쓴다 — 운영 필수 +# - BACKEND_API_URL : 화면 주소 + /api. 앱은 쓰지 않고, 운영 기동 때 공개 주소인지 검사하는 데만 쓴다 # -# 아래 example.com 이나 localhost 가 남아 있으면 **백엔드가 기동에 실패한다** +# localhost 나 example.com 이 남아 있으면 **백엔드가 기동에 실패한다** # (backend/src/shared/config/env.validation.ts — fail-fast). -# 기동은 되는데 브라우저만 조용히 실패하는 상황을 막기 위한 검사다. -# # 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 # ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다. +# +# - TRUST_PROXY : 백엔드 앞 프록시 — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수 +# 이 compose 의 운영 경로는 클라이언트 → 외부 리버스 프록시(TLS) → 프론트 nginx → 백엔드 하나뿐이다. +# (프론트 nginx 는 호스트 포트를 열지 않고 TLS 도 하지 않으므로 외부 프록시 없이는 들어올 수 없다 — 1 은 해당 없음) +# 2 = 홉 수. proxy 네트워크를 이 프로젝트의 외부 프록시만 쓸 때 +# 주소 목록 = ,<외부 프록시 IP> (예: 172.16.72.0/24,172.20.0.10) — proxy 네트워크를 +# 다른 프로젝트와 함께 쓴다면 이쪽을 권장한다. 홉 수는 "몇 번째 주소"만 믿어서, 같은 네트워크의 다른 +# 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면 X-Forwarded-For 를 꾸며 IP 를 바꿀 수 있다. +# 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안 주소)이며 고정해 둔다(ipv4_address 등). +# 넓은 사설 대역을 통째로 넣지 않는다. +# true·0·false 는 운영에서 기동이 거부된다. # ========================================== -BACKEND_API_URL=https://api.example.com/api -CORS_ORIGIN=https://app.example.com +BACKEND_API_URL=https://portal.arasystem.it.kr/api +CORS_ORIGIN=https://portal.arasystem.it.kr +TRUST_PROXY=2 # ========================================== # Rate Limiting (API 남용 방지) @@ -68,15 +91,75 @@ DB_SYNCHRONIZE=false # ========================================== # 선택적 서비스 (Compose profiles) # -# 기본값(비어 있음) = redis 를 기동하지 않는다. -# 캐시·세션 공유가 필요해지면 아래를 redis 로 바꾸면 컨테이너가 함께 뜬다. -# COMPOSE_PROFILES=redis +# 기본값 = mail — 메일 엔진(Stalwart)을 띄운다. 직원 로그인이 메일 엔진에 의존한다. +# (redis 는 기동하지 않는다) +# 캐시·세션 공유가 필요해지면 redis 를 추가하면 컨테이너가 함께 뜬다. +# COMPOSE_PROFILES=mail,redis # 일회성으로 켜려면: docker compose --profile redis ... up -d # ========================================== -COMPOSE_PROFILES= +COMPOSE_PROFILES=mail # ========================================== # 파일 업로드 (로컬 디스크) +# +# 저장 위치는 named volume _uploads 다 (docker-compose.yml). 호스트 디렉터리를 쓰지 않는다 — +# sudo docker compose 가 없는 디렉터리를 root 소유로 만들어 backend(node)가 쓰지 못한다. +# (UPLOAD_DATA_DIR 는 개발 전용이라 운영에는 두지 않는다. 백업은 docker 스킬 "운영 환경") # ========================================== -UPLOAD_DATA_DIR=./data/uploads UPLOAD_MAX_SIZE_MB=5 + +# ========================================== +# 메일 엔진 (Stalwart) — profile `mail` +# +# 운영 기본 가정 = 메일 전용 호스트: 25·465·993·443 을 이 호스트에 직접 연다 (docker-compose.yml). +# 첫 배포 후 setup 스크립트를 한 번 실행한다 — mail/README.md "운영 배포". +# +# ⚠️ 비밀번호는 모두 강한 무작위 값으로 교체한다. CHANGE-ME 가 남아 있으면 setup 스크립트가 중단한다. +# 영문·숫자로만 만든다 — # · 따옴표 · $ · 공백은 compose 와 Node 가 다르게 읽는다. +# 예: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))" +# ⚠️ 8080(관리 API)은 공개하지 않는다. setup 스크립트는 내부망에서 실행한다 (README 참조). +# 공개 443 의 JMAP·관리 API·웹 포털은 setup 이 막는다 (MTA-STS 정책 파일만 열어 둔다). +# ========================================== +# 패치까지 고정한다 — setup 스크립트가 0.16 의 동작(관리 API·bootstrap)에 맞춰져 있다 (docker 스킬 "이미지 버전 고정") +MAIL_IMAGE=stalwartlabs/stalwart:v0.16.27 +MAIL_DOMAIN=arasystem.it.kr +# 새 엔진의 호스트명 (EHLO·PTR·TLS 인증서) — 최초 bootstrap 때만 반영된다. +# mailcow 가 mail.arasystem.it.kr 를 쓰는 전환 기간에는 새 이름(mx1.)을 쓴다 (mail/README.md "전환 절차") +MAIL_HOSTNAME=mx1.arasystem.it.kr +# JMAP·OAuth 디스커버리 기준 주소 — 새 엔진에 실제로 닿는 주소여야 한다 +MAIL_PUBLIC_URL=https://mx1.arasystem.it.kr +# 영구 관리자 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 admin) — setup 스크립트가 이 이름으로 관리자를 만들고, +# 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 (두 쪽이 이 값 하나를 읽는다). +# ⚠️ 운영 bootstrap 전에 정한다. mailcow 에 실제 admin@ 메일함이 있으면 다른 이름으로 한다 +# (그대로 두면 엔진 관리자와 주소가 겹쳐 그 메일함은 옮겨 와도 웹메일에 로그인할 수 없다). +# ⚠️ bootstrap 뒤에 이 값만 바꾸면 엔진 관리자는 그대로 남고 백엔드는 새 이름만 막게 되므로, setup 이 중단한다 +# (되돌리는 절차: mail/README.md "문제 해결"). 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (백엔드와 같은 규칙) +MAIL_ADMIN_LOCAL_PART=admin +# 영구 관리자 비밀번호 — setup 스크립트가 이 값으로 맞춘다 +MAIL_ADMIN_PASSWORD=CHANGE-ME-BEFORE-DEPLOY +# 복구 관리자(MAIL_RECOVERY_ADMIN, "아이디:비밀번호")는 이 파일에 적지 않는다 — 최초 bootstrap·관리자 복구 때 +# 셸에서 일회성으로만 준다. 값이 있는 동안은 정상 모드에서도 관리자로 로그인되기 때문이다. +# 이 파일에 값이 있거나, 끝난 뒤에도 mail 컨테이너에 남아 있으면 setup 이 실패한다 (mail/README.md "운영 배포"). +# read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P" +# docker compose --env-file .env.production up -d mail +# node --env-file=.env.production mail/scripts/setup.mjs --recovery +# unset MAIL_RECOVERY_ADMIN P; docker compose --env-file .env.production up -d mail # 복구 관리자 끄기 +# node --env-file=.env.production mail/scripts/setup.mjs # 꺼졌는지 확인 +# 백엔드가 직원 메일함을 대리 접근(impersonate)할 때 쓰는 서비스 계정 (주소 전체). +# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다 +MAIL_GATEWAY_USER=mail-gateway@arasystem.it.kr +MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY +# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망) +MAIL_JMAP_URL=http://mail:8080 +# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다. +# ⚠️ 그 앞의 외부 리버스 프록시 본문 한도는 직접 이 값 + 1MB 이상으로 둔다 — +# 작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 (docker 스킬 "운영 환경") +MAIL_ATTACHMENT_MAX_SIZE_MB=20 +# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다 +MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25 +# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분). +# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다 +MAIL_TRUSTED_NETWORKS= +# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다. +# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다) +# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.it.kr diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbd8045..aba139c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,17 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# lock(package-lock.json)을 쓰고 검사하는 npm 버전 — backend·frontend Dockerfile 의 NPM_VERSION, docker 스킬 문서와 같은 값. +# setup-node 의 Node 에 딸린 npm(Node 24 는 11.19 등 — 패치마다 바뀐다)은 lock 의 중첩 항목을 다르게 정리하므로 그대로 쓰지 않는다. +# Node 는 24 LTS 패치까지 고정 — backend·frontend Dockerfile 의 ARG NODE_VERSION 과 같은 값 (세 잡이 이 값 하나를 쓴다). +# 갱신 규칙(일반 패치는 배포 2주 뒤, 보안 릴리스는 CI 통과 즉시)은 docker 스킬 문서 "Node 버전". +env: + NPM_VERSION: '11.6.2' + NODE_VERSION: '24.22.1' + jobs: backend: - name: Backend (lint · test · e2e · build) + name: Backend (lint · type-check · test · e2e · build) runs-on: ubuntu-latest # 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다 timeout-minutes: 20 @@ -43,16 +51,23 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: backend/package-lock.json + - name: npm 버전 고정 + run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version + - run: npm ci # Warning 0 (.claude/rules/checklist.md) - name: Lint run: npm run lint + # tsc --noEmit -p tsconfig.json — 빌드(tsconfig.build.json)가 빼는 spec·test 파일까지 타입 검사한다 + - name: Type check + run: npm run type-check + - name: Test run: npm run test @@ -72,7 +87,7 @@ jobs: run: npm run build frontend: - name: Frontend (lint · type-check · test) + name: Frontend (lint · type-check · test · build) runs-on: ubuntu-latest # 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다 timeout-minutes: 15 @@ -84,10 +99,13 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: frontend/package-lock.json + - name: npm 버전 고정 + run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version + - run: npm ci - name: Lint @@ -99,6 +117,52 @@ jobs: - name: Test run: npm run test:unit -- --run + # 운영 이미지와 같은 vite 빌드 (타입 검사는 위에서 했으므로 build-only) + - name: Build + run: npm run build-only + + infra: + name: Infra (compose · Dockerfile · nginx) + runs-on: ubuntu-latest + # 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다 + timeout-minutes: 10 + # 잡 컨테이너 안에서 docker 를 쓴다 — act_runner 기본 설정(docker_host 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고, + # 잡 이미지(gitea/runner-images:ubuntu-latest)에 docker CLI·compose 가 들어 있다 (docker 스킬 "CI 러너"). + # 빌드 컨텍스트는 API 로 올라가므로 잡 컨테이너 안의 체크아웃으로 빌드할 수 있다 (-v 바인드 마운트는 호스트 경로라 쓰지 않는다) + steps: + - uses: actions/checkout@v4 + + - name: compose 문법·변수 치환 (예시 env) + run: | + # env_file 대상(backend/.env.*)은 커밋되지 않는다 — 빈 파일로 자리만 채운다 + touch backend/.env.development backend/.env.production + docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example config -q + docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile frontend config -q + docker compose -f docker-compose.yml --env-file .env.production.example config -q + + - name: Dockerfile 검사 + run: | + docker build --check backend + docker build --check frontend + + # 운영 이미지의 nginx 단계만 만든다 (화면 빌드 없음 — frontend/Dockerfile 의 nginx-config) + - name: nginx 설정 검사 + run: | + tag="ci-nginx-config:${{ github.run_id }}" + docker build --target nginx-config -t "$tag" frontend + docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t + # 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소) + if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then + echo "::error::MAIL_ATTACHMENT_MAX_SIZE_MB=08 을 받아들였습니다 (거부해야 한다)"; exit 1 + fi + if docker run --rm -e NGINX_BACKEND_UPSTREAM=http://ci-backend:3000/ "$tag" nginx -t; then + echo "::error::경로가 붙은 NGINX_BACKEND_UPSTREAM 을 받아들였습니다 (거부해야 한다)"; exit 1 + fi + + - name: 정리 + if: always() + run: docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true + error-codes-sync: name: 에러 코드 SSOT 동기화 검사 runs-on: ubuntu-latest @@ -109,10 +173,13 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: backend/package-lock.json + - name: npm 버전 고정 + run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version + - name: Install backend deps working-directory: backend run: npm ci diff --git a/.gitignore b/.gitignore index df744a5..1ae546a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,14 +11,17 @@ # ========================================== # ⚠️ 루트 기준으로 고정한다(앞의 /). 'data/' 로 두면 모든 depth 의 data 디렉터리가 무시되어 # flutter/lib/features/<도메인>/data/ 같은 소스 코드까지 커밋되지 않는다. -/data/ +# 디렉터리(/data/)가 아니라 내용(/data/*)을 무시하고 .gitkeep 만 추적한다 — 그래야 클론할 때 data/ 가 +# 클론한 사용자 소유로 생긴다. 없으면 운영의 `sudo docker compose up` 이 root 소유로 만들어 +# 메일 setup 스크립트가 data/mail/ 에 쓰지 못한다 (mail/README.md "운영 배포"). +# (개발 DB·업로드 등 data/ 아래 내용은 모두 무시된다) +/data/* !/data/.gitkeep /db-data/ /redis-data/ # CI 러너 등록 정보 ci/runner-data/ # 업로드 파일 (로컬 저장) — 코드가 아니다 -/data/uploads/ backend/uploads/ # ========================================== diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..63ac85f --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,19 @@ +# ========================================== +# 이미지 빌드 컨텍스트 제외 목록 +# +# 이미지에는 소스와 lock 파일만 들어가야 한다. +# +# node_modules : 호스트(Windows)에서 설치한 node_modules 가 `COPY . .` 로 들어가면 +# `npm ci` 가 설치한 Linux 용 의존성을 덮어쓴다 (네이티브 모듈이 깨진다). +# dev 의 익명 볼륨(/app/node_modules)도 이미지 내용으로 초기화되므로 같은 문제가 생긴다. +# .env* : 비밀값이 이미지 레이어에 영구히 남는다. 값은 compose 가 런타임에 주입한다. +# ========================================== +node_modules +dist +coverage +uploads +logs +*.log +.env +.env.* +.git diff --git a/backend/Dockerfile b/backend/Dockerfile index 3773d5c..a90b692 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,9 +1,18 @@ -FROM node:20-alpine AS builder +# lock(package-lock.json)을 쓰고 검사하는 npm 은 한 버전으로 고정한다. +# npm 은 버전마다 lock 의 중첩 항목(chokidar·readdirp·@emnapi 등)을 다르게 정리해서, 섞어 쓰면 lock 이 매번 바뀐다. +# 바꿀 때는 frontend/Dockerfile · .github/workflows/ci.yml 의 NPM_VERSION · docker 스킬 문서를 함께 고친다. +ARG NPM_VERSION=11.6.2 + +# 런타임은 Node 24 LTS — 패치까지 고정한다 (메이저 태그는 빌드할 때마다 그날의 최신 패치를 받는다). +# 갱신 규칙(일반 패치는 배포 2주 뒤, 보안 릴리스는 CI 통과 즉시)과 함께 고치는 곳은 docker 스킬 문서 "Node 버전" 참조. +ARG NODE_VERSION=24.22.1 + +FROM node:${NODE_VERSION}-alpine3.24 AS builder +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)은 이 스테이지를 그대로 구동하므로 비-root 로 전환한다. # /app 소유권을 넘겨야 익명 볼륨(node_modules)도 node 소유로 생성되어 캐시 쓰기가 가능하다. @@ -17,13 +26,14 @@ RUN npm ci COPY --chown=node:node . . RUN npm run build -FROM node:20-alpine AS production +FROM node:${NODE_VERSION}-alpine3.24 AS production +ARG NPM_VERSION WORKDIR /app ENV NODE_ENV=production # 운영 stage 도 동일 npm 버전 사용 -RUN npm install -g npm@11 +RUN npm install -g npm@${NPM_VERSION} # 운영용 의존성만 설치 COPY package*.json ./ @@ -31,6 +41,11 @@ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist +# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads) — node 소유로 만들어 둔다. +# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다. +# (호스트 디렉터리를 바인드 마운트하면 sudo docker compose 가 root 소유로 만들어 업로드가 실패한다) +RUN mkdir -p /app/uploads && chown node:node /app/uploads + EXPOSE 3000 # 비-root 실행 (보안 규칙) diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 35bd0c5..e166b9a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -21,8 +21,9 @@ services: target: build-stage # 비-root 구동이라 1024 미만 포트를 쓸 수 없다 → vite 기본 포트 사용 command: npm run dev -- --host 0.0.0.0 --port 5173 + # 호스트의 브라우저만 붙으면 된다 — 127.0.0.1 로 한정해 LAN 에 노출하지 않는다 (보안 규칙, 호스트 vite 와 같은 범위) ports: - - "${FRONTEND_PORT}:5173" + - "127.0.0.1:${FRONTEND_PORT}:5173" # dev 는 nginx(8080)가 아니라 vite(5173)를 띄우므로 base 의 헬스체크를 덮어쓴다 healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5173/ || exit 1"] @@ -36,30 +37,58 @@ services: volumes: - ./${FRONTEND_DIR}:/app - /app/node_modules - # dev에서는 리버스 프록시 미사용 — base의 networks 목록을 덮어씀 + # dev에서는 리버스 프록시 미사용 — base의 networks 목록을 덮어씀. + # 운영과 같이 edge 로만 백엔드에 닿는다 (app-network = 메일 엔진의 신뢰 대역에는 붙이지 않는다) networks: !override - - app-network + - edge backend: # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (수동 up 때만 실행) restart: "no" build: target: builder - command: npm run start:dev + # /app/dist 는 아래 익명 볼륨이다. 그런데 nest-cli.json 의 deleteOutDir 는 출력 폴더를 통째로 지우려다 + # 마운트 지점이라 EBUSY 로 기동에 실패한다 (검증함). 그래서 기동할 때 볼륨 "내용만" 비우고 + # (= deleteOutDir 와 같은 효과 — 지운 소스의 옛 entity·migration 산출물이 TypeORM glob 에 잡히지 않게), + # deleteOutDir 만 끈 nest-cli.json 사본으로 같은 start:dev 를 돌린다. 원본 nest-cli.json 은 고치지 않는다. + # + # 마이그레이션은 컨테이너를 기동할 때 한 번만 적용한다 (dc down -v 등으로 DB 를 새로 만든 경우 대비). + # watch 재시작은 이 스크립트를 다시 돌지 않으므로, 생성만 하고 검토 전인 마이그레이션이 저장할 때마다 적용되지는 않는다. + # ⚠️ 다만 컨테이너를 다시 띄우면 그때까지 생긴 마이그레이션이 적용된다 — 검토를 마친 뒤 재기동한다. + # 실패하면 기동을 멈춘다 (마이그레이션이 덜 된 DB 로 앱을 띄우지 않는다) + command: + - sh + - -c + - | + set -e + find dist -mindepth 1 -delete + npm run migration:run + node -e "const fs = require('fs'); const c = require('./nest-cli.json'); c.compilerOptions = { ...c.compilerOptions, deleteOutDir: false }; fs.mkdirSync('node_modules/.cache', { recursive: true }); fs.writeFileSync('node_modules/.cache/nest-cli.dev.json', JSON.stringify(c))" + exec npm run start:dev -- --config node_modules/.cache/nest-cli.dev.json + # 호스트의 vite 개발 서버·DB 툴만 붙으면 된다 — 127.0.0.1 로 한정해 LAN 에 노출하지 않는다 (보안 규칙) + # (Android 에뮬레이터의 10.0.2.2 는 호스트의 127.0.0.1 로 연결되므로 그대로 된다. 실기기는 닿지 않는다) ports: - - "${BACKEND_PORT}:${BACKEND_PORT}" - - "${WEBSOCKET_PORT}:${WEBSOCKET_PORT}" + - "127.0.0.1:${BACKEND_PORT}:${BACKEND_PORT}" + - "127.0.0.1:${WEBSOCKET_PORT}:${WEBSOCKET_PORT}" environment: - CHOKIDAR_USEPOLLING=true - WATCHPACK_POLLING=true volumes: - ./${BACKEND_DIR}:/app - /app/node_modules - # base 의 업로드 볼륨을 소스 바인드 마운트 위에 다시 얹는다 + # 빌드 산출물은 호스트와 나눈다 — 호스트에서 npm run build 를 하면 바인드 마운트된 backend/dist 가 + # 지워졌다 다시 생기면서 컨테이너의 Nest watch 가 끊긴다 (앱이 옛 코드로 계속 돈다) + - /app/dist + # 업로드는 개발에서만 호스트 디렉터리로 — 파일을 바로 열어 볼 수 있게 (base 의 named volume 을 같은 경로로 덮어쓴다). + # 소스 바인드 마운트(/app) 위에 다시 얹는다. Windows·macOS 의 Docker Desktop 은 소유자 문제가 없고, + # 리눅스 호스트는 도커가 없는 디렉터리를 root 소유로 만들므로 처음 한 번 mkdir -p data/uploads 를 해 둔다 - ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads - # dev에서는 리버스 프록시 미사용 — base의 networks 목록을 덮어씀 - networks: !override - - app-network + # dev 는 watch 컴파일(swc)과 타입 검사(tsc, nest-cli.json 의 typeCheck)가 앱과 함께 돌아 + # base 의 1g 로는 OOM 으로 두 번 죽었다 → dev 에서만 2g. 운영 한도(1g)는 그대로다 + deploy: + resources: + limits: + memory: 2g db: # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀) @@ -75,6 +104,43 @@ services: ports: - "127.0.0.1:${REDIS_PORT}:6379" + mail: + # dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 unless-stopped 를 덮어씀) + restart: "no" + # 운영용 공개 포트(25·465·993·443)를 모두 지운다 — 개발 환경은 외부에서 메일을 받지 않는다. + # 관리 화면·setup 스크립트용 HTTP(8080)만 127.0.0.1 로 연다 (보안 규칙) + ports: !override + - "127.0.0.1:${MAIL_ADMIN_PORT}:8080" + + # ---------------------------------------- + # mailpit — 개발 전용 메일 수집기 (profile `mail`, 운영 compose 에는 없다) + # + # setup 스크립트가 Stalwart 의 외부 발송 경로를 이리로 돌린다(Relay → mailpit:1025). + # 사내 도메인 밖으로 보낸 메일은 인터넷으로 나가지 않고 여기에만 쌓인다. + # UI: http://localhost:${MAILPIT_UI_PORT} + # ⚠️ 경로는 setup 스크립트가 설정·리로드한 뒤에야 적용된다. 그 전에 외부 주소로 보내면 실제로 발송된다. + # ---------------------------------------- + mailpit: + profiles: ["mail"] + container_name: ${PROJECT_NAME}-mailpit + image: ${MAILPIT_IMAGE} + # 이미지 기본은 root 실행이다 → nobody 로 내린다 (보안 규칙: dev 포함 비-root). + # 메일은 /tmp 의 임시 DB 에 저장되므로 쓰기 권한 문제가 없다 (컨테이너를 재시작하면 비워진다) + user: "65534:65534" + restart: "no" + # SMTP 는 내부망(mail 컨테이너)에서만 받는다 + expose: + - "1025" + ports: + - "127.0.0.1:${MAILPIT_UI_PORT}:8025" + networks: + - app-network + deploy: + resources: + limits: + cpus: '0.25' + memory: 128m + # base의 proxy 외부 네트워크 의존을 dev에서 제거 networks: proxy: !reset null diff --git a/docker-compose.yml b/docker-compose.yml index 7ccb969..7439cf2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,16 @@ services: # ---------------------------------------- - # 1. Frontend (Nginx 정적 호스팅) + # 1. Frontend (Nginx — 화면 정적 호스팅 + /api 프록시) — 운영의 유일한 입구 + # + # 같은 주소(origin) 배포: 브라우저는 화면과 같은 주소의 /api 를 부르고, 이 nginx 가 백엔드로 넘긴다 + # (docs/mail/api-contract.md 머리말 — CORS 없이 쿠키·CSP connect-src 'self' 로 동작한다). + # 외부 리버스 프록시는 proxy 네트워크로 이 컨테이너(-frontend:8080)에만 붙는다. + # + # 네트워크 — 인터넷을 향한 컨테이너를 메일 엔진·DB 와 떼어 놓는다: + # proxy 외부 리버스 프록시 ↔ frontend (다른 프로젝트와 함께 쓸 수 있다) + # edge frontend ↔ backend 전용 + # app-network backend ↔ db·redis·mail — 메일 엔진의 신뢰 대역이다. frontend 는 붙이지 않는다 + # (붙이면 frontend 가 mail:8080 관리 API 에 닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다) # ---------------------------------------- frontend: container_name: ${PROJECT_NAME}-frontend @@ -9,14 +19,21 @@ services: dockerfile: Dockerfile args: - BUILD_MODE=${BUILD_MODE} - - VITE_API_BASE_URL=${BACKEND_API_URL} + # 같은 주소 배포라 상대경로로 고정한다 — 다른 주소를 넣으면 쿠키(Path=/api)·CSP 가 맞지 않는다 + - VITE_API_BASE_URL=/api expose: - "8080" environment: - NODE_ENV=${APP_ENV} + # nginx 템플릿 값 (frontend/nginx-env.envsh) — 본문 한도를 백엔드와 같은 env 에서 만든다 (각 한도 + 1MB) + - MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20} + - UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5} + # 서비스 이름(backend)이 아니라 고유 컨테이너 이름 — proxy 네트워크를 다른 프로젝트와 함께 쓰면 + # 그쪽의 backend 라는 이름으로 풀릴 수 있다 (nginx 는 붙은 모든 네트워크에서 이름을 찾는다) + - NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT} restart: unless-stopped networks: - - app-network + - edge - proxy depends_on: - backend @@ -54,7 +71,12 @@ services: - NODE_ENV=${APP_ENV} - PORT=${BACKEND_PORT} - WEBSOCKET_PORT=${WEBSOCKET_PORT} + # 화면의 공개 주소(origin, 콤마로 여러 개) — 상태 변경 요청의 출처 검사에 쓴다. 운영 필수 - CORS_ORIGIN=${CORS_ORIGIN} + # 백엔드 앞의 프록시 홉 수(또는 주소 목록) — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수 + # 외부 리버스 프록시 → 프론트 nginx → 백엔드 = 2. proxy 네트워크를 다른 프로젝트와 함께 쓰면 + # 주소 목록(,<외부 프록시 IP>)을 권장한다 (루트 env 예시 참조) + - TRUST_PROXY=${TRUST_PROXY:-} # 앱이 직접 쓰지는 않는다. 로컬 주소가 남은 채 운영에 배포되는 것을 # 기동 시점에 잡기 위해 검증용으로 주입한다 (shared/config/env.validation.ts) - BACKEND_API_URL=${BACKEND_API_URL} @@ -73,13 +95,35 @@ services: - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD} - # 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다 + # 메일 엔진(Stalwart) — 백엔드 전용 내부 JMAP. 직원 메일함은 게이트웨이 계정의 + # 대리 접근(<직원 주소>%<게이트웨이 주소>)으로 읽는다 (mail/README.md) + - MAIL_JMAP_URL=${MAIL_JMAP_URL:-http://mail:8080} + - MAIL_DOMAIN=${MAIL_DOMAIN} + - MAIL_GATEWAY_USER=${MAIL_GATEWAY_USER} + - MAIL_GATEWAY_PASSWORD=${MAIL_GATEWAY_PASSWORD} + # 메일 엔진 영구 관리자 아이디 — mail setup 이 같은 값으로 관리자를 만들고, 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다. + # bootstrap 전에만 정할 수 있다 (루트 env 예시 · mail/README.md) + - MAIL_ADMIN_LOCAL_PART=${MAIL_ADMIN_LOCAL_PART:-admin} + # 관리자 외에 웹메일 로그인·대리 접근을 막을 계정 (주소 전체, 콤마). 비어 있어도 위 관리자는 항상 막는다 + - MAIL_BLOCKED_ACCOUNTS=${MAIL_BLOCKED_ACCOUNTS:-} + # 첨부 1개 최대 크기(MB). 앞단 리버스 프록시의 요청 본문 한도는 이보다 커야 한다 + # (작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 — .claude/skills/docker/SKILL.md) + - MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20} + # 메일 한 통의 첨부 합계(MB) — 파일당 한도 이상이어야 한다 + - MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=${MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB:-25} + # 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다 — named volume _uploads. + # 호스트 디렉터리(./data/uploads)를 쓰지 않는 이유: sudo docker compose 가 없는 디렉터리를 root 소유로 만들어 + # backend(node)가 쓰지 못한다. 새 named volume 은 이미지의 /app/uploads(node 소유) 소유자를 물려받는다 (backend/Dockerfile). + # (개발은 docker-compose.dev.yml 이 ./data/uploads 바인드 마운트로 바꾼다) + # (메일 첨부는 업로드 중 /tmp/arasystem-mail-uploads 에 잠시 받는다 — 컨테이너 쓰기 계층 디스크. + # read_only 로 바꾸면 그 경로에 tmpfs 가 아닌 볼륨을 붙인다: tmpfs 는 메모리 한도에 잡힌다) volumes: - - ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads + - uploads:/app/uploads restart: unless-stopped + # 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다 networks: - app-network - - proxy + - edge depends_on: db: condition: service_healthy @@ -87,6 +131,10 @@ services: redis: condition: service_healthy required: false + # mail 도 profile 뒤에 있다. 켜져 있으면 메일 엔진이 준비된 뒤 백엔드가 뜬다 + mail: + condition: service_healthy + required: false healthcheck: # localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 Node 는 0.0.0.0(IPv4) 바인딩이므로 127.0.0.1 을 명시한다 test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${BACKEND_PORT}/ || exit 1"] @@ -164,8 +212,85 @@ services: labels: - "com.centurylinklabs.watchtower.enable=true" + # ---------------------------------------- + # 5. Mail (Stalwart) — 사내 메일 엔진 · profile `mail` + # + # 직원 로그인 = 메일 계정. 백엔드가 내부 JMAP(http://mail:8080)으로 중계한다. + # 켜기: 루트 .env.{APP_ENV} 에 COMPOSE_PROFILES=mail + # 초기화: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs (여러 번 실행해도 안전) + # 자세한 내용: mail/README.md + # + # 운영 기본 가정 = 메일 전용 호스트 (mailcow 와 같은 방식). 아래 포트를 호스트에 직접 연다. + # 25 SMTP 수신 · 465 SMTPS 제출 · 993 IMAPS · 443 HTTPS (내장 ACME 인증서 발급에도 쓴다) + # 같은 호스트의 리버스 프록시가 443 을 이미 쓰고 있다면 mail/README.md 의 대안을 따른다. + # + # ⚠️ 8080(HTTP JMAP·관리 API)은 백엔드 전용 내부 포트다 — 호스트에 절대 공개하지 않는다. + # ⚠️ 0.0.0.0(IPv4)으로만 바인딩한다. 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가 + # 중계해 컨테이너에는 도커 게이트웨이 주소로 보인다. 그러면 외부 접속이 신뢰 대역(MAIL_TRUSTED_NETWORKS) + # 으로 취급되어 자동 차단 예외·게이트웨이 IP 제한이 무력화된다. + # ---------------------------------------- + mail: + profiles: ["mail"] + container_name: ${PROJECT_NAME}-mail + image: ${MAIL_IMAGE} + # 이미지 기본 사용자 stalwart(UID 2000)로 구동한다 (비-root). healthcheck 는 이미지에 내장되어 있다 + # (https 443 → 실패 시 http 8080 /healthz/live — bootstrap 모드에서도 동작). + expose: + - "8080" + ports: + - "0.0.0.0:25:25" + - "0.0.0.0:465:465" + - "0.0.0.0:993:993" + - "0.0.0.0:443:443" + environment: + # JMAP·OAuth 디스커버리 URL 의 기준 주소 (공개 주소) + - STALWART_PUBLIC_URL=${MAIL_PUBLIC_URL} + # 최초 기동(bootstrap) 전용 임시 관리자 "아이디:비밀번호". bootstrap/recovery 모드에서만 통한다. + # 운영은 설정을 마치면 값을 비우고 컨테이너를 다시 만든다 (mail/README.md) + - STALWART_RECOVERY_ADMIN=${MAIL_RECOVERY_ADMIN:-} + volumes: + - mail-config:/etc/stalwart + - mail-data:/var/lib/stalwart + restart: unless-stopped + networks: + - app-network + deploy: + resources: + limits: + cpus: '1.0' + memory: 1g + networks: app-network: + # 이름·서브넷을 고정한다. 메일 엔진은 이 대역만 신뢰한다 — 게이트웨이 로그인·자동 차단 예외 + # (mail/scripts/setup.mjs 가 APP_NETWORK_SUBNET 으로 맞추고, 더 넓은 신뢰 대역은 거부한다). + # 그래서 backend·db·redis·mail(개발은 mailpit 도)만 붙인다 — frontend 등 다른 컨테이너를 붙이지 않는다 (setup 이 확인한다). + # 기본값은 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖이라 자동 배정 네트워크와 겹치지 않는다. + # 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → APP_NETWORK_SUBNET 을 바꾼다. + name: ${PROJECT_NAME}_app-network driver: bridge + ipam: + config: + - subnet: ${APP_NETWORK_SUBNET:-172.16.71.0/24} + edge: + # frontend(nginx) ↔ backend 전용. 서브넷을 고정해 두면 백엔드 TRUST_PROXY 를 주소 목록으로 쓸 수 있다 + # (backend 가 보는 frontend 주소가 늘 이 대역 안이다). 겹치면 EDGE_NETWORK_SUBNET 을 바꾼다. + name: ${PROJECT_NAME}_edge + driver: bridge + ipam: + config: + - subnet: ${EDGE_NETWORK_SUBNET:-172.16.72.0/24} proxy: - external: true \ No newline at end of file + external: true + +# 메일 설정(config.json)·저장소(RocksDB). 컨테이너 사용자(UID 2000)가 써야 하므로 +# 바인드 마운트 대신 named volume 을 쓴다. 이름을 고정해 백업 명령이 디렉터리 이름에 좌우되지 않게 한다. +# 업로드도 같은 이유(컨테이너 사용자 node 가 써야 한다)로 named volume 이다. +# ⚠️ `docker compose down -v` 는 이 볼륨들까지 지운다 — 메일함 전체와 업로드 파일이 사라진다. +volumes: + mail-config: + name: ${PROJECT_NAME}_mail-config + mail-data: + name: ${PROJECT_NAME}_mail-data + uploads: + name: ${PROJECT_NAME}_uploads \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..df24576 --- /dev/null +++ b/frontend/.dockerignore @@ -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__ diff --git a/frontend/.env.development.example b/frontend/.env.development.example index fd69ecc..2cf6046 100644 --- a/frontend/.env.development.example +++ b/frontend/.env.development.example @@ -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 diff --git a/frontend/.env.production.example b/frontend/.env.production.example index 7d8f1e5..d33734f 100644 --- a/frontend/.env.production.example +++ b/frontend/.env.production.example @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 45d836b..ea647fd 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 로 되돌리지 말 것 diff --git a/frontend/nginx-env.envsh b/frontend/nginx-env.envsh new file mode 100644 index 0000000..50f9c91 --- /dev/null +++ b/frontend/nginx-env.envsh @@ -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://-backend: 를 준다. +# 서비스 이름(backend)은 쓰지 않는다: proxy 네트워크를 다른 프로젝트와 함께 쓰면 그쪽 backend 로 풀릴 수 있다. +if [ -z "${NGINX_BACKEND_UPSTREAM:-}" ]; then + [ -n "${PROJECT_NAME:-}" ] || nginx_env_fail "NGINX_BACKEND_UPSTREAM(예: http://-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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index a23e190..6167b7d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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 문서는 이 정책을 상속하는데, + # 그 안에는 앱이 넣는