웹메일 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>
49 KiB
메일 엔진 (Stalwart)
사내 메일은 Stalwart(JMAP) 를 자체 호스팅한다. 직원 로그인 = 메일 계정이다. 웹메일 UI 는 자체 제작하고, 백엔드(NestJS)가 내부 JMAP 으로 메일 엔진에 중계한다.
- 이미지:
stalwartlabs/stalwart:v0.16.27— 패치까지 고정 (비-rootstalwart, UID 2000 · healthcheck 내장. 올릴 때는 docker 스킬 "이미지 버전 고정") - 설정값 SSOT: 루트
.env.{APP_ENV}의 메일 섹션 (env-structure) - 초기화·계정 구성:
scripts/setup.mjs— 의존성 없음, 여러 번 실행해도 안전 - 이름은 루트 env 의
PROJECT_NAME을 따른다 — 컨테이너${PROJECT_NAME}-mail, 볼륨${PROJECT_NAME}_mail-config·_mail-data, 네트워크${PROJECT_NAME}_app-network(신뢰 대역)·${PROJECT_NAME}_edge(frontend ↔ backend). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다: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 |
개발 시작
# 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등)만 쓴다. - 개발 메일 데이터를 버리고 처음부터:
(
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 스크립트가 하는 일
- 사전 확인 (아무것도 바꾸기 전) — app-network 의 실제 서브넷이
APP_NETWORK_SUBNET인지, 앱 네트워크에 정해진 컨테이너 (backend·db·redis·mail·mailpit)만 붙어 있는지, 백엔드 주소가 신뢰 대역 안인지. 어긋나면 엔진 설정을 바꾸지 않고 중단한다 (신뢰 대역을 바꾼 뒤에 알면 전 직원의 메일이 멈추고 백엔드 주소가 자동 차단 예외에서 빠진다). 앱 네트워크에 다른 컨테이너가 있으면 운영은 중단, 개발은 경고. - 모드 감지 — bootstrap(첫 기동, 8080 만 열림) / bootstrap 완료·재시작 대기 / 정상
- bootstrap — 호스트명·기본 도메인·DKIM 키(ed25519·rsa)·로그(표준 출력) → 재시작.
응답의 무작위 관리자 비밀번호는 한 번만 보이므로
data/mail/bootstrap-admin.json(git 무시)에 잠시 보관했다가 교체 후 지운다. ⚠️Bootstrap/set은 원자적이지 않다 — 다시 부르면 도메인 중복으로 거부되면서도 tracer 가 하나 더 생겨 설정 오류가 난다. 그래서- 부르기 전에
data/mail/에 실제로 써 보고, 쓸 수 없으면 아무것도 바꾸지 않고 멈춘다 - 재실행 때는 mail 컨테이너의
/etc/stalwart/config.json으로 "bootstrap 은 끝났고 재시작만 남았다"를 알아보고 재시작만 한다 (Bootstrap/set 은 재시작 전에 이미 이 파일을 쓴다 — 보관 파일이 없어도 다시 부르지 않는다. docker 를 쓸 수 없으면 보관 파일로 판단)
- 부르기 전에
- 관리자 —
<MAIL_ADMIN_LOCAL_PART>@도메인비밀번호를MAIL_ADMIN_PASSWORD로 맞춘다 (나중에 env 값을 바꾸면 복구 관리자로 다시 맞춘다 — 운영은--recovery). 갓 bootstrap 한 엔진이면 bootstrap 이 만든admin@의 아이디를MAIL_ADMIN_LOCAL_PART로 바꾼다 (Bootstrap/set 은 아이디를 받지 않는다). 이미 쓰던 엔진에서 env 아이디의 관리자가 없으면(bootstrap 뒤에 값만 바꾼 경우) 중단한다 - 자동 차단 예외 — 신뢰 대역을 AllowedIp 로 등록 (기본 보안 설정은 IP 당 인증 실패 하루 100회면 차단하는데, 모든 로그인이 백엔드 IP 하나를 거친다).
신뢰 대역 =
MAIL_TRUSTED_NETWORKS, 비우면APP_NETWORK_SUBNET(compose 가 고정한 app-network 서브넷). 서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다. 이 스크립트가 만든 예전 항목은 지운다. - 게이트웨이 — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만
- 잠금 — 직원(
User) 역할에서 Principal 조회 권한 13개(jmapPrincipal*·davPrincipal*)를 뺀다(→InvalidateCaches). 공개 443(https 리스너)의 HTTP 는/.well-known/mta-sts.txt만 열고 나머지는 403 (x:Http의allowedEndpoints) — 보안 - 설정 리로드 → 반영 확인 — 메일 컨테이너의 네트워크 주소로
https://…:443/jmap/session이 403, 내부 8080 이 200 인지 본다. 운영은 바깥에서 쓰는 주소 그대로(MAIL_PUBLIC_URL/jmap/session, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — 200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할curl명령을 보여 준다. 게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번[주의](보안) - [개발] 외부 발송 경로 — Relay
mailpit:1025+ 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 7 의 리로드로 반영된 것을 확인한 뒤에만 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 스킬 "운영 환경" |
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).
운영 배포
사전 조건:
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 대안 참조)
MAIL_HOSTNAME의 A 레코드 → 이 호스트 IP, 그 IP 의 PTR →MAIL_HOSTNAME(PTR)- 아웃바운드 25 허용 (클라우드는 기본 차단인 곳이 많다 — 막혀 있으면 외부 릴레이)
- 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다
- 메일 서버 전용 재귀 DNS — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막혀 스팸 판정이 틀어진다 (DNS 리졸버와 스팸 차단 목록)
- 스팸 차단 목록 이용 조건 — Spamhaus 는 회사용 무료 DQS 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절)
- 포털(웹메일) 앞의 외부 리버스 프록시 — 요구사항은 docker 스킬 "운영 환경" (같은 주소 배포 ·
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(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.
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 을 손으로 부르지 않는다.
- 엔진이 어디까지 갔는지 본다:
sudo docker exec "${PROJECT_NAME}-mail" test -s /etc/stalwart/config.json && echo "bootstrap 끝남" - 실패 원인을 고친다 — 출력의
[실패]줄.data/mail에 쓸 수 없다고 나왔다면 소유자를 바꾼다:sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail(sudo 로 돌릴 거라면 그대로 둔다 — 매번 같은 형태로) - 셸에
MAIL_RECOVERY_ADMIN이 남아 있는 채로 같은 명령(--recovery)을 다시 실행한다. bootstrap 이 끝났으면 재시작만 하고(config.json 으로 판단 — 임시 비밀번호 파일이 없어도 된다) 복구 관리자로 관리자 비밀번호·아이디를 맞춘다. 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 = …로 남는다).
# 바깥 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"]):
[
["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 와 별개 서비스라 각자 이용 조건이 있다.
- 메일 서버 전용 재귀 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" 경고가 사라진다.
- 공용·ISP DNS(KT
- Spamhaus 이용 조건을 맞춘다.
- 공개 목록(public mirror)은 소규모·비상업용이다. 회사 메일 서버는 무료 DQS 키(사용량 한도 안)를 발급받아
Spamhaus 두 항목(
STWT_RBL_SPAMHAUS_IP,STWT_DBL_SPAMHAUS_DOMAIN)의 조회 주소를 DQS 주소로 바꾸거나, 조건이 맞지 않으면 두 항목을 끈다 (x:SpamDnsblServer/set으로enable: false→ReloadSettings). - 나머지 목록도 운영 전에 각 서비스의 이용 조건(상업적 사용, 조회량 한도, 사전 등록 필요 여부)을 확인하고 맞지 않는 것은 끈다.
- 공개 목록(public mirror)은 소규모·비상업용이다. 회사 메일 서버는 무료 DQS 키(사용량 한도 안)를 발급받아
Spamhaus 두 항목(
- 조회 거부 응답의 처리를 시험한다 (미검증).
127.255.255.254같은 "조회 거부" 응답을 "목록에 있음"으로 처리하면 정상 메일까지 스팸 점수를 받는다는 사용자 보고가 있다(Stalwart 공식 확인 아님). 운영 전에 외부에서 시험 메일을 받아 스팸 판정 기록에 Spamhaus 항목이 잘못 붙지 않는지 확인한다.
# 리졸버가 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 로 무료 목록 쓰기, Spamhaus — KT 경유 조회 안내, Stalwart 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
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 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 잠시 멈추고 받는다. 멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다.
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로그에 남는다.
- Stalwart 0.16.27 에는 대리 접근 대상을 제한하는 방법이 없다 (2026-10-27 확인). 역할 권한(
- 신뢰 대역 = 고정한 앱 네트워크 서브넷. 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 로 만든다.
- 2026-10-26 확인: https 의
- 직원은 계정 목록을 볼 수 없다. 기본
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/setupdate {"<User 역할 id>": {"enabledPermissions/jmapPrincipalGet": true, …}}→InvalidateCaches를 한다. (setup 은 다음 실행 때 다시 뺀다 — 계속 필요하면ensureUserRoleWithoutPrincipals단계를 먼저 고친다)
- 2026-10-26 확인: 테스트 직원의
- 복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다 (검증함). 운영은
.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 을 쓰면 아래 중 하나를 택한다. (문서로만 정리 — 검증하지 않았다)
- IP 를 하나 더 붙인다 (가장 단순) — 메일 포트를 그 IP 에만 바인딩:
"<메일 전용 IP>:443:443"등. 나머지는 그대로. - 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과 겹치지 않게).
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; } } - ⚠️ mail 과 L4 프록시만 붙는 전용 네트워크를 새로 만든다 (예:
- 프록시가 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 직후에 멈췄다면 — 볼륨을 지우지 말고 원인을 고친 뒤 같은 명령(--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.* |