2026.06 / infrastructure log
How Email Is Handled on This VPS
Email is one of those pieces of infrastructure that looks simple from the outside and becomes very precise once you host it yourself. A web app can be redeployed in seconds if something goes wrong. Mail has reputation, DNS, certificates, spam checks, reverse DNS, authentication, and a long tail of servers deciding whether they trust you.
For this VPS, I wanted email to stay self-hosted where practical, but not fragile. The current setup uses one existing mail server identity for the VPS, then hosts more than one domain on top of it.
The important rule was: add adrianmarikar.com email without disturbing the working PoolVisit email setup.
The shape of the setup
The mail server runs as a Docker Mailserver container on the VPS. It sits beside the Coolify apps, not inside any one app. That means apps can use it for SMTP, and normal mail clients can connect to it over IMAP and SMTP submission.
VPS
├─ Coolify apps
│ ├─ adrianmarikar.com blog
│ ├─ raceedge.uk
│ └─ other apps
│
└─ Docker Mailserver
├─ SMTP inbound: 25
├─ SMTP submission: 587
├─ IMAPS: 993
├─ PoolVisit mailboxes
└─ Adrian Marikar mailbox
The public mail hostname is:
mail.poolvisitreports.com
That hostname is also the reverse-DNS identity for the VPS mail server. I did not want to change that, because the PoolVisit mail setup was already working.
Why the mail hostname stayed the same
A server can host mailboxes for multiple domains while using one mail server hostname. This is normal. The reverse DNS does not have to be mail.adrianmarikar.com just because one mailbox is [email protected].
For outbound deliverability, the important part is that the server identity is consistent:
- The VPS IP has PTR/rDNS pointing to
mail.poolvisitreports.com. mail.poolvisitreports.comresolves back to the VPS IP.- The SMTP server announces itself as that hostname.
- Each sending domain has its own SPF, DKIM, and DMARC records.
So the safe path was to keep the existing identity and add adrianmarikar.com as another hosted mail domain.
DNS records for Adrian email
For [email protected], the domain needs its own DNS records. The MX record points mail for the domain at the existing server:
adrianmarikar.com. MX 10 mail.poolvisitreports.com.
SPF authorises the configured mail exchanger to send mail for the domain, without needing to publish the VPS IP address in this write-up:
adrianmarikar.com. TXT "v=spf1 mx -all"
DMARC starts in monitoring mode:
_dmarc.adrianmarikar.com. TXT "v=DMARC1; p=none; adkim=s; aspf=s"
DKIM uses the mail selector:
mail._domainkey.adrianmarikar.com. TXT "v=DKIM1; k=rsa; p=..."
The real DKIM key is intentionally not shown here in full. The useful lesson is that the DKIM value must match the key generated by the mail server exactly. It is easy to break DKIM by pasting extra labels like “Name:” or “Value:” into the DNS field.
Adding the mailbox
The mailbox itself was added inside Docker Mailserver:
[email protected]
The password is stored locally on the VPS in a private file, not committed to Git and not written into any blog post or application code.
After creating the mailbox, I verified authentication directly against Dovecot and sent a local message through SMTP submission to confirm the mailbox could receive mail.
DKIM with multiple domains
Docker Mailserver can generate DKIM keys for more than one domain. In this setup, Rspamd handles DKIM signing.
The subtle part was that the mail server already had a custom Rspamd DKIM signing config for PoolVisit. Generating a new key for adrianmarikar.com created the key files, but it did not overwrite the existing config. That is a good safety behaviour, but it means the new domain has to be added manually alongside the existing domain.
The final idea looks like this:
domain {
poolvisitreports.com {
selector = "mail";
path = "/tmp/docker-mailserver/rspamd/dkim/...poolvisitreports.com.private.txt";
}
adrianmarikar.com {
selector = "mail";
path = "/tmp/docker-mailserver/rspamd/dkim/...adrianmarikar.com.private.txt";
}
}
The public DKIM record was then checked against the generated public key byte-for-byte. Presence alone is not enough; it has to be the exact value.
Switching from self-signed TLS to Let’s Encrypt
The first version of the mail server used a self-signed certificate. That was enough for controlled app-to-mail communication, but it is not good for normal mail clients. Himalaya, the terminal mail client I use from the VPS, correctly rejected the self-signed certificate.
The fix was to issue a real Let’s Encrypt certificate for:
mail.poolvisitreports.com
Because this VPS already uses Coolify and Traefik for web apps, I did not want Certbot to grab ports 80 or 443. The safer approach was a DNS-01 challenge through Cloudflare:
Certbot asks Let's Encrypt for a certificate
↓
Certbot creates a temporary Cloudflare TXT record
↓
Let's Encrypt verifies the DNS challenge
↓
Certificate is issued
↓
Docker Mailserver mounts the certificate directory
This avoids touching the web stack entirely.
The mail container certificate change
Docker Mailserver was changed from:
SSL_TYPE=self-signed
to:
SSL_TYPE=letsencrypt
The Let’s Encrypt directory is mounted into the container at /etc/letsencrypt. That matters because the live/ certificate files are symlinks into the archive/ directory, so the full directory tree needs to be mounted, not just one file.
What was verified
After the switch, the mail server was restarted and checked carefully:
- The mail container returned to healthy state.
- IMAPS on port 993 presented a Let’s Encrypt certificate.
- SMTP submission on port 587 presented the same trusted certificate over STARTTLS.
- The certificate chain verified successfully.
[email protected]authenticated successfully.- The existing PoolVisit mailbox still existed.
- Himalaya could list folders and messages.
- Himalaya could send a message from
[email protected].
The mail client settings are now simple:
IMAP
Host: mail.poolvisitreports.com
Port: 993
Security: SSL/TLS
Username: [email protected]
SMTP
Host: mail.poolvisitreports.com
Port: 587
Security: STARTTLS
Username: [email protected]
Renewal
Let’s Encrypt certificates expire, so renewal needs to be automatic. A daily renewal job runs Certbot with the Cloudflare DNS plugin. If Certbot renews the certificate, the mail container is restarted so it picks up the new files.
The renewal job is intentionally quiet when nothing changes. That way it does not create noise every day, but it can still surface real errors.
What I would avoid changing
The important safety lesson is that adding a second mail domain should not require rebuilding the whole mail identity. I specifically avoided changing:
- PTR/rDNS
- The mail hostname
- PoolVisit’s existing MX, SPF, DKIM, or DMARC records
- Existing PoolVisit mailboxes
- The Coolify or Traefik web routing setup
Why this setup is useful
This gives the VPS a practical self-hosted mail layer. Apps can send transactional email, the personal domain has a real mailbox, and Hermes can operate the mailbox through a terminal client when needed.
It is not as hands-off as using a big hosted email provider, but it fits the way this VPS is being used: self-hosted where practical, simple enough to understand, and verified end-to-end instead of assumed to work.