services: # ---------------------------------------- # 1. Frontend (Nginx — 화면 정적 호스팅 + /api 프록시) — 운영의 유일한 입구 # # 같은 주소(origin) 배포: 브라우저는 화면과 같은 주소의 /api 를 부르고, 이 nginx 가 백엔드로 넘긴다 # (docs/mail/api-contract.md 머리말 — CORS 없이 쿠키·CSP connect-src 'self' 로 동작한다). # 외부 리버스 프록시는 proxy 네트워크로 이 컨테이너(-frontend:8080)에만 붙는다. # # 네트워크 — 인터넷을 향한 컨테이너를 메일 엔진·DB 와 떼어 놓는다: # proxy 외부 리버스 프록시 ↔ frontend (다른 프로젝트와 함께 쓸 수 있다) # edge frontend ↔ backend 전용 # app-network backend ↔ db·redis·mail — 메일 엔진의 신뢰 대역이다. frontend 는 붙이지 않는다 # (붙이면 frontend 가 mail:8080 관리 API 에 닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다) # mail-dns mail ↔ mail-resolver(메일 전용 DNS) 전용 — 바깥 길 없음. 리졸버의 바깥 조회는 mail-dns-egress 로만 (6번 참조) # ---------------------------------------- frontend: container_name: ${PROJECT_NAME}-frontend build: context: ./${FRONTEND_DIR} dockerfile: Dockerfile args: - BUILD_MODE=${BUILD_MODE} # 같은 주소 배포라 상대경로로 고정한다 — 다른 주소를 넣으면 쿠키(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: - edge - proxy depends_on: - backend healthcheck: # localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 nginx 는 IPv4 만 바인딩하므로 127.0.0.1 을 명시한다 test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ || exit 1"] interval: 30s timeout: 5s retries: 5 start_period: 10s deploy: resources: limits: cpus: '0.5' memory: 512m # ---------------------------------------- # 2. Backend (HTTP API & WebSocket 통합) # ---------------------------------------- backend: container_name: ${PROJECT_NAME}-backend build: context: ./${BACKEND_DIR} dockerfile: Dockerfile expose: - "${BACKEND_PORT}" - "${WEBSOCKET_PORT}" # PID 1 을 tini 로 두어 SIGTERM 이 애플리케이션까지 확실히 전달되게 한다 (graceful shutdown) init: true # 처리 중인 요청을 마칠 시간 — 초과하면 SIGKILL (기본 10초) stop_grace_period: 30s env_file: - ./${BACKEND_DIR}/.env.${APP_ENV} environment: - NODE_ENV=${APP_ENV} - PORT=${BACKEND_PORT} - WEBSOCKET_PORT=${WEBSOCKET_PORT} # 화면의 공개 주소(origin, 콤마로 여러 개) — 상태 변경 요청의 출처 검사에 쓴다. 운영 필수 - CORS_ORIGIN=${CORS_ORIGIN} # 백엔드 앞의 프록시 홉 수(또는 주소 목록) — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수 # 외부 리버스 프록시 → 프론트 nginx → 백엔드 = 2. proxy 네트워크를 다른 프로젝트와 함께 쓰면 # 주소 목록(,<외부 프록시 IP>)을 권장한다 (루트 env 예시 참조) - TRUST_PROXY=${TRUST_PROXY:-} # 앱이 직접 쓰지는 않는다. 로컬 주소가 남은 채 운영에 배포되는 것을 # 기동 시점에 잡기 위해 검증용으로 주입한다 (shared/config/env.validation.ts) - BACKEND_API_URL=${BACKEND_API_URL} # 위 공개 주소 검사를 건너뛰는 탈출구 — 로컬에서 운영 이미지를 검증할 때만 true - ALLOW_LOCALHOST_URL=${ALLOW_LOCALHOST_URL:-} - THROTTLE_TTL_MS=${THROTTLE_TTL_MS:-60000} - THROTTLE_LIMIT=${THROTTLE_LIMIT:-100} - DB_HOST=db - DB_PORT=5432 - DB_USER=${DB_USER} - DB_PASSWORD=${DB_PASSWORD} - DB_NAME=${DB_NAME} - DB_SYNCHRONIZE=${DB_SYNCHRONIZE:-false} - UPLOAD_DIR=/app/uploads - UPLOAD_MAX_SIZE_MB=${UPLOAD_MAX_SIZE_MB:-5} - 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:-} # 사내 목록 계정 아이디 — mail setup 이 만든 비밀번호 없는 계정(<이 값>@MAIL_DOMAIN). 백엔드가 게이트웨이 대리 접근으로 # 엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는다 (docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md) - MAIL_DIRECTORY_LOCAL_PART=${MAIL_DIRECTORY_LOCAL_PART:-mail-directory} # 첨부 1개 최대 크기(MB). 앞단 리버스 프록시의 요청 본문 한도는 이보다 커야 한다 # (작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 — .claude/skills/docker/SKILL.md) - MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20} # 메일 한 통의 첨부 합계(MB) — 파일당 한도 이상이어야 한다 - MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=${MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB:-25} # 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다 — named volume _uploads. # 호스트 디렉터리(./data/uploads)를 쓰지 않는 이유: sudo docker compose 가 없는 디렉터리를 root 소유로 만들어 # backend(node)가 쓰지 못한다. 새 named volume 은 이미지의 /app/uploads(node 소유) 소유자를 물려받는다 (backend/Dockerfile). # (개발은 docker-compose.dev.yml 이 ./data/uploads 바인드 마운트로 바꾼다) # (메일 첨부는 업로드 중 /tmp/arasystem-mail-uploads 에 잠시 받는다 — 컨테이너 쓰기 계층 디스크. # read_only 로 바꾸면 그 경로에 tmpfs 가 아닌 볼륨을 붙인다: tmpfs 는 메모리 한도에 잡힌다) volumes: - uploads:/app/uploads restart: unless-stopped # 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다 networks: - app-network - edge depends_on: db: condition: service_healthy # redis 는 profile 로 비활성화될 수 있으므로 선택적 의존으로 둔다 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"] interval: 30s timeout: 5s retries: 5 start_period: 30s deploy: resources: limits: cpus: '1.0' memory: 1g # ---------------------------------------- # 3. Database (PostgreSQL) — 내부 네트워크 전용 # ---------------------------------------- db: container_name: ${PROJECT_NAME}-db image: ${DB_IMAGE} expose: - "5432" environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_NAME} volumes: - ${DB_DATA_DIR}:/var/lib/postgresql/data restart: always networks: - app-network healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] interval: 10s timeout: 5s retries: 5 start_period: 10s deploy: resources: limits: cpus: '0.5' memory: 512m labels: - "com.centurylinklabs.watchtower.enable=true" # ---------------------------------------- # 4. Redis — 내부 네트워크 전용 · 기본 비활성 # # 기본적으로 기동하지 않는다. 캐시/세션 공유가 실제로 필요해질 때 켠다. # 루트 .env.{APP_ENV} 에서 COMPOSE_PROFILES=redis # 또는 일회성으로 docker compose --profile redis up -d # ---------------------------------------- redis: profiles: ["redis"] container_name: ${PROJECT_NAME}-redis image: ${REDIS_IMAGE} command: redis-server --requirepass ${REDIS_PASSWORD} expose: - "6379" volumes: - ${REDIS_DATA_DIR}:/data restart: always networks: - app-network healthcheck: test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"] interval: 10s timeout: 5s retries: 5 start_period: 5s deploy: resources: limits: cpus: '0.25' memory: 256m 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 # 컨테이너의 DNS — 도커 DNS(127.0.0.11)가 도커 내부 이름(mailpit 등)은 직접 풀고 바깥 이름은 메일 전용 리졸버에 묻는다. # 엔진 자신의 DNS(x:DnsResolver)는 mail setup 이 맞춘다 — 운영은 이 리졸버에 바로(도커 DNS 를 거치지 않는다), 개발은 이 길(System) dns: - ${MAIL_RESOLVER_IP:-172.16.73.254} networks: - app-network - mail-dns # 리졸버가 답한 뒤에 뜬다 — 엔진은 기동할 때 리졸버가 DNSSEC 을 검증하는지 보고, 못 하면 DANE 을 끈다. # required: false — 평소에는 healthy 를 기다리고, 리졸버가 unhealthy 거나 떠 있지 못하면(설정 오류로 바로 끝남 · 재시작을 되풀이함) # compose 가 경고("optional dependency … failed to start")만 내고 mail 을 띄운다. 없으면 리졸버 오류 하나로 `up -d mail` 이 옛 mail 을 지운 뒤 # 새 mail 을 띄우지 않고 끝나 메일 전체가 선다 — 바깥 DNS 만 안 되는 편이 낫다 (2026-10-30 개발 Compose 2.40 에서 넷 다 확인, mail/README.md "리졸버 장애 때") depends_on: mail-resolver: condition: service_healthy required: false deploy: resources: limits: cpus: '1.0' memory: 1g # ---------------------------------------- # 6. Mail resolver (unbound) — 메일 엔진 전용 재귀 DNS · profile `mail` # # 엔진의 DNS 조회(스팸 차단 목록 DNSBL · SPF · DKIM · DMARC · MX · PTR · TLSA)를 공용 · ISP DNS 가 아니라 이 서버에서 직접 푼다. # Spamhaus 는 공용 · ISP DNS 를 거친 조회를 거부한다(127.255.255.254). DNSSEC 을 검증해 엔진의 DANE 이 켜진다. # 설정 mail/resolver/unbound.conf · mail setup 이 엔진의 x:DnsResolver 를 이 주소로 맞춘다 (mail/README.md "DNS 리졸버와 스팸 차단 목록"). # # 네트워크 — 메일 엔진만 닿게: # mail-dns mail ↔ 리졸버 전용. internal(바깥으로 나가는 길 없음). 리졸버 주소는 MAIL_RESOLVER_IP 로 고정한다 — # 엔진 설정 · mail 의 dns 가 주소(IP)만 받는다. 서브넷의 끝 쪽에 둔다(도커는 mail 에 앞쪽부터 차례로 준다) # mail-dns-egress 리졸버의 바깥 조회 길 — 리졸버 혼자 붙는다 # app-network(메일 엔진의 신뢰 대역)에는 붙이지 않는다 — mail setup 이 두 네트워크의 구성원을 확인한다 (운영은 그 밖이 있으면 중단) # 이미지는 판까지 고정한다 (docker 스킬 "이미지 버전 고정"). 1.26.2 는 NLnet Labs 의 보안 릴리스(2026-10-21)라 2주를 기다리지 않았다 # ---------------------------------------- mail-resolver: profiles: ["mail"] container_name: ${PROJECT_NAME}-mail-resolver # 공식이 아닌(커뮤니티) 이미지라 판에 digest 까지 묶는다 — 같은 태그를 누가 다시 올려도 바뀌지 않게 (2026-10-30 manifest list digest) image: klutchell/unbound:1.26.2@sha256:50cbeb32b5838a829a7226b19cb151f9535bd7f9f3190f950780f452fe807478 # 이미지는 root 로 떠서 53 에 붙은 뒤 내려가는 꼴이다 — 처음부터 비-root(이미지의 unbound 사용자 101:102)로 띄운다. # 53 에 붙는 것은 도커가 컨테이너마다 net.ipv4.ip_unprivileged_port_start=0 으로 두기 때문이다 — 권한(capability)은 모두 뺀다 user: "101:102" cap_drop: - ALL security_opt: - no-new-privileges:true volumes: # 이미지의 기본 설정을 통째로 바꾼다. DNSSEC 신뢰 앵커(/var/unbound/root.key)는 이미지의 것을 쓰고 컨테이너 안에서 고쳐 쓴다 - ./mail/resolver/unbound.conf:/etc/unbound/unbound.conf:ro restart: unless-stopped networks: mail-dns: ipv4_address: ${MAIL_RESOLVER_IP:-172.16.73.254} mail-dns-egress: {} healthcheck: # 데몬이 답하는지만 본다(자기 안의 localhost) — 바깥 DNS 가 잠깐 막혀도 mail 이 뜨지 못하는 일이 없게. # 바깥 조회 · DNSSEC 은 mail setup 이 메일 컨테이너에서 확인한다. drill-hc 는 이미지에 든 drill 의 종료 코드를 0 · 1 로 맞춘 것 test: ["CMD", "drill-hc", "localhost", "@127.0.0.1", "A"] interval: 30s timeout: 5s retries: 3 start_period: 10s deploy: resources: limits: cpus: '0.25' memory: 128m 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} mail-dns: # mail ↔ mail-resolver 전용. internal — 이 네트워크로는 바깥에 나가지 못한다(mail 의 바깥 길은 app-network 그대로). # 리졸버 주소(MAIL_RESOLVER_IP)를 고정하려고 서브넷을 고정한다. 겹치면 MAIL_RESOLVER_SUBNET · MAIL_RESOLVER_IP 를 함께 바꾼다 name: ${PROJECT_NAME}_mail-dns driver: bridge internal: true ipam: config: - subnet: ${MAIL_RESOLVER_SUBNET:-172.16.73.0/24} mail-dns-egress: # 리졸버가 루트 · 각 도메인의 DNS 서버에 묻는 길 — 리졸버 혼자 붙는다. 믿는 대역이 아니라 서브넷을 고정하지 않는다 — # 도커가 기본 주소 풀(172.17~31.x · 192.168.x)에서 정한다. 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail/README.md) name: ${PROJECT_NAME}_mail-dns-egress driver: bridge proxy: 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