August 15, 2026 · 14 min read
Self-Hosted Mail Server: Changing the Send Path Twice Without a PTR
Sending and receiving on our own domain, with no PTR: the send path changed twice. SPF, DKIM, and DMARC go in, Stalwart takes delivery, and the recovery listener answers 502.
I connected with telnet 203.0.113.10 25. That did not mean I could say the mail server was working.
An open port and another person’s mail server accepting our mail were different things. In between were PTR, SPF, DKIM, and DMARC.
I chose Postal at first. Because there was no PTR, I could not complete outbound verification, and after trying to bypass it with a relay, I returned to sending directly. Postal actually came up and is still running, while Stalwart, which I installed later, receives the mailboxes. There are two mail servers in one cluster.
Then I connected the Stalwart administration console to the Ingress, and got a 502. I had not chosen the wrong port; that port served a different purpose.
I chose Postal and changed the delivery route twice
The goal was to send to and receive from the outside at admin@example.com. This was not a matter of setting up only SMTP and IMAP. DNS, TLS, storage, and backup scope were involved as well.
I evaluated three candidates.
| Candidate | What I checked | Decision |
|---|---|---|
| Postal v3.3.7 | MIT, SMTP submission, HTTP sending API | Chose it initially |
| Mailu | SMTP, community Helm | Excluded it because I could not find evidence of an HTTP sending API |
| Stalwart | Easy to attach to Kubernetes, dual-licensed under AGPL-3.0 and a commercial license | Kept it as an option |
Since I needed to send authentication emails from code, an option with an HTTP sending API was more convenient. That was Postal.
Next, I checked the live state. The cluster had one control-plane node, and the public address was 203.0.113.10. example.com had only an A record. It had no MX, SPF, DKIM, or DMARC records. TCP connections to port 25 from outside worked.
There was no PTR record. It is the record that maps an IP back to a domain. The receiving mail server checks the PTR of the sending IP to determine whether that IP has the right to claim that it belongs to its domain. Without a PTR, the probability of being classified as spam increases.
We could not create the PTR. The party providing the IP creates it. Therefore, at that point, there was no way to verify sending reliability.
The fact that there was only one node was also a concern. Postal recommends a dedicated server. Deploying it on a single control-plane node went against that recommendation.
I chose to give up on sending directly
Instead of waiting for the PTR, I decided to route around it. I would keep Postal in the cluster and use its management UI, sending API, delivery logs, and webhooks, while using the 587 submission endpoint of a reputation-managed relay for the final leg to external recipients. Postal's smtp_relays configuration provides that route.
Our IP would not speak directly to other people's mail servers. The PTR would no longer be our problem either.
I did not create secrets or GitOps configuration before deciding on the relay. Otherwise, unused secrets would remain in the repository.
Then I changed course again and decided not to use a relay
I also decided not to use a free relay. I switched back to having Postal send directly without an external relay.
This was a decision to accept the PTR risk. Since the SK Broadband connection IP had no PTR, authentication-code emails were more likely to land in spam at Gmail, Outlook, and iCloud. I decided to verify that probability through actual delivery before launch and proceeded.
Postal Was Actually Deployed
This did not stop at the design stage. With a single GitOps commit, I deployed Postal 3.3.7 and MariaDB 11.4.5.
| Resource | Status |
|---|---|
postal Argo Application |
Synced·Healthy at revision 2a12c48 |
| MariaDB StatefulSet | 1/1 |
| Postal web·worker·smtp | 1/1 each |
| Initialization Job | Complete |
| cert-manager Certificate | Ready |
https://postal.example.com/login |
200 |
I kept the MariaDB, Rails, and Postal signing keys outside Git. I created them directly as Secrets in the postal namespace.
The first rollout failed. The MariaDB health command and the probe schema in the manifests were incorrect. After I fixed them in commits fa970df and 2a12c48, everything became Ready.
So the reason I could not get as far as the app signup verification code was not the deployment. I did not have the administrator account, sending domain, DKIM, or backend OTP API credentials.
There Are Two Mail Servers in the Cluster Today
The five pods in the postal namespace had been running for 21 days. postal.example.com resolved to 203.0.113.10.
Stalwart had been in the mail namespace for 18 days. It handled the mailboxes and SMTP·IMAP.
There were two mail servers in one cluster. I did not decide to take either one down.
I deployed Stalwart and exposed only four ports externally
The service actually receiving mail was Stalwart. I deployed stalwartlabs/stalwart:v0.16 as a StatefulSet and attached a 50Gi local-path PVC. It was Synced and Healthy at Argo revision 7c38ac0, and the pod was 1/1.
There are eight ports for mail protocols: 25, 465, 587, 143, 993, 110, 995, and 4190. I exposed only four through the LoadBalancer.
The manifest has ten named ports. The eight mail protocol ports are joined by two management ports.
# 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 } # 관리 콘솔Specifying containerPort is documentation only; the application configuration determines what is actually opened and closed. The Service determines whether it can be reached from outside.
| Port | What | External exposure |
|---|---|---|
| 25 | When another mail server delivers mail to us | Exposed |
| 465 | When a client connects directly over TLS to send mail | Exposed |
| 587 | When a client connects using STARTTLS to send mail | Exposed |
| 993 | When a client reads the mailbox over TLS | Exposed |
| 143 · 110 · 995 · 4190 | Plaintext IMAP, POP3, POP3S, ManageSieve | Not exposed |
143 is plaintext IMAP. With 993 available, there was no reason to use it. If left open, a user who misconfigured their client could send their password in plaintext.
I exposed the management console through an Ingress rather than a LoadBalancer. It is mail.example.com. Mail protocols and the management interface enter through different doors.
I created support@example.com as a regular User mailbox and verified SMTP AUTH and IMAP LOGIN. A message sent to itself entered the queue.
The 587 submission listener was initially missing, so I created it as [::]:587 and rebuilt the StatefulSet. After that, I was able to connect externally to all four ports: 25, 465, 587, and 993.
I checked again today, 18 days later. The pod was 1/1 Running, the LoadBalancer's EXTERNAL-IP was 203.0.113.10, and the four ports were still attached.
I also checked which ports the probes were using. Readiness was /healthz/ready, liveness was /healthz/live, and both used http, meaning 8080. It was one door that answered the kubelet's requests but not Traefik's requests that determined whether the pod was Ready.
I checked DNS with a public resolver
DNS was viewed by others, not by our cluster. So checking from our own server was insufficient. I had to ask a public resolver.
I queried the records after adding them in GoDaddy and waiting for propagation.
| Record | Value |
|---|---|
| A | 203.0.113.10 |
| MX | 10 mail.example.com. |
| SPF | v=spf1 ip4:203.0.113.10 -all |
| DMARC | p=none |
| DKIM | Two were found at the time, but the selector names were not recorded |
I queried them again today. MX and SPF were unchanged. DMARC had gained a value and now included an address for receiving reports.
# 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 ended with -all. This declared that the receiving side could discard mail from our domain if it came from somewhere other than this IP. The decision to use a relay and then reverse it remained in this single character. If we used a relay, we would have to add that relay's IP here; for now, there was only our IP.
adkim=s required the DKIM domain to match strictly. If no DKIM signature was attached, this value was not used for evaluation.
There were two DMARC records
During the investigation, I found two TXT records at _dmarc.example.com. One was p=none, and the other was p=quarantine.
DMARC had to have a single record. With two records, it was invalid under the RFC, and the receiving side could not know which policy to apply. We might believe that we had set a strong policy, while in fact no policy was being applied.
I kept p=none and deleted p=quarantine. During initial operation, observation came before quarantine. When I queried it today, there was a single record.
I could not find the DKIM selector today. I queried default, stalwart, mail, s1, s2, dkim, rsa, ed25519, 202608, selector1, k1, and postal under _domainkey, and all twelve returned empty responses.
Without knowing the selector, there was no way to check DKIM from outside. It was a record that only someone who knew the name could query. I had a record from the investigation saying “두 개 조회됨 (two found),” but the names were not preserved, so I could not verify whether those same two still existed.
This was why I had postponed applying Postal GitOps while the PTR was missing and DMARC was conflicting. If I had deployed it and fixed these two afterward, I would not have known where the mail sent during that interval had gone.
I checked the certificates separately on the three ports
After the cert-manager certificate became Ready, I mounted the secret in Stalwart and restarted it.
I connected to each of ports 465, 993, and 587 and checked the certificate. It was from Let's Encrypt, its CN was mail.example.com, and it returned Verify return code: 0 (ok).
I checked three times because the connection methods differed. Port 465 used TLS from the moment it connected, port 587 connected in plaintext and upgraded through STARTTLS, and port 993 was IMAP. If I had checked only one and moved on, the certificate could have been different on the other two paths.
I Attached an Ingress to 8080 and Saw a 502
I connected the management console to the Ingress. Traefik sent traffic to Stalwart’s 8080, and the connection was reset. The browser showed a 502.
8080 was the recovery listener. It was intended for recovery and rejected non-local requests. Requests from Traefik were not local. As a result, the connection was dropped before the application could generate a response.
This symptom could easily look like a TLS problem. If I had gone down the path of checking the certificate again and fixing the Ingress annotations, I would have wasted time.
I created a separate http-public listener on 0.0.0.0:8081 and changed the Service’s targetPort to it. The commit was 35befa3.
The port number remained unchanged in the corrected section.
# workloads/stalwart/services.yaml — 관리 콘솔용 Service
kind: Service
metadata:
name: stalwart-web
spec:
ports:
- name: http
port: 8080 # Ingress가 부르는 번호는 그대로
targetPort: http-public # 도착지만 8081로 바꿨다Nothing changed in the Ingress. Because stalwart-web specified targetPort by name, I moved only the container port referenced by that name. The headless Service still saw targetPort: http, which was 8080.
There were three Services in the same file, and each pointed to a different door. stalwart-mail exposed 25·465·587·993 through a LoadBalancer, stalwart-web exposed 8081, and stalwart-headless exposed 8080. Since there were three doors, fixing one left the other two unchanged.
| Path | Result |
|---|---|
| Traefik → 8080 recovery listener | Connection reset, 502 |
Traefik → http-public 8081 |
200 |
https://mail.example.com/ |
Redirected to /account/ and returned 200, HTML 465 bytes |
In Argo, revision 35befa36 was Synced and Healthy.
I also removed what I had created while investigating. I had added 10.42.0.46 to the allowlist and left a blocked record behind. I left only 10.42.0.1 in the Traefik NAT source allowlist. If I leave an exception added during exploration in place, the next person will read it as part of the design.
There was one more reason I did not stop at changing the port number. The recovery listener existed for a reason. When a bad configuration prevented the server from opening its normal listener, I could use it to get in and fix the configuration. Using that door for public access would have meant exposing the recovery mechanism.
I Recorded That I Had Lowered the Password Policy
I was trying to create the admin@example.com mailbox, but Stalwart kept rejecting the password because it was weak and common.
I was blocked there. If the account could not be created, I could not proceed to the next step.
After talking with the user, I changed the Authentication policy. The minimum length was 9, and the lowest strength was Too guessable.
I had lowered the policy. The purpose of this section was to record that fact. If the verification log only recorded "성공" (“success”), why it succeeded would be lost.
I did not write down the password itself anywhere. I did not keep the policy values and credentials in the same place. The policy remained in the repository, while the password was given to the user once and then discarded.
I created the support mailbox afterward, and SMTP AUTH and IMAP LOGIN worked.
Three Weeks Later, I Counted Again and There Were Still Two Servers
Today I read the two namespaces side by side.
mail (Stalwart) |
postal (Postal) |
|
|---|---|---|
| Pods | stalwart-0 1/1, 18 days |
4 Running, 21 days |
| Restarts | 0 | web·smtp·worker 3 each (21 days ago) |
| External address | LoadBalancer 203.0.113.10 |
Ingress postal.example.com |
| Open ports | 25, 465, 587, 993 | HTTP only |
| Role | Mailboxes, server-to-server delivery | Admin UI, sending API, delivery logs |
stalwart-0 had 0 restarts over 18 days. The three Postal pods had remained unchanged since restarting three times 21 days ago. Those three restarts occurred when I was fixing the probe schema in the first rollout.
I also recorded the LoadBalancer node ports. 25 was 32160, 465 was 32093, 587 was 31966, and 993 was 31681. Because externalTrafficPolicy: Local was set, one node received everything. If the number of nodes increased, these values would matter; with one node, they did not.
Taken together, the path of a single email crossed two servers. When the app sent an authentication code, it called Postal’s HTTP API, and when a user replied to support@example.com, the message entered through Stalwart’s port 25. The sending and receiving sides were different processes, different storage, and different backup scopes.
The reason I had not combined them was not a decision but an unresolved matter. There had been reasons to set up each one, and after setting them up, I had not decided which one to take down.