2026년 8월 15일 · 10분 읽기
셀프호스팅 메일 서버: PTR 없는 발송 경로를 두 번 바꾼 기록
자체 도메인으로 메일을 주고받으려다 PTR이 없어 발송 경로를 두 번 바꾼 기록입니다. SPF와 DKIM, DMARC를 넣고 수신은 Stalwart에 맡겼으며, 복구용 리스너에서 502를 만난 경위까지 적었습니다.
2026년 8월 23일 업데이트
telnet 203.0.113.10 25가 붙었습니다. 그것으로 메일 서버가 동작한다고 말할 수는 없습니다.
포트가 열린 것과 남의 메일 서버가 우리 메일을 받아 주는 것은 다른 사실입니다. 사이에 PTR과 SPF와 DKIM과 DMARC가 있습니다.
처음에 고른 것은 Postal이었습니다. PTR이 없어서 발송 검증을 못 했고, relay로 우회하려다 다시 직접 보내는 쪽으로 되돌렸습니다. Postal은 실제로 올라가서 지금도 돌고 있고, 메일함은 나중에 올린 Stalwart가 받습니다. 한 클러스터에 메일 서버가 둘입니다.
그리고 Stalwart 관리 콘솔을 Ingress에 붙이자 502가 났습니다. 포트를 잘못 고른 것이 아니라 그 포트가 하는 일이 달랐습니다.
한눈에 보기
| 문제 | `telnet 203. |
| 결정 | 사용자와 이야기한 뒤 Authentication 정책을 바꿨습니다. |
| 결과 | support mailbox는 그 뒤에 만들었고 SMTP AUTH와 IMAP LOGIN이 통했습니다. |
| 제약 | 비밀번호 자체는 어디에도 적지 않았습니다. |
Postal을 고르고 발송 경로를 두 번 바꿨습니다
목표는 admin@example.com로 외부에 보내고 외부에서 받는 것이었습니다. SMTP와 IMAP만 세우면 되는 일이 아닙니다. DNS, TLS, 저장소, 백업 범위가 같이 걸립니다.
후보를 셋 놓고 봤습니다.
| 후보 | 확인한 것 | 결정 |
|---|---|---|
| Postal v3.3.7 | MIT, SMTP submission, HTTP 발송 API | 처음 골랐습니다 |
| Mailu | SMTP, 커뮤니티 Helm | 발송 HTTP API 근거를 못 찾아 뺐습니다 |
| Stalwart | Kubernetes에 붙이기 쉬움, AGPL-3.0과 상용 이중 라이선스 | 남겨 뒀습니다 |
인증 메일을 코드에서 보내야 하니 HTTP 발송 API가 있는 쪽이 편합니다. 그래서 Postal이었습니다.
그 다음 라이브 상태를 봤습니다. 클러스터는 control-plane 노드 한 대이고 공개 주소는 203.0.113.10입니다. example.com에는 A 레코드만 있었습니다. MX도 SPF도 DKIM도 DMARC도 없었습니다. 외부에서 25번 포트로 TCP는 붙었습니다.
PTR이 없었습니다. IP를 도메인으로 되돌려 주는 레코드입니다. 받는 쪽 메일 서버는 보낸 IP의 PTR을 보고 그 IP가 자기 도메인이라고 주장할 자격이 있는지 확인합니다. PTR이 없으면 스팸으로 갈 확률이 올라갑니다.
PTR은 우리가 만들 수 없습니다. IP를 주는 쪽이 만듭니다. 그래서 발송 신뢰성을 검증할 방법이 그 시점에 없었습니다.
노드가 한 대인 것도 걸렸습니다. Postal은 전용 서버를 권합니다. 단일 control-plane 노드에 얹는 것은 그 권고와 다릅니다.
직접 보내는 것을 포기하는 방향을 골랐습니다
PTR을 기다리는 대신 우회하기로 했습니다. Postal은 클러스터에 두고 관리 UI, 발송 API, 전송 로그, webhook을 쓰되, 외부 수신자에게 가는 마지막 구간은 평판을 관리하는 relay의 587 submission을 씁니다. Postal의 smtp_relays 설정이 그 경로입니다.
우리 IP가 남의 메일 서버에 직접 말을 걸지 않습니다. PTR도 우리 문제가 아니게 됩니다.
시크릿과 깃옵스 설정은 relay가 정해지기 전에 만들지 않았습니다. 만들면 쓰이지 않는 시크릿이 저장소에 남습니다.
그리고 relay를 안 쓰기로 다시 바꿨습니다
무료 relay도 쓰지 않기로 했습니다. 외부 relay 없이 Postal이 직접 보내는 쪽으로 되돌렸습니다.
PTR 위험을 안고 가는 결정입니다. SK Broadband 회선 IP에 PTR이 없으니 Gmail, Outlook, iCloud에서 인증 코드 메일이 스팸으로 갈 확률이 높습니다. 그 확률을 출시 전에 실제 수신으로 확인하기로 하고 넘어갔습니다.
Postal은 실제로 올라갔습니다
설계에서 멈춘 것이 아닙니다. 깃옵스 커밋 하나로 Postal 3.3.7과 MariaDB 11.4.5를 올렸습니다.
| 리소스 | 상태 |
|---|---|
postal Argo Application |
revision 2a12c48에서 Synced·Healthy |
| MariaDB StatefulSet | 1/1 |
| Postal web·worker·smtp | 각각 1/1 |
| 초기화 Job | Complete |
| cert-manager Certificate | Ready |
https://postal.example.com/login |
200 |
MariaDB와 Rails와 Postal 서명 키는 Git 밖에 뒀습니다. postal 네임스페이스의 Secret으로 직접 만들었습니다.
첫 롤아웃은 실패했습니다. MariaDB health 명령과 매니페스트의 probe 스키마가 틀렸습니다. 커밋 fa970df와 2a12c48로 고친 뒤 전부 Ready가 됐습니다.
그래서 앱 가입 인증 코드까지 가지 못한 이유는 배포가 아니었습니다. 관리자 계정과 발신 도메인과 DKIM, 그리고 백엔드 OTP API 자격증명이 없었습니다.
오늘 클러스터에는 메일 서버가 둘 있습니다
postal 네임스페이스의 파드 다섯 개가 21일째 돌고 있습니다. postal.example.com는 203.0.113.10로 해석됩니다.
mail 네임스페이스에는 Stalwart가 18일째 있습니다. 메일함과 SMTP·IMAP은 그쪽이 받습니다.
한 클러스터에 메일 서버가 둘입니다. 하나를 내리는 결정을 하지 않았습니다.
Stalwart를 올리고 포트 네 개만 외부에 뒀습니다
메일함을 실제로 받은 것은 Stalwart입니다. stalwartlabs/stalwart:v0.16을 StatefulSet으로, 50Gi local-path PVC를 붙였습니다. Argo revision 7c38ac0에서 Synced이고 Healthy였고 파드는 1/1이었습니다.
메일 프로토콜용 포트는 여덟 개입니다. 25, 465, 587, 143, 993, 110, 995, 4190입니다. LoadBalancer에는 넷만 올렸습니다.
매니페스트에 이름 붙은 포트가 열 개입니다. 메일 프로토콜 여덟 개에 관리용 두 개가 더 있습니다.
# workloads/stalwart/statefulset.yaml:41-61
ports:
- { name: smtp, containerPort: 25 } # 서버끼리 배달
- { name: smtps, containerPort: 465 } # 클라이언트, 즉시 TLS
- { name: submission, containerPort: 587 } # 클라이언트, STARTTLS
- { name: imap, containerPort: 143 } # 평문
- { name: imaps, containerPort: 993 }
- { name: pop3, containerPort: 110 }
- { name: pop3s, containerPort: 995 }
- { name: sieve, containerPort: 4190 }
- { name: http, containerPort: 8080 } # recovery listener
- { name: http-public, containerPort: 8081 } # 관리 콘솔containerPort를 적는 것은 문서일 뿐이고 실제로 여닫는 것은 애플리케이션 설정입니다. 밖에서 닿을 수 있는지를 정하는 것은 Service입니다.
| 포트 | 무엇 | 외부 노출 |
|---|---|---|
| 25 | 다른 메일 서버가 우리에게 배달할 때 | 노출 |
| 465 | 클라이언트가 TLS로 바로 붙어 보낼 때 | 노출 |
| 587 | 클라이언트가 STARTTLS로 붙어 보낼 때 | 노출 |
| 993 | 클라이언트가 TLS로 메일함을 읽을 때 | 노출 |
| 143 · 110 · 995 · 4190 | 평문 IMAP, POP3, POP3S, ManageSieve | 안 함 |
143은 평문 IMAP입니다. 993이 있으면 쓸 이유가 없습니다. 열어 두면 클라이언트 설정을 잘못한 사용자의 비밀번호가 평문으로 지나갑니다.
관리 콘솔은 LoadBalancer가 아니라 Ingress로 뺐습니다. mail.example.com입니다. 메일 프로토콜과 관리 화면은 다른 문으로 들어옵니다.
support@example.com를 일반 User mailbox로 만들고 SMTP AUTH와 IMAP LOGIN을 확인했습니다. 자기에게 보내는 메시지는 큐에 들어갔습니다.
587 submission listener는 처음에 없어서 [::]:587로 만들고 StatefulSet을 다시 세웠습니다. 그 뒤 25, 465, 587, 993에 외부에서 전부 붙었습니다.
18일이 지난 오늘 다시 봤습니다. 파드는 1/1 Running이고 LoadBalancer의 EXTERNAL-IP는 203.0.113.10이며 네 포트가 그대로 붙어 있습니다.
probe가 어느 포트를 보는지도 봐 뒀습니다. readiness는 /healthz/ready, liveness는 /healthz/live이고 둘 다 http, 즉 8080입니다. 5절에서 502를 낸 그 포트입니다. kubelet의 요청에는 답하고 Traefik의 요청에는 답하지 않는 문 하나가 파드의 Ready를 정하고 있습니다.
DNS는 공개 resolver로 확인했습니다
DNS는 우리 클러스터가 아니라 남이 봅니다. 그래서 우리 서버에서 확인하는 것으로는 부족합니다. 공개 resolver에 물어야 합니다.
GoDaddy에서 레코드를 넣고 반영된 뒤 조회했습니다.
| 레코드 | 값 |
|---|---|
| A | 203.0.113.10 |
| MX | 10 mail.example.com. |
| SPF | v=spf1 ip4:203.0.113.10 -all |
| DMARC | p=none |
| DKIM | 그때 두 개 조회됨, selector 이름은 기록에 없음 |
오늘 다시 물어봤습니다. MX와 SPF는 같습니다. DMARC는 값이 늘어서 보고서를 받을 주소가 붙었습니다.
# dig @8.8.8.8 (2026-08-15)
example.com MX 10 mail.example.com.
example.com TXT "v=spf1 ip4:203.0.113.10 -all"
_dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com;
adkim=s; aspf=r; pct=100"
mail.example.com A 203.0.113.10
203.0.113.10 PTR (없음)SPF가 -all로 끝납니다. 이 IP가 아닌 곳에서 온 우리 도메인 메일은 받는 쪽이 버려도 된다는 선언입니다. relay를 쓰기로 했다가 되돌린 결정이 이 한 글자에 남아 있습니다. relay를 쓰면 그 relay의 IP를 여기에 더해야 하고, 지금은 우리 IP 하나뿐입니다.
adkim=s는 DKIM 도메인을 엄격하게 맞추라는 값입니다. DKIM 서명이 붙지 않으면 이 값은 판정에 쓰이지 않습니다.
DMARC 레코드가 두 개 있었습니다
조사 중에 _dmarc.example.com에 TXT가 두 개 있는 것을 발견했습니다. 하나는 p=none, 하나는 p=quarantine이었습니다.
DMARC는 레코드가 하나여야 합니다. 두 개면 RFC상 유효하지 않고, 받는 쪽이 어느 정책을 적용할지 알 수 없습니다. 정책을 강하게 걸었다고 믿는데 아무 정책도 적용되지 않는 상태입니다.
p=none을 남기고 p=quarantine을 지웠습니다. 처음 운영에서는 격리보다 관찰이 먼저입니다. 오늘 조회하면 레코드가 하나입니다.
DKIM은 오늘 selector를 못 찾았습니다. default, stalwart, mail, s1, s2, dkim, rsa, ed25519, 202608, selector1, k1, postal을 _domainkey 아래에서 물어봤고 열두 개 다 빈 응답이었습니다.
selector를 모르면 밖에서 DKIM을 확인할 방법이 없습니다. 이름을 아는 사람만 조회할 수 있는 레코드입니다. 조사 때 "두 개 조회됨"이라고 적은 기록은 남아 있는데 그 이름이 남아 있지 않아, 지금 그 두 개가 그대로 있는지 확인하지 못했습니다.
PTR이 없고 DMARC가 충돌하는 상태에서 Postal 깃옵스 적용을 보류한 이유가 여기입니다. 올려 놓고 나서 이 둘을 고치면 그 사이에 나간 메일이 어디로 갔는지 알 수 없습니다.
인증서는 세 포트에서 각각 확인했습니다
cert-manager 인증서가 Ready가 된 뒤 Stalwart에 secret을 mount하고 다시 세웠습니다.
465, 993, 587에 각각 붙어 인증서를 봤습니다. Let's Encrypt이고 CN이 mail.example.com이며 Verify return code: 0 (ok)였습니다.
세 번 확인한 이유는 방식이 다르기 때문입니다. 465는 붙는 순간 TLS이고, 587은 평문으로 붙어 STARTTLS로 올라가고, 993은 IMAP입니다. 하나만 확인하고 넘어가면 나머지 두 경로에서 인증서가 다르게 잡힐 수 있습니다.
8080에 Ingress를 붙였다가 502를 봤습니다
관리 콘솔을 Ingress에 연결했습니다. Traefik이 Stalwart의 8080으로 보냈고 연결이 reset됐습니다. 브라우저에는 502가 떴습니다.
8080은 recovery listener였습니다. 복구용이고 비로컬 요청을 거부합니다. Traefik에서 오는 요청은 로컬이 아닙니다. 그래서 애플리케이션이 응답을 만들기 전에 연결이 끊겼습니다.
TLS 문제로 보이기 쉬운 증상입니다. 인증서를 다시 보고 Ingress 어노테이션을 고치는 쪽으로 갔다면 시간을 버렸을 것입니다.
http-public listener를 0.0.0.0:8081로 따로 만들고 Service의 targetPort를 그것으로 바꿨습니다. 커밋 35befa3입니다.
고친 자리를 보면 포트 번호는 그대로입니다.
# workloads/stalwart/services.yaml — 관리 콘솔용 Service
kind: Service
metadata:
name: stalwart-web
spec:
ports:
- name: http
port: 8080 # Ingress가 부르는 번호는 그대로
targetPort: http-public # 도착지만 8081로 바꿨다Ingress는 아무것도 바뀌지 않았습니다. stalwart-web의 targetPort를 이름으로 적어 두었기 때문에 그 이름이 가리키는 컨테이너 포트만 옮겼습니다. 헤드리스 Service는 여전히 targetPort: http, 즉 8080을 봅니다.
같은 파일에 Service가 셋 있고 셋이 서로 다른 문을 봅니다. stalwart-mail은 LoadBalancer로 25·465·587·993, stalwart-web은 8081, stalwart-headless는 8080입니다. 문이 셋이라 하나를 고쳐도 나머지 둘은 그대로입니다.
| 경로 | 결과 |
|---|---|
| Traefik → 8080 recovery listener | 연결 reset, 502 |
Traefik → http-public 8081 |
200 |
https://mail.example.com/ |
/account/로 가고 200, HTML 465바이트 |
Argo에서 revision 35befa36이 Synced이고 Healthy였습니다.
원인을 찾는 동안 만든 것도 지웠습니다. 10.42.0.46을 allowlist에 넣고 blocked record를 만든 흔적입니다. Traefik NAT source allowlist에는 10.42.0.1만 남겼습니다. 탐색 중에 넣은 예외를 그대로 두면 다음 사람이 그것을 설계로 읽습니다.
포트 번호를 바꾸는 것으로 끝내지 않은 이유가 하나 더 있습니다. recovery listener는 필요해서 있습니다. 설정을 잘못 넣어 서버가 정상 listener를 못 열 때 그것으로 들어가 고칩니다. 그 문을 공개용으로 쓰면 복구 수단을 공개하는 셈입니다.
비밀번호 정책을 낮춘 것을 기록으로 남깁니다
admin@example.com mailbox를 만들려는데 Stalwart가 비밀번호를 계속 거부했습니다. weak, common password라는 사유였습니다.
여기서 막혔습니다. 계정이 만들어지지 않으면 다음 단계로 갈 수 없습니다.
사용자와 이야기한 뒤 Authentication 정책을 바꿨습니다. 최소 길이 9, 최저 강도 Too guessable입니다.
정책을 낮춘 것입니다. 그 사실을 적어 두는 것이 이 절의 목적입니다. 검증 로그에 "성공"만 남기면 왜 성공했는지가 사라집니다.
비밀번호 자체는 어디에도 적지 않았습니다. 정책값과 자격증명을 같은 자리에 두지 않습니다. 정책은 저장소에 남고, 비밀번호는 사용자에게 한 번 전달하고 끝냅니다.
support mailbox는 그 뒤에 만들었고 SMTP AUTH와 IMAP LOGIN이 통했습니다.
3주 뒤 다시 세어 보니 서버가 여전히 둘입니다
오늘 두 네임스페이스를 나란히 읽었습니다.
mail (Stalwart) |
postal (Postal) |
|
|---|---|---|
| 파드 | stalwart-0 1/1, 18일 |
4개 Running, 21일 |
| 재시작 | 0 | web·smtp·worker 각 3 (21일 전) |
| 외부 주소 | LoadBalancer 203.0.113.10 |
Ingress postal.example.com |
| 여는 포트 | 25, 465, 587, 993 | HTTP만 |
| 하는 일 | 메일함, 서버 간 배달 | 관리 UI, 발송 API, 전송 로그 |
stalwart-0은 18일 동안 재시작이 0입니다. Postal 쪽 세 파드는 21일 전에 세 번씩 재시작한 뒤로 그대로입니다. 그 세 번이 첫 롤아웃에서 probe 스키마를 고치던 그 시각입니다.
LoadBalancer의 노드 포트도 같이 적어 둡니다. 25는 32160, 465는 32093, 587은 31966, 993은 31681입니다. externalTrafficPolicy: Local이라 노드 한 대가 전부 받습니다. 노드가 늘어나면 이 값이 의미를 갖고, 지금은 한 대라 의미가 없습니다.
둘을 합치면 메일 한 통의 경로가 두 서버에 걸립니다. 앱이 인증 코드를 보낼 때는 Postal의 HTTP API를 부르고, 사용자가 support@example.com로 답장하면 Stalwart의 25번으로 들어옵니다. 보내는 쪽과 받는 쪽이 다른 프로세스이고 다른 저장소이며 다른 백업 범위입니다.
하나로 합치지 않은 이유는 판단이 아니라 미결입니다. 각각을 세울 이유가 있었고, 세운 뒤에 무엇을 내릴지 정하지 않았습니다.