generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정
웹메일 1단계를 개발 환경에서 띄우고 운영에 올리기 위한 인프라 구성이다. - 메일 엔진 Stalwart(v0.16.27)와 개발용 Mailpit 을 compose profile `mail` 로 추가한다. 초기 설정은 mail/scripts/setup.mjs 가 맡는다 — 여러 번 실행해도 안전하고, 운영에서는 사전 검사가 실패하면 아무것도 바꾸지 않고 멈춘다 - 화면과 API 를 한 주소로 배포한다: 프론트 nginx 가 /api 를 백엔드로 넘기고, 본문 한도·시간 제한·헤더 전달을 env 와 맞춘다. nginx 가 직접 내는 오류도 표준 실패 형식이다 - 인터넷을 향한 nginx 는 edge 네트워크로 백엔드에만 닿고, DB·메일 엔진은 app-network 에 둔다 (메일 엔진의 신뢰 대역도 app-network 뿐) - Node 24.22.1·npm 11.6.2·nginx 1.30.6 을 Dockerfile 과 CI 에 한 값으로 고정한다 - 개발 호스트 포트는 127.0.0.1 로 한정하고, data/ 는 클론 때부터 사용자 소유가 되게 .gitkeep 을 추적한다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+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
|
||||
Reference in New Issue
Block a user