generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1
@@ -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 <PROJECT_NAME>_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
|
||||
|
||||
+97
-14
@@ -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 네트워크를 이 프로젝트의 외부 프록시만 쓸 때
|
||||
# 주소 목록 = <EDGE_NETWORK_SUBNET>,<외부 프록시 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 <PROJECT_NAME>_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
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -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/
|
||||
|
||||
# ==========================================
|
||||
|
||||
@@ -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
|
||||
+21
-6
@@ -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 실행 (보안 규칙)
|
||||
|
||||
+76
-10
@@ -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
|
||||
|
||||
+132
-7
@@ -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 네트워크로 이 컨테이너(<PROJECT_NAME>-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 네트워크를 다른 프로젝트와 함께 쓰면
|
||||
# 주소 목록(<EDGE_NETWORK_SUBNET>,<외부 프록시 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 <PROJECT_NAME>_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
|
||||
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
|
||||
@@ -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;
|
||||
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
# 메일 엔진 (Stalwart)
|
||||
|
||||
사내 메일은 **Stalwart(JMAP) 를 자체 호스팅**한다. 직원 로그인 = 메일 계정이다.
|
||||
웹메일 UI 는 자체 제작하고, 백엔드(NestJS)가 내부 JMAP 으로 메일 엔진에 중계한다.
|
||||
|
||||
- 이미지: `stalwartlabs/stalwart:v0.16.27` — 패치까지 고정 (비-root `stalwart`, UID 2000 · healthcheck 내장. 올릴 때는 [docker 스킬](../.claude/skills/docker/SKILL.md) "이미지 버전 고정")
|
||||
- 설정값 SSOT: 루트 `.env.{APP_ENV}` 의 메일 섹션 ([env-structure](../.claude/rules/env-structure.md))
|
||||
- 초기화·계정 구성: [`scripts/setup.mjs`](./scripts/setup.mjs) — 의존성 없음, 여러 번 실행해도 안전
|
||||
- 이름은 루트 env 의 `PROJECT_NAME` 을 따른다 — 컨테이너 `${PROJECT_NAME}-mail`, 볼륨 `${PROJECT_NAME}_mail-config`·`_mail-data`,
|
||||
네트워크 `${PROJECT_NAME}_app-network`(신뢰 대역)·`${PROJECT_NAME}_edge`(frontend ↔ backend). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다:
|
||||
```bash
|
||||
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.development)" # 운영은 .env.production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||
```
|
||||
브라우저 ─▶ frontend nginx ─(edge)─▶ backend ──HTTP JMAP (app-network, http://mail:8080)──▶ mail (Stalwart)
|
||||
│ 로그인: <직원 주소>:<비밀번호> 로 세션 조회 (200 / 401) │ 25 SMTP 수신
|
||||
│ 메일함: <직원 주소>%<게이트웨이 주소> 로 대리 접근 │ 465 SMTPS 제출 · 993 IMAPS
|
||||
│ 443 HTTPS — ACME·MTA-STS 만 (JMAP·관리 API·포털은 403)
|
||||
▼ 사내 도메인 밖 (개발 환경만)
|
||||
mailpit — 외부 수신자 메일 수집
|
||||
```
|
||||
|
||||
메일 엔진은 **app-network 대역 전체를 신뢰한다**(자동 차단 예외·게이트웨이 로그인). 그래서 app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만
|
||||
붙이고, 인터넷을 향한 frontend 는 edge 로만 backend 에 닿는다. setup 이 앱 네트워크에 붙은 컨테이너를 확인한다.
|
||||
|
||||
| 구성 요소 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `mail` 서비스 | `docker-compose.yml` (profile `mail`) | named volume `<PROJECT_NAME>_mail-config`(config.json) · `<PROJECT_NAME>_mail-data`(RocksDB) |
|
||||
| dev 오버라이드 | `docker-compose.dev.yml` | 운영 포트 제거, `127.0.0.1:${MAIL_ADMIN_PORT}` → 8080 만 |
|
||||
| `mailpit` | `docker-compose.dev.yml` 전용 (profile `mail`) | 비-root(65534), UI `127.0.0.1:${MAILPIT_UI_PORT}` |
|
||||
| setup 스크립트 | `mail/scripts/setup.mjs` | 아래 "setup 스크립트가 하는 일" |
|
||||
|
||||
| 포트 | 운영 | 개발 |
|
||||
| --- | --- | --- |
|
||||
| 25 · 465 · 993 · 443 | 호스트에 공개 (`0.0.0.0`, IPv4 만 — [보안](#보안)). 443 의 HTTP 는 MTA-STS 정책 파일만 연다 | 공개하지 않음 |
|
||||
| 8080 (HTTP JMAP·관리 API) | **공개하지 않음** (backend 전용 내부망) | `127.0.0.1:${MAIL_ADMIN_PORT}` (관리 화면·setup 스크립트) |
|
||||
| 995 pop3s · 4190 sieve | 리스너는 있으나 공개하지 않음 | — |
|
||||
|
||||
| 계정 | 용도 | 비밀번호 |
|
||||
| --- | --- | --- |
|
||||
| `<MAIL_ADMIN_LOCAL_PART>@<MAIL_DOMAIN>` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 | `MAIL_ADMIN_PASSWORD` |
|
||||
| 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다 | `MAIL_RECOVERY_ADMIN` |
|
||||
| `MAIL_GATEWAY_USER` | 백엔드의 대리 접근 전용 서비스 계정 — 역할 권한은 `authenticate`·`impersonate` 뿐, 신뢰 대역(`APP_NETWORK_SUBNET`)에서만 로그인 | `MAIL_GATEWAY_PASSWORD` |
|
||||
| `ara.kim@` 김아라 · `doyun.lee@` 이도윤 · `seojun.park@` 박서준 | **개발 전용** 테스트 직원 | `MAIL_DEV_USER_PASSWORD` |
|
||||
|
||||
---
|
||||
|
||||
## 개발 시작
|
||||
|
||||
```bash
|
||||
# 0) env 파일 (처음 한 번) — change-me-* 를 무작위 영문·숫자로 교체한다
|
||||
cp .env.development.example .env.development
|
||||
cp backend/.env.development.example backend/.env.development
|
||||
|
||||
# 1) 기동 — COMPOSE_PROFILES=mail 이 기본이라 backend·db·mail·mailpit 이 함께 뜬다
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 2) 메일 엔진 설정 (처음 한 번 — 이후에도 env 를 바꾸면 다시 실행한다)
|
||||
node --env-file=.env.development mail/scripts/setup.mjs
|
||||
```
|
||||
|
||||
| 확인 | 주소 |
|
||||
| --- | --- |
|
||||
| 관리 화면 (웹 포털) | <http://localhost:8180/admin/> — `admin@arasystem.test` / `MAIL_ADMIN_PASSWORD` |
|
||||
| 직원 셀프서비스 | <http://localhost:8180/account/> |
|
||||
| Mailpit (외부 수신자 메일) | <http://localhost:8025> |
|
||||
|
||||
- 첫 실행은 bootstrap → **mail 컨테이너를 스스로 재시작**(`docker restart <PROJECT_NAME>-mail`)한 뒤 이어서 진행한다.
|
||||
- 출력의 `[생성]`·`[변경]`·`[유지]` 로 무엇이 바뀌었는지 보인다. 두 번째 실행부터는 전부 `[유지]` 여야 정상이다.
|
||||
- ⚠️ **외부 발송 경로(Mailpit)는 setup 이 설정·리로드한 뒤에야 적용된다.** 그 전에 외부 주소로 보내면 실제 인터넷으로 나간다.
|
||||
그래서 스크립트는 Mailpit 경로를 먼저 확정하고 나서 테스트 계정을 만든다. 시험 수신자는 예약 도메인(`example.net` 등)만 쓴다.
|
||||
- 개발 메일 데이터를 버리고 처음부터:
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development rm -sf mail mailpit
|
||||
docker volume rm "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data"
|
||||
# 이후 1) 2) 를 다시
|
||||
```
|
||||
(`docker compose down -v` 도 이 볼륨을 지운다 — 의도한 게 아니라면 쓰지 않는다)
|
||||
|
||||
### setup 스크립트가 하는 일
|
||||
|
||||
0. **사전 확인 (아무것도 바꾸기 전)** — app-network 의 실제 서브넷이 `APP_NETWORK_SUBNET` 인지, 앱 네트워크에 정해진 컨테이너
|
||||
(backend·db·redis·mail·mailpit)만 붙어 있는지, 백엔드 주소가 신뢰 대역 안인지. 어긋나면 **엔진 설정을 바꾸지 않고 중단**한다
|
||||
(신뢰 대역을 바꾼 뒤에 알면 전 직원의 메일이 멈추고 백엔드 주소가 자동 차단 예외에서 빠진다). 앱 네트워크에 다른 컨테이너가 있으면 운영은 중단, 개발은 경고.
|
||||
1. **모드 감지** — bootstrap(첫 기동, 8080 만 열림) / bootstrap 완료·재시작 대기 / 정상
|
||||
2. **bootstrap** — 호스트명·기본 도메인·DKIM 키(ed25519·rsa)·로그(표준 출력) → 재시작.
|
||||
응답의 무작위 관리자 비밀번호는 한 번만 보이므로 `data/mail/bootstrap-admin.json`(git 무시)에 잠시 보관했다가 교체 후 지운다.
|
||||
⚠️ `Bootstrap/set` 은 원자적이지 않다 — 다시 부르면 도메인 중복으로 거부되면서도 tracer 가 하나 더 생겨 설정 오류가 난다. 그래서
|
||||
- 부르기 **전에** `data/mail/` 에 실제로 써 보고, 쓸 수 없으면 아무것도 바꾸지 않고 멈춘다
|
||||
- 재실행 때는 mail 컨테이너의 `/etc/stalwart/config.json` 으로 "bootstrap 은 끝났고 재시작만 남았다"를 알아보고 재시작만 한다
|
||||
(Bootstrap/set 은 재시작 전에 이미 이 파일을 쓴다 — 보관 파일이 없어도 다시 부르지 않는다. docker 를 쓸 수 없으면 보관 파일로 판단)
|
||||
3. **관리자** — `<MAIL_ADMIN_LOCAL_PART>@도메인` 비밀번호를 `MAIL_ADMIN_PASSWORD` 로 맞춘다 (나중에 env 값을 바꾸면 복구 관리자로 다시 맞춘다 — 운영은 `--recovery`).
|
||||
갓 bootstrap 한 엔진이면 bootstrap 이 만든 `admin@` 의 아이디를 `MAIL_ADMIN_LOCAL_PART` 로 바꾼다 (Bootstrap/set 은 아이디를 받지 않는다).
|
||||
이미 쓰던 엔진에서 env 아이디의 관리자가 없으면(bootstrap 뒤에 값만 바꾼 경우) **중단**한다
|
||||
4. **자동 차단 예외** — 신뢰 대역을 AllowedIp 로 등록 (기본 보안 설정은 IP 당 인증 실패 하루 100회면 차단하는데, 모든 로그인이 백엔드 IP 하나를 거친다).
|
||||
신뢰 대역 = `MAIL_TRUSTED_NETWORKS`, 비우면 `APP_NETWORK_SUBNET`(compose 가 고정한 app-network 서브넷).
|
||||
**서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다.** 이 스크립트가 만든 예전 항목은 지운다.
|
||||
5. **게이트웨이** — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만
|
||||
6. **잠금** — 직원(`User`) 역할에서 Principal 조회 권한 13개(`jmapPrincipal*`·`davPrincipal*`)를 뺀다(→ `InvalidateCaches`).
|
||||
공개 443(https 리스너)의 HTTP 는 `/.well-known/mta-sts.txt` 만 열고 나머지는 403 (`x:Http` 의 `allowedEndpoints`) — [보안](#보안)
|
||||
7. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다.
|
||||
운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 —
|
||||
200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 `curl` 명령을 보여 준다.
|
||||
게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안))
|
||||
8. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 7 의 리로드로 반영된 것을 확인한 뒤에만 9
|
||||
9. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인
|
||||
|
||||
그 밖의 안전장치:
|
||||
|
||||
- **운영은 docker 를 쓸 수 있어야 한다** — 없으면 아무것도 바꾸지 않고 실패한다 (네트워크·복구 관리자 확인, bootstrap 뒤 재시작).
|
||||
- **셸에 `APP_ENV`·`APP_NETWORK_SUBNET` 등이 이미 있으면 중단한다.** Node 의 `--env-file` 은 기존 환경변수를 덮어쓰지 않아,
|
||||
셸에 `APP_ENV=development` 가 남은 채 `.env.production` 으로 돌리면 운영에 개발 설정(Mailpit 경로)을 넣게 된다.
|
||||
(`MAIL_RECOVERY_ADMIN` 만 예외 — 원래 셸에서 일회성으로 주는 값이다)
|
||||
- `APP_NETWORK_SUBNET` 은 사설 대역의 네트워크 주소, 접두 길이 `/24`~`/28` 만 받는다 — 이 대역 전체가 신뢰 대역이라 넓게 잡지 않는다
|
||||
(넓을수록 사무실 LAN·VPN 같은 실제 클라이언트 주소와 겹친다. `/28` 보다 좁으면 컨테이너 주소가 모자란다).
|
||||
- `MAIL_ADMIN_LOCAL_PART` 는 백엔드와 같은 형식 규칙(영문·숫자·`.`·`_`·`-`, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시)이다.
|
||||
로그인 확인은 2xx 만 "맞음", 401 만 "틀림"으로 보고 그 밖(429·5xx)은 중단한다 — 비밀번호를 잘못 덮어쓰지 않게.
|
||||
- 운영에서는 `change-me` 예시 값을 거부하고, `MAIL_PUBLIC_URL` 이 경로 없는 `https://` 가 아니면 실패, 발송 전략이 Mailpit 을 가리키면 실패로 보고한다.
|
||||
- 운영의 복구 관리자 규칙 — env 파일에 있으면 실패, 셸에 있는데 `--recovery` 가 없으면 실패,
|
||||
`--recovery` 실행은 끝에 "복구 관리자 끄기"를 안내하고 종료 코드 3, 그 뒤 일반 실행은 mail 컨테이너에 복구 관리자가 남아 있으면 실패.
|
||||
- 관리자 역할 계정이 백엔드 차단 목록(`MAIL_ADMIN_LOCAL_PART` 관리자 + `MAIL_BLOCKED_ACCOUNTS`)에서 빠져 있으면 `[주의]`.
|
||||
- 종료 코드 `0` 성공 · `1` 실패 · `3` 후속 조치 필요(개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 `--recovery` 뒤 복구 관리자 끄기 — 안내대로 한 뒤 다시 실행).
|
||||
|
||||
### 백엔드 연결 정보
|
||||
|
||||
| env (backend 에 compose 가 주입) | 값 |
|
||||
| --- | --- |
|
||||
| `MAIL_JMAP_URL` | `http://mail:8080` (내부 JMAP) |
|
||||
| `MAIL_DOMAIN` | `arasystem.test` / 운영 `arasystem.it.kr` |
|
||||
| `MAIL_GATEWAY_USER` · `MAIL_GATEWAY_PASSWORD` | 게이트웨이 주소 · 비밀번호 |
|
||||
| `MAIL_ATTACHMENT_MAX_SIZE_MB` | 첨부 1개 최대 크기 (기본 20). 프론트 nginx 본문 한도는 이 값 + 1MB 로 자동, 외부 프록시는 직접 — [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" |
|
||||
| `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상) |
|
||||
|
||||
- 로그인 확인: `GET {MAIL_JMAP_URL}/jmap/session` + Basic `<직원 주소>:<비밀번호>` → 200 / 401
|
||||
- 메일함 접근: Basic `<직원 주소>%<MAIL_GATEWAY_USER>` : `MAIL_GATEWAY_PASSWORD`
|
||||
(게이트웨이 비밀번호 틀림·없는 직원 → 401, 대리 접근 권한 없는 계정 → 403)
|
||||
- ⚠️ 세션 응답의 `apiUrl`·`downloadUrl`·`uploadUrl`·`eventSourceUrl` 은 **`MAIL_PUBLIC_URL` 기준 절대 주소**다
|
||||
(개발 `http://localhost:8180/jmap/`). 백엔드는 이 주소를 따르지 말고 `MAIL_JMAP_URL` 에 경로만 붙여 쓴다.
|
||||
- `Email/query` 의 `subject` 필터는 단어 단위 검색이고 제목 앞의 `[태그]` 를 떼고 색인하는 것으로 보인다 — 부분 문자열 검색이 아니다.
|
||||
- 대리 접근은 메일 엔진 로그 `auth.success` 에 `accountName`(대상 직원)·`details`(게이트웨이)·`remoteIp` 로 남는다.
|
||||
- 신뢰 대역은 자동 차단에서 빠지므로 **로그인 무차별 대입 방어는 백엔드가 맡아야 한다** (로그인 엔드포인트 엄격 한도 — [security](../.claude/rules/security.md)).
|
||||
|
||||
---
|
||||
|
||||
## 운영 배포
|
||||
|
||||
사전 조건:
|
||||
|
||||
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 [대안](#리버스-프록시와-같은-호스트에-둘-때-대안) 참조)
|
||||
- `MAIL_HOSTNAME` 의 A 레코드 → 이 호스트 IP, 그 IP 의 PTR → `MAIL_HOSTNAME` ([PTR](#ptr))
|
||||
- 아웃바운드 25 허용 (클라우드는 기본 차단인 곳이 많다 — 막혀 있으면 [외부 릴레이](#외부-릴레이-ses-등))
|
||||
- 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다
|
||||
- **메일 서버 전용 재귀 DNS** — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막혀 스팸 판정이 틀어진다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl))
|
||||
- **스팸 차단 목록 이용 조건** — Spamhaus 는 회사용 무료 DQS 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절)
|
||||
- **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한)
|
||||
- **`APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`** 이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면 `up` 이 "Pool overlaps" 로 실패한다)
|
||||
- **rootful 도커 + iptables** (도커 기본값). 게시 포트(25·465·993·443)로 들어온 접속이 원래 주소 그대로 컨테이너에 보여야 한다 —
|
||||
rootless 도커(포트 전달이 주소를 바꾼다)나 `"iptables": false`(모든 접속이 docker-proxy 를 거친다)면 외부 접속이 도커 내부 주소로 보여
|
||||
자동 차단·게이트웨이 IP 제한이 무력화된다. 배포 뒤 [원래 접속 주소 확인](#원래-접속-주소-확인-배포-직후)으로 확인한다.
|
||||
- **setup 을 돌릴 사용자** — docker 를 쓸 수 있어야 한다(docker 그룹, 또는 매번 `sudo -E`). 저장소는 그 사용자로 클론한다(`sudo git clone` 금지) —
|
||||
`data/` 가 그 사용자 소유여야 setup 이 bootstrap 임시 비밀번호를 보관할 수 있다 (`data/.gitkeep` 이 있어 클론 때 생긴다).
|
||||
- **`MAIL_ADMIN_LOCAL_PART`** 를 bootstrap 전에 정한다 — mailcow 에 실제 `admin@` 메일함이 있으면 다른 아이디로 (bootstrap 뒤에는 바꿀 수 없다).
|
||||
|
||||
복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.
|
||||
|
||||
```bash
|
||||
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)"
|
||||
# 1) 복구 관리자를 일회성으로 준다 — 셸 기록에 남지 않게 read 로 받는다 (compose 는 셸 값이 env 파일보다 우선한다)
|
||||
read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P"
|
||||
|
||||
# 2) 기동 (mail 은 bootstrap 모드로 뜬다 — 8080 만 열림). sudo 를 쓰면 -E 로 셸 값을 넘긴다
|
||||
sudo -E docker compose --env-file .env.production up -d --build
|
||||
|
||||
# 3) setup --recovery — 호스트에서, docker 를 쓸 수 있는 사용자로 실행한다 (운영은 docker 가 없으면 아무것도 바꾸지 않고 실패한다).
|
||||
# 8080 을 공개하지 않으므로 mail 컨테이너의 내부 주소로 붙는다 (리눅스 호스트는 컨테이너 IP 로 바로 닿는다).
|
||||
export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080"
|
||||
# (A) 호스트 Node (24 LTS 권장, 최소 20.6 — --env-file) — docker 그룹이면 그대로:
|
||||
node --env-file=.env.production mail/scripts/setup.mjs --recovery
|
||||
# docker 그룹이 아니면 sudo 로 (셸 값과 PATH 를 넘긴다). ⚠️ 그러면 이후 실행도 모두 같은 형태로 — data/mail/ 이 root 소유가 된다
|
||||
sudo -E env "PATH=$PATH" node --env-file=.env.production mail/scripts/setup.mjs --recovery
|
||||
# (B) 호스트에 Node 가 없으면 — 공식 배포 파일을 임시 폴더에 받아 그대로 쓴다 (설치하지 않는다, 체크섬 확인. ARM 서버는 linux-arm64)
|
||||
NODE_DIST=node-v24.22.1-linux-x64; NODE_TMP="$(mktemp -d)"
|
||||
curl -fsSL -o "$NODE_TMP/$NODE_DIST.tar.xz" "https://nodejs.org/dist/v24.22.1/$NODE_DIST.tar.xz"
|
||||
curl -fsSL "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt" | grep " $NODE_DIST.tar.xz\$" | (cd "$NODE_TMP" && sha256sum -c -)
|
||||
tar -xJf "$NODE_TMP/$NODE_DIST.tar.xz" -C "$NODE_TMP" && export PATH="$NODE_TMP/$NODE_DIST/bin:$PATH"
|
||||
# → 이후는 (A) 와 같다. 끝나면 rm -rf "$NODE_TMP"
|
||||
# --recovery 실행은 설정을 마친 뒤 "복구 관리자 끄기"를 안내하고 종료 코드 3 으로 끝난다 → 4) 로
|
||||
|
||||
# 4) 복구 관리자 끄기 — 셸 값을 지우고 mail 컨테이너를 다시 만든다 (빈 값이면 복구 자격 증명은 401 — 검증함)
|
||||
unset MAIL_RECOVERY_ADMIN P
|
||||
sudo docker compose --env-file .env.production up -d mail
|
||||
|
||||
# 5) --recovery 없이 다시 실행 (3 과 같은 형태 — 컨테이너를 다시 만들었으므로 MAIL_ADMIN_URL 도 다시 구한다)
|
||||
# 전부 [유지] 이고, 컨테이너에 복구 관리자가 남아 있지 않은지 확인한다 (남아 있으면 실패)
|
||||
export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080"
|
||||
node --env-file=.env.production mail/scripts/setup.mjs
|
||||
```
|
||||
|
||||
- 운영 setup 은 끝에 **권장 DNS 레코드**(DKIM·SPF·MX·DMARC·SRV·MTA-STS·autoconfig)를 출력한다. 그대로 붙이지 말고 아래 전환 절차 순서를 따른다.
|
||||
- 공개 443 차단은 setup 이 컨테이너 안과 `MAIL_PUBLIC_URL`(호스트에서) 두 곳에서 확인한다. 호스트에서 자기 공인 주소로 되돌아오는 경로가 막힌 환경이면
|
||||
경고만 남기므로, 그때는 바깥에서 확인한다: `curl -s -o /dev/null -w '%{http_code}\n' https://<MAIL_HOSTNAME>/jmap/session` → `403`.
|
||||
|
||||
#### bootstrap 직후에 멈췄다면 (다시 실행하기 전에)
|
||||
|
||||
setup 이 bootstrap 과 재시작 사이(또는 직후)에 멈췄으면 — 연결 끊김, 권한 오류, 종료 코드 1 — **볼륨을 지우거나 `Bootstrap/set` 을 손으로 부르지 않는다.**
|
||||
|
||||
1. 엔진이 어디까지 갔는지 본다: `sudo docker exec "${PROJECT_NAME}-mail" test -s /etc/stalwart/config.json && echo "bootstrap 끝남"`
|
||||
2. 실패 원인을 고친다 — 출력의 `[실패]` 줄. `data/mail` 에 쓸 수 없다고 나왔다면 소유자를 바꾼다:
|
||||
`sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail` (sudo 로 돌릴 거라면 그대로 둔다 — 매번 같은 형태로)
|
||||
3. 셸에 `MAIL_RECOVERY_ADMIN` 이 남아 있는 채로 **같은 명령(`--recovery`)을 다시** 실행한다.
|
||||
bootstrap 이 끝났으면 재시작만 하고(config.json 으로 판단 — 임시 비밀번호 파일이 없어도 된다) 복구 관리자로 관리자 비밀번호·아이디를 맞춘다.
|
||||
4. `bootstrap 이 거부되었습니다 (primaryKeyViolation)` 가 나오면 [문제 해결](#문제-해결) 의 해당 줄을 본다.
|
||||
|
||||
#### 원래 접속 주소 확인 (배포 직후)
|
||||
|
||||
자동 차단·게이트웨이 IP 제한은 접속 주소를 믿고 동작한다. 바깥(다른 네트워크의 PC)에서 **실제 계정(본인 메일함)으로 IMAP 에 한 번 로그인**한 뒤,
|
||||
메일 엔진 로그의 `auth.success` 줄의 `remoteIp` 가 그 PC 의 공인 주소인지 본다.
|
||||
도커 내부 주소(`172.16.71.1` 같은 게이트웨이 등)로 보이면 rootless·iptables 설정을 고친다.
|
||||
|
||||
- 틀린 비밀번호로는 확인할 수 없다 — setup 이 정한 로그 수준(info)에서는 **실패한 로그인이 로그에 남지 않는다**
|
||||
(2026-10-27 Stalwart 0.16.27 에서 IMAP·HTTP 모두 확인. 성공은 `auth.success … listenerId = "imaps", remoteIp = …` 로 남는다).
|
||||
|
||||
```bash
|
||||
# 바깥 PC 에서 — 연결되면 아래 두 줄을 직접 입력한다 (명령에 비밀번호를 적지 않아 셸 기록에 남지 않는다)
|
||||
openssl s_client -quiet -connect mx1.arasystem.it.kr:993
|
||||
# a LOGIN <본인 주소> <비밀번호>
|
||||
# a LOGOUT
|
||||
# 메일 호스트에서
|
||||
sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' | grep 'imaps' | tail -3
|
||||
```
|
||||
- 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 내부 8080 에 붙는다:
|
||||
`ssh -L 8180:<mail 컨테이너 IP>:8080 <메일 호스트>` → <http://localhost:8180/admin/>. setup 은 운영에서 직원 계정을 만들지 않는다.
|
||||
- `MAIL_HOSTNAME` 은 bootstrap 때만 반영된다. 바꾸려면 관리 화면에서 고친다.
|
||||
- 관리자 비밀번호를 바꾸려면: env 의 `MAIL_ADMIN_PASSWORD` 변경 → 위 1)·`up -d mail` → `--recovery` 실행 → 4)·5).
|
||||
- 이미 서브넷을 고정하기 전에 만든 환경이라면 처음 한 번은 `sudo docker compose --env-file .env.production down` 후 `up -d` 로 네트워크와
|
||||
컨테이너를 함께 다시 만든다. 그냥 `up -d` 만 하면 네트워크는 새로 만들어지지만 재생성되지 않은 컨테이너가 서비스 이름(`db`·`mail` 등)
|
||||
별칭을 잃어 백엔드가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (개발 환경에서 확인 — `up -d --force-recreate` 로도 풀린다).
|
||||
|
||||
### PTR
|
||||
|
||||
메일 호스트 IP 의 역방향 DNS 를 `MAIL_HOSTNAME`(전환 기간에는 `mx1.arasystem.it.kr`)으로 설정한다
|
||||
(가비아가 아니라 **IP 를 준 호스팅사** 콘솔 또는 지원 요청).
|
||||
`A(MAIL_HOSTNAME) → IP` 와 `PTR(IP) → MAIL_HOSTNAME` 이 서로 맞고(FCrDNS), SMTP 인사말(EHLO = `MAIL_HOSTNAME`)과도 같아야
|
||||
Gmail·네이버 등이 거부하거나 스팸 처리하지 않는다.
|
||||
|
||||
### 외부 릴레이 (SES 등)
|
||||
|
||||
아웃바운드 25 가 막혀 있거나 IP 평판이 나쁠 때 외부 메일을 릴레이로 보낸다. 개발의 Mailpit 경로와 같은 방식이고 인증만 다르다.
|
||||
관리자 Basic 인증으로 `POST {관리 API}/jmap/` (`using: ["urn:ietf:params:jmap:core","urn:stalwart:jmap"]`):
|
||||
|
||||
```jsonc
|
||||
[
|
||||
["x:MtaRoute/set", { "accountId": "<세션의 urn:stalwart:jmap 계정>", "create": { "ses": {
|
||||
"@type": "Relay", "name": "ses", "description": "외부 발송 — Amazon SES",
|
||||
"address": "email-smtp.ap-northeast-2.amazonaws.com", "port": 587,
|
||||
"authUsername": "<SMTP 사용자>",
|
||||
// 비밀번호를 DB 에 두지 않고 mail 컨테이너의 환경변수에서 읽는다 → compose 의 mail.environment 에 추가
|
||||
"authSecret": { "@type": "EnvironmentVariable", "variableName": "MAIL_RELAY_PASSWORD" }
|
||||
} } }, "0"],
|
||||
["x:MtaOutboundStrategy/set", { "accountId": "<…>", "update": { "singleton": { "route": {
|
||||
"match": { "0": { "if": "is_local_domain(rcpt_domain)", "then": "'local'" } }, "else": "'ses'" } } } }, "1"],
|
||||
// 리로드 전에는 반영되지 않는다
|
||||
["x:Action/set", { "accountId": "<…>", "create": { "r": { "@type": "ReloadSettings" } } }, "2"]
|
||||
]
|
||||
```
|
||||
|
||||
- SPF 에 릴레이 쪽을 넣는다 (예: SES 는 `include:amazonses.com`). DKIM 은 Stalwart 가 보내기 전에 서명한다.
|
||||
- 포트별 TLS 방식(587 STARTTLS / 465 암묵적 TLS) 속성은 관리 화면에서 확인한다. **이 예시는 실제 SES 로 검증하지 않았다.**
|
||||
|
||||
### DNS 리졸버와 스팸 차단 목록 (DNSBL)
|
||||
|
||||
스팸 필터는 기본으로 외부 차단 목록(DNSBL) 18개를 DNS 로 조회한다 — Spamhaus(`zen` IP · `dbl` 도메인), SURBL, URIBL, Barracuda, SpamCop,
|
||||
DNSWL, Mailspike, SenderScore 등 (목록: 관리 화면 Settings › Spam Filter › DNSBL, 또는 `x:SpamDnsblServer/query`).
|
||||
이 목록들은 Stalwart 와 **별개 서비스라 각자 이용 조건이 있다.**
|
||||
|
||||
1. **메일 서버 전용 재귀 DNS 를 둔다.**
|
||||
- 공용·ISP DNS(KT `168.126.63.1`, `8.8.8.8`, `1.1.1.1` 등)로 조회하면 Spamhaus 가 거부하고 오류 코드 `127.255.255.254` 를 돌려준다.
|
||||
2026-10-26 개발 환경(KT DNS)에서 항상 등록돼 있는 시험 주소를 조회해 이 오류 코드가 오는 것을 확인했다.
|
||||
- 전용 재귀 리졸버(unbound 등)를 메일 호스트나 같은 compose 네트워크에 두고, Stalwart 의 DNS 리졸버 설정을 그쪽으로 바꾼다
|
||||
(관리 화면 Settings › Network › DNS › DNS Resolver. 관리 API 로는 `x:DnsResolver` — 지금은 `@type: System`, 다른 유형의 필드 이름은 관리 화면에서 확인).
|
||||
- DNSSEC 검증도 되어 기동 로그의 "DNS resolver cannot validate DNSSEC. DANE has been disabled" 경고가 사라진다.
|
||||
2. **Spamhaus 이용 조건을 맞춘다.**
|
||||
- 공개 목록(public mirror)은 소규모·비상업용이다. 회사 메일 서버는 **무료 DQS 키**(사용량 한도 안)를 발급받아
|
||||
Spamhaus 두 항목(`STWT_RBL_SPAMHAUS_IP`, `STWT_DBL_SPAMHAUS_DOMAIN`)의 조회 주소를 DQS 주소로 바꾸거나,
|
||||
조건이 맞지 않으면 두 항목을 끈다 (`x:SpamDnsblServer/set` 으로 `enable: false` → `ReloadSettings`).
|
||||
- 나머지 목록도 운영 전에 각 서비스의 이용 조건(상업적 사용, 조회량 한도, 사전 등록 필요 여부)을 확인하고 맞지 않는 것은 끈다.
|
||||
3. **조회 거부 응답의 처리를 시험한다 (미검증).**
|
||||
`127.255.255.254` 같은 "조회 거부" 응답을 "목록에 있음"으로 처리하면 정상 메일까지 스팸 점수를 받는다는 사용자 보고가 있다(Stalwart 공식 확인 아님).
|
||||
운영 전에 외부에서 시험 메일을 받아 스팸 판정 기록에 Spamhaus 항목이 잘못 붙지 않는지 확인한다.
|
||||
|
||||
```bash
|
||||
# 리졸버가 Spamhaus 에 막혀 있는지 확인 — 127.0.0.2 는 항상 등록된 시험 주소다.
|
||||
# 정상이면 127.0.0.x 가 돌아오고, 127.255.255.254 면 그 리졸버는 막혀 있다.
|
||||
sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org <리졸버 IP>
|
||||
```
|
||||
|
||||
참고: [Spamhaus — 공용 DNS 로 무료 목록 쓰기](https://www.spamhaus.com/resource-center/successfully-accessing-spamhauss-free-block-lists-using-a-public-dns/),
|
||||
[Spamhaus — KT 경유 조회 안내](https://submit.spamhaus.org/resources/query-the-legacy-dnsbls-via-korea-telecom/),
|
||||
[Stalwart DNSBL 문서](https://www.stalw.art/docs/spamfilter/dnsbl)
|
||||
|
||||
---
|
||||
|
||||
## arasystem.it.kr 전환 절차 (mailcow → Stalwart)
|
||||
|
||||
현재 (DNS: 가비아):
|
||||
|
||||
| 레코드 | 값 |
|
||||
| --- | --- |
|
||||
| `arasystem.it.kr` MX | `10 mail.arasystem.it.kr` (mailcow) |
|
||||
| `arasystem.it.kr` TXT (SPF) | `v=spf1 mx ip4:46.250.248.43 -all` |
|
||||
| `_dmarc.arasystem.it.kr` TXT | `v=DMARC1; p=none; rua=mailto:dmarc@arasystem.it.kr` |
|
||||
| `dkim._domainkey.arasystem.it.kr` TXT | mailcow DKIM (selector `dkim`) |
|
||||
|
||||
원칙:
|
||||
|
||||
- **메일박스를 먼저 만들고 MX 를 바꾼다.** 거꾸로면 없는 주소로 들어온 메일이 반송(550)된다.
|
||||
- 새 엔진은 **새 호스트명(`mx1.arasystem.it.kr`)** 으로 띄우고 **MX 대상만** 바꾼다.
|
||||
`mail.arasystem.it.kr` 은 끝까지 mailcow 를 가리키므로, MX 만 되돌리면 롤백된다.
|
||||
- SPF 가 `-all` 이다. **새 서버 IP 를 SPF 에 넣기 전에** 새 서버에서 외부로 보내면 수신 측이 거부하거나 스팸 처리한다.
|
||||
|
||||
| 단계 | 할 일 | 확인 |
|
||||
| --- | --- | --- |
|
||||
| 0. TTL 낮추기 (D-3 이상) | MX·SPF TXT·`mx1` A 의 TTL 을 300초로. **기존 TTL 만큼** 기다린 뒤 다음 단계 | `dig +noall +answer MX arasystem.it.kr` 의 TTL |
|
||||
| 1. 새 호스트 | `mx1.arasystem.it.kr` A → 새 IP, PTR → `mx1.arasystem.it.kr`, `.env.production` 에 `MAIL_HOSTNAME`·`MAIL_PUBLIC_URL` = mx1, [운영 배포](#운영-배포) | 컨테이너 로그에 인증서 발급, `openssl s_client -connect mx1.arasystem.it.kr:993` |
|
||||
| 2. 같은 주소 생성 | 모든 직원 주소·별칭·공용 주소 + **`dmarc@`**(현재 DMARC 리포트 수신처) + `postmaster@`(Stalwart 기본 리포트 수신처) | 관리 화면 계정 목록 |
|
||||
| 3. DKIM selector 추가 | setup 이 출력한 `v1-ed25519-YYYYMMDD._domainkey`·`v1-rsa-YYYYMMDD._domainkey` TXT 를 **추가**. mailcow 의 `dkim._domainkey` 는 그대로 둔다 (selector 가 달라 공존) | `dig TXT v1-rsa-YYYYMMDD._domainkey.arasystem.it.kr` |
|
||||
| 4. SPF 갱신 | `v=spf1 mx ip4:46.250.248.43 ip4:<새 IP> -all` (+ 권장: `mx1.arasystem.it.kr TXT "v=spf1 a -all"`) — **MX 변경·외부 발송 전에** | 새 서버에서 외부(본인 Gmail 등)로 1통 → 헤더의 `spf=pass dkim=pass` |
|
||||
| 5. 메일 이전 1차 | `imapsync` 로 mailcow → Stalwart 전체 동기화 (아래) | 폴더별 메일 수 비교 |
|
||||
| 6. MX 전환 | `arasystem.it.kr MX 10 mx1.arasystem.it.kr` 로 교체. mailcow 는 **계속 켜 둔다** (전파 중에는 mailcow 로도 들어온다) | 외부에서 보낸 메일이 새 엔진 로그에 수신 |
|
||||
| 7. 증분 동기화 | MX 전환 직후 · TTL 경과 후 · 정리 직전에 `imapsync` 를 다시 (이미 옮긴 메일은 건너뛴다) | mailcow 로그에 새 수신 없음 |
|
||||
| 8. 병행 1~2주 후 정리 | SPF 에서 `ip4:46.250.248.43` 제거, `dkim._domainkey` 삭제, mailcow 중지. 필요하면 `mail.arasystem.it.kr` 을 새 호스트로 옮기고(인증서 포함) `MAIL_PUBLIC_URL` 변경 | — |
|
||||
|
||||
롤백: MX 를 `10 mail.arasystem.it.kr` 로 되돌린다. 그 사이 새 엔진에 들어온 메일은 반대 방향 `imapsync` 로 옮긴다.
|
||||
|
||||
주의할 레코드:
|
||||
|
||||
- **DMARC** — setup 이 출력하는 권장값은 `p=reject; rua=mailto:postmaster@…` 다. 전환 중에는 **지금의 `p=none`·`rua=dmarc@` 를 유지**하고,
|
||||
리포트로 두 서버 모두 통과하는 것을 본 뒤 `quarantine` → `reject` 로 올린다.
|
||||
- **MTA-STS** — 정책이 MX 와 어긋나면 MTA-STS 를 지키는 발신자가 배달에 실패한다. **전환이 끝난 뒤** 게시한다.
|
||||
- **SRV·autoconfig·autodiscover** — 메일 클라이언트(IMAP) 자동 설정용. 웹메일만 쓰면 필요 없다.
|
||||
- **DKIM 자동 교체** — 도메인 설정이 `dkimManagement: Automatic`(90일마다 새 selector, 7일 뒤 이전 키 은퇴)이고 DNS 는 수동(가비아)이다.
|
||||
교체된 키의 TXT 가 DNS 에 없으면 서명 검증이 실패하므로, **운영 전에 관리 화면에서 교체 방식을 확인**하고
|
||||
교체 때마다 새 selector 를 등록하거나 자동 교체를 끈다. (교체 시점의 동작은 확인하지 않았다)
|
||||
|
||||
### imapsync
|
||||
|
||||
```bash
|
||||
imapsync \
|
||||
--host1 mail.arasystem.it.kr --ssl1 --user1 '<직원 주소><구분자><마스터 계정>' --password1 '<마스터 비밀번호>' \
|
||||
--host2 mx1.arasystem.it.kr --ssl2 --user2 '<직원 주소>' --password2 '<새 엔진 초기 비밀번호>' \
|
||||
--automap --syncinternaldates
|
||||
```
|
||||
|
||||
- 원본(mailcow)은 **Dovecot 마스터 계정**으로 직원 비밀번호 없이 읽는다. mailcow 의 마스터 계정 설정 방법과
|
||||
로그인 형식(Dovecot 기본 구분자는 `*`)은 **mailcow 문서를 확인해야 한다** (여기서는 검증하지 않았다).
|
||||
- 대상(Stalwart)은 계정을 만들 때 정한 초기 비밀번호로 로그인한다. 게이트웨이 대리 접근(`%`)은 HTTP 에서만 검증했고 IMAP 에서는 확인하지 않았다.
|
||||
게이트웨이는 신뢰 대역에서만 로그인되므로 외부에서 쓸 수도 없다.
|
||||
- 직원 비밀번호는 정책 결정이 필요하다 — 새 초기 비밀번호를 배포하고 첫 로그인 때 바꾸게 하거나,
|
||||
mailcow 의 비밀번호 해시를 그대로 옮길 수 있는지 검토한다 (미검증).
|
||||
|
||||
---
|
||||
|
||||
## 백업
|
||||
|
||||
| 볼륨 | 내용 |
|
||||
| --- | --- |
|
||||
| `<PROJECT_NAME>_mail-config` | `config.json` (작다). **없으면 bootstrap 모드로 뜬다** |
|
||||
| `<PROJECT_NAME>_mail-data` | RocksDB — 메일·계정·설정 전체 |
|
||||
|
||||
RocksDB 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 **잠시 멈추고** 받는다.
|
||||
멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다.
|
||||
|
||||
```bash
|
||||
sudo docker compose --env-file .env.production stop mail
|
||||
sudo docker run --rm -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_NAME}_mail-data:/data:ro" -v "$PWD/backup:/backup" \
|
||||
alpine tar czf "/backup/mail-$(date +%Y%m%d-%H%M).tar.gz" -C / config data
|
||||
sudo docker compose --env-file .env.production start mail
|
||||
```
|
||||
|
||||
- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /` , 소유자 UID 2000 유지) 컨테이너를 띄운다. 복원 연습을 주기적으로 한다.
|
||||
- 백업 파일은 다른 장소에도 둔다. 메일 전체가 들어 있으므로 접근을 제한한다.
|
||||
|
||||
---
|
||||
|
||||
## 보안
|
||||
|
||||
- **게이트웨이 비밀번호 = 관리자급 비밀.** 전 직원 메일함의 열쇠일 뿐 아니라, **관리자 계정도 대리 접근된다** —
|
||||
`<관리자 주소>%<게이트웨이 주소>` 로 로그인하면 관리 API(`x:Account/get` 등)가 열린다 (백엔드 확인, setup 이 매번 `[주의]` 로 보여 준다).
|
||||
- Stalwart 0.16.27 에는 대리 접근 대상을 제한하는 방법이 없다 (2026-10-27 확인). 역할 권한(`enabledPermissions`)은 접속하는 쪽의 권한만 정하고,
|
||||
테넌트로 나누면 테넌트에 든 게이트웨이는 `impersonate` 권한 자체를 잃는다 — 테넌트 권한을 `Merge`·`Replace` 로 주거나 계정에 직접 줘도
|
||||
모든 대리 접근이 403(`security.unauthorized details = "impersonate"`) 이었다.
|
||||
- 그래서 막는 일은 백엔드가 한다 — 관리자(`MAIL_ADMIN_LOCAL_PART`)·`MAIL_BLOCKED_ACCOUNTS` 계정은 웹메일 로그인·대리 접근 대상에서 뺀다
|
||||
(+ 대리 접근한 세션에 관리 권한이 있으면 거부하는 2차 확인). 관리자 계정을 더 만들면 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다 (빠지면 setup 이 `[주의]`).
|
||||
- **지킬 것**: 백엔드 env(루트 `.env.production`)에만 둔다 · 다른 곳에 재사용하지 않는다(관리자·DB 비밀번호와도 다르게) · 무작위로 길게 ·
|
||||
정기 교체 · 게이트웨이 허용 IP(신뢰 대역 = `APP_NETWORK_SUBNET`)를 넓히지 않는다 — 이 제한이 바깥에서의 사용을 막는 유일한 장치다.
|
||||
- **교체 절차**: `.env.production` 의 `MAIL_GATEWAY_PASSWORD` 를 새 무작위 값으로 → `node --env-file=.env.production mail/scripts/setup.mjs`
|
||||
(운영 배포 3 과 같은 형태 — `[변경] 게이트웨이 … 비밀번호`) → `sudo docker compose --env-file .env.production up -d backend` (새 값으로 다시 만든다).
|
||||
setup 과 backend 재생성 사이에는 웹메일이 `MAIL_001` 로 잠시 멈춘다 — 사용이 적은 시간에 한다. 사용 기록은 `auth.success` 로그에 남는다.
|
||||
- **신뢰 대역 = 고정한 앱 네트워크 서브넷.** compose 가 app-network 서브넷을 `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`)으로 고정하고,
|
||||
setup 은 그 안의 대역만 신뢰 대역으로 받는다 (더 넓으면 실패). 예전처럼 도커 주소 풀 전체(172.16.0.0/12·192.168.0.0/16)를 믿지 않는다.
|
||||
app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만 붙인다 — **인터넷을 향한 frontend 는 edge 네트워크로만 backend 에 닿는다**
|
||||
(frontend 가 붙어 있으면 그 주소가 신뢰 대역에 들고 `mail:8080` 관리 API 에 닿는다. setup 이 구성원을 확인한다).
|
||||
- **8080 은 절대 공개하지 않는다** — 평문 HTTP 관리 API·JMAP 이다.
|
||||
- **공개 443 은 ACME·MTA-STS 만** — setup 이 `x:Http` 싱글톤의 `allowedEndpoints` 를
|
||||
`listener == 'https' && path != '/.well-known/mta-sts.txt'` → 403 으로 둔다. 웹메일은 백엔드 → 내부 `http`(8080)라 영향이 없다.
|
||||
- 2026-10-26 확인: https 의 `/jmap/session`·`/.well-known/jmap`·`/admin/`·`/account/`·`/login`·OAuth 디스커버리·`/healthz` 는 403,
|
||||
`/.well-known/mta-sts.txt` 는 200, 내부 8080 은 그대로. 호스트·다른 컨테이너·AllowedIp 에 든 주소에서 와도 403 이다.
|
||||
- 식의 변수는 `listener`·`path`(그 밖에 `url`·`method`·`remote_ip`·`local_ip`·`local_port`·`protocol`·`headers`·`is_tls`)가 통하고
|
||||
`url_path`·`host` 등은 저장할 때 거부된다. 바꾼 뒤에는 `ReloadSettings` 가 있어야 반영된다.
|
||||
- ⚠️ **루프백(127.0.0.1)에서 온 요청은 정책을 건너뛴다** — 메일 컨테이너 안에서 `https://127.0.0.1` 로 시험하면 200 이 나온다.
|
||||
확인은 컨테이너의 네트워크 주소로 한다 (setup 이 그렇게 확인한다). 이미지 healthcheck 는 https 실패 시 http 8080 으로 대체되어 영향이 없다.
|
||||
- ACME(TLS-ALPN-01)는 TLS 단계에서 끝나 HTTP 경로 제한과 무관하다 (운영 인증서 발급으로는 아직 확인하지 않았다).
|
||||
- 관리 화면은 SSH 터널로 내부 8080 에 붙는다: `ssh -L 8180:<mail 컨테이너 IP>:8080 <메일 호스트>` → <http://localhost:8180/admin/>.
|
||||
직원 셀프서비스(`/account/` — 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 필요해지면 백엔드 API 로 만든다.
|
||||
- **직원은 계정 목록을 볼 수 없다.** 기본 `User` 역할에는 `Principal/get` 으로 관리자·게이트웨이를 포함한 전체 계정을 보는 권한
|
||||
(`jmapPrincipal*`·`davPrincipal*` 13개)이 있어서, setup 이 뺀다 (역할 권한은 캐시되므로 `InvalidateCaches` 로 반영).
|
||||
- 2026-10-26 확인: 테스트 직원의 `Principal/get` → forbidden. 백엔드를 거친 웹메일(폴더·목록·읽기·첨부 올리기/내려받기·발송·중복 발송 방지)은 그대로 동작.
|
||||
- ⚠️ 되돌리기는 **일반 관리자로는 안 된다** — 관리자 역할(System Administrator)에 이 권한이 없어 "You are not authorized to grant permissions" 로 거부된다.
|
||||
복구 관리자(일회성)로 `x:Role/set` `update {"<User 역할 id>": {"enabledPermissions/jmapPrincipalGet": true, …}}` → `InvalidateCaches` 를 한다.
|
||||
(setup 은 다음 실행 때 다시 뺀다 — 계속 필요하면 `ensureUserRoleWithoutPrincipals` 단계를 먼저 고친다)
|
||||
- **복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다** (검증함). 운영은 `.env.production` 에 두지 않고
|
||||
일회성 override + `--recovery` 로만 쓴다 — 파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 ("운영 배포" 4·5).
|
||||
- **포트는 IPv4(`0.0.0.0`)로만 공개한다.** 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가 중계해 컨테이너에는
|
||||
도커 게이트웨이 주소로 보인다 → 신뢰 대역으로 취급되어 자동 차단 예외와 게이트웨이 IP 제한이 무력화된다.
|
||||
IPv6 수신이 필요하면 Docker 데몬의 IPv6(ip6tables)를 켜 원본 주소가 보존되는 것을 확인한 뒤 바인딩을 추가한다.
|
||||
- `X-Forwarded-For` 는 기본 설정에서 신뢰하지 않는다 (`x:Http` 의 `useXForwarded: false` — 위조 헤더로 IP 판정이 바뀌지 않음을 검증).
|
||||
프록시 뒤에서 이를 켤 때는 프록시 주소로 한정해야 한다.
|
||||
- 신뢰 대역은 자동 차단에서 빠진다 → 무차별 대입 방어는 백엔드 rate limit 이 담당한다.
|
||||
- 비밀번호는 영문·숫자로만 — `#`·따옴표·`$`·공백은 compose 와 Node(`--env-file`, `#` 부터 주석)가 다르게 읽는다.
|
||||
|
||||
---
|
||||
|
||||
## 리버스 프록시와 같은 호스트에 둘 때 (대안)
|
||||
|
||||
기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (문서로만 정리 — 검증하지 않았다)
|
||||
|
||||
1. **IP 를 하나 더 붙인다 (가장 단순)** — 메일 포트를 그 IP 에만 바인딩: `"<메일 전용 IP>:443:443"` 등. 나머지는 그대로.
|
||||
2. **SNI 패스스루** — L4 프록시(nginx `stream` + `ssl_preread`, HAProxy, Traefik TCP)가 SNI 가 `mx1.arasystem.it.kr` 인 TLS 를
|
||||
복호화하지 않고 mail 의 443 으로 넘긴다. Stalwart 가 TLS 와 ACME(TLS-ALPN-01)를 그대로 처리한다. compose 에서 mail 의 443 매핑을 뺀다.
|
||||
- ⚠️ **mail 과 L4 프록시만 붙는 전용 네트워크**를 새로 만든다 (예: `<PROJECT_NAME>_mail-ingress`, 서브넷 고정, 다른 컨테이너를 붙이지 않는다).
|
||||
**함께 쓰는 `proxy` 네트워크나 app-network 에 mail 을 붙이지 않는다** — 그 네트워크의 모든 컨테이너가 `mail:8080`(평문 관리 API·JMAP)에 닿는다.
|
||||
- 프록시의 대상은 서비스 이름(`mail`)이 아니라 `<PROJECT_NAME>-mail:443` 으로 적는다 (다른 프로젝트의 `mail` 과 겹치지 않게).
|
||||
```nginx
|
||||
stream {
|
||||
map $ssl_preread_server_name $upstream {
|
||||
mx1.arasystem.it.kr <PROJECT_NAME>-mail:443; # 전용 네트워크로만 닿는다
|
||||
default 127.0.0.1:8443; # 기존 웹 TLS 종단
|
||||
}
|
||||
server { listen 443; ssl_preread on; proxy_pass $upstream; }
|
||||
}
|
||||
```
|
||||
3. **프록시가 TLS 종단 → `mail:8080`** — 993·465 용 인증서는 여전히 Stalwart 가 받아야 하므로 ACME 를 HTTP-01 등으로 바꿔야 한다.
|
||||
|
||||
⚠️ 2·3 은 Stalwart 에 **프록시 주소가 접속 주소로 보인다.** 프록시를 app-network(신뢰 대역 `APP_NETWORK_SUBNET`)에 두면
|
||||
인터넷 접속이 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다(setup 의 구성원 확인에서도 걸린다). 프록시는 반드시 app-network 밖(위의 전용 네트워크)에 두고,
|
||||
PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요) 또는 그 프록시로만 한정한 `X-Forwarded-For`(3)로 원본 주소를 넘긴다.
|
||||
3 은 공개 443 정책(`listener == 'https'`)이 `http` 리스너에는 걸리지 않으므로 프록시 쪽에서 `/jmap`·관리 경로를 따로 막아야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
| 증상 | 조치 |
|
||||
| --- | --- |
|
||||
| `셸 환경변수가 env 파일 값을 가리고 있습니다` | 표시된 변수를 지우고 다시 (`unset APP_ENV` · PowerShell `Remove-Item Env:APP_ENV`) |
|
||||
| `메일 엔진에 연결할 수 없습니다` | `COMPOSE_PROFILES=mail` 로 떠 있는지, `MAIL_ADMIN_PORT` 가 맞는지. 운영은 `MAIL_ADMIN_URL` 로 컨테이너 내부 주소를 준다 ("운영 배포" 3) |
|
||||
| `운영에서는 docker 를 쓸 수 있어야 합니다` | docker 그룹 사용자로, 또는 `sudo -E env "PATH=$PATH" node …` 로 호스트에서 실행 ("운영 배포" 3). 아무것도 바뀌지 않았다 |
|
||||
| `임시 비밀번호 파일 자리(…/data/mail)에 쓸 수 없어 bootstrap 을 시작하지 않고 중단합니다` | 아무것도 바뀌지 않았다. `data/`·`data/mail/` 을 실행하는 사용자 소유로 (`sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail`) 또는 늘 `sudo -E` 로 — 같은 명령 다시 |
|
||||
| bootstrap 직후 멈춤 (`bootstrap 은 끝났지만 임시 비밀번호를 보관하지 못했습니다` 등) | [bootstrap 직후에 멈췄다면](#bootstrap-직후에-멈췄다면-다시-실행하기-전에) — 볼륨을 지우지 말고 원인을 고친 뒤 같은 명령(`--recovery`) 다시. config.json 으로 판단해 재시작만 한다 |
|
||||
| `재시작 후에도 bootstrap 모드입니다 (config.json 은 이미 있음)` | 설정 파일이 있는데 엔진이 bootstrap 모드로 떴다 — `docker logs "${PROJECT_NAME}-mail"` 의 기동 오류를 본다. `Bootstrap/set` 을 다시 부르지 않는다 |
|
||||
| `bootstrap 이 거부되었습니다` + `primaryKeyViolation` | 데이터 볼륨에는 이전 설치가 있는데 설정 볼륨에 config.json 이 없다 (예: `_mail-config` 만 지움). **새 설치**면 두 볼륨을 다 지우고 처음부터 (`docker volume rm "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data"`), **운영 중이던 서버**면 백업에서 `_mail-config` 를 복원한다 |
|
||||
| `사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다` | 아래 줄의 항목을 고치고 다시 — 엔진 설정은 그대로다 |
|
||||
| `앱 네트워크 … 에 신뢰 대역에 있으면 안 되는 컨테이너가 붙어 있습니다` | `docker network disconnect "${PROJECT_NAME}_app-network" <컨테이너>`, compose 의 `networks` 확인 (frontend 는 edge 만). 개발은 경고만 |
|
||||
| `APP_NETWORK_SUBNET(…) — 접두 길이는 /24 ~ /28 …` · `네트워크 주소로 …` · `사설 대역 …` | 사설 대역의 네트워크 주소, `/24`~`/28` 로 (예: `172.16.71.0/24`). 바꾸면 `down` 후 `up -d` 로 네트워크를 다시 만든다 |
|
||||
| `메일 엔진의 관리자(admin@…)와 MAIL_ADMIN_LOCAL_PART(…) 가 다릅니다` | bootstrap 뒤에 `MAIL_ADMIN_LOCAL_PART` 만 바꿨다 — **bootstrap 전에만 바꿀 수 있다.** (1) env 를 엔진 관리자의 아이디로 되돌리고 `up -d backend` (가장 간단). (2) 꼭 바꿔야 하면: 복구 관리자로 엔진의 관리자 계정 아이디를 바꾼다(관리 화면 › 계정, 또는 `x:Account/set` `update {"<id>": {"name": "<새 아이디>"}}` — 주소가 함께 바뀐다) → env 를 같은 값으로 → `up -d backend` → setup 재실행 |
|
||||
| `…(MAIL_ADMIN_LOCAL_PART)로 로그인은 되지만 관리 권한이 없습니다` · `… 는 메일 엔진의 관리자 계정이 아닙니다` | `MAIL_ADMIN_LOCAL_PART` 가 직원 계정을 가리킨다 — 엔진의 관리자 아이디로 맞춘다 (메시지에 엔진의 관리자 목록이 나온다) |
|
||||
| `… 의 로그인 확인이 예상하지 못한 응답으로 끝났습니다 (HTTP 429·5xx)` | 비밀번호를 덮어쓰지 않으려고 멈췄다. 429 면 몇 분 뒤, 5xx 면 `docker logs` 로 엔진 상태를 본 뒤 다시 |
|
||||
| `[주의] 게이트웨이로 관리자 … 를 대리 접근하면 관리 API 가 열립니다` | 알려진 남은 위험이다 (Stalwart 0.16 은 막을 수 없다) — [보안](#보안)의 게이트웨이 비밀번호 수칙을 지킨다. 실패가 아니다 |
|
||||
| `[주의] 관리자 역할 계정 … 가 백엔드 차단 목록에 없습니다` | 그 주소를 `MAIL_BLOCKED_ACCOUNTS` 에 넣고 `up -d backend`, 쓰지 않는 관리자라면 지운다 |
|
||||
| `관리자로 로그인할 수 없습니다` | 개발: `MAIL_RECOVERY_ADMIN` 을 채워 `up -d mail` 후 setup 재실행. 운영: "운영 배포" 1)·`up -d mail` → `--recovery` → 4)·5) |
|
||||
| `[재시작 필요]` · `[후속 조치]` (종료 코드 3) | 안내대로 한 뒤 같은 명령으로 다시 (운영 `--recovery` 뒤에는 `--recovery` 없이) |
|
||||
| `MAIL_TRUSTED_NETWORKS 의 … 가 APP_NETWORK_SUBNET(…) 보다 넓거나 그 밖에 있습니다` | `MAIL_TRUSTED_NETWORKS` 를 비우거나(= 서브넷) 서브넷 안의 더 좁은 대역으로 |
|
||||
| `앱 네트워크 … 의 서브넷(…)이 APP_NETWORK_SUBNET(…) 과 다릅니다` | 서브넷을 고정하기 전에 만든 네트워크 — `down` 후 `up -d` 로 다시 만들고 setup 재실행 |
|
||||
| 서브넷을 바꾼 뒤 백엔드가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 | 네트워크만 새로 만들어지고 다른 컨테이너가 서비스 이름 별칭을 잃었다 — `up -d --force-recreate` (또는 `down` 후 `up -d`) |
|
||||
| `운영 env 파일에 MAIL_RECOVERY_ADMIN 가 들어 있습니다` 등 복구 관리자 오류 | 파일에서 지우고 셸에서 일회성으로만 준다 ("운영 배포") |
|
||||
| `… 에 복구 관리자(STALWART_RECOVERY_ADMIN)가 남아 있습니다` | `unset MAIL_RECOVERY_ADMIN` 후 `docker compose --env-file .env.production up -d mail` |
|
||||
| `백엔드 주소 … 가 신뢰 대역(…) 밖입니다` | `MAIL_TRUSTED_NETWORKS` 를 비우고(= `APP_NETWORK_SUBNET`) setup 재실행 |
|
||||
| `공개 443 차단 확인 실패` | setup 이 `ReloadSettings` 까지 했는지, `x:Http` 의 `allowedEndpoints` 가 위 식인지 (관리 화면 › Settings › HTTP) |
|
||||
| `Password is too weak` | 더 길고 무작위인 값으로 (예: `change-me-in-local-env` 는 통과, `devpassword1` 은 거부) |
|
||||
| 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 `[확인] 설정 리로드 완료` 가 나왔는지. 로그: `docker logs "${PROJECT_NAME}-mail"` 의 `delivery.*` |
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user