SSL/TLS in Postfix and Dovecot: why and how

Mascote do LinuxPro fechando um grande cadeado dourado no servidor de e-mail, com envelopes trancados passando por um túnel protegido até outro servidor e o cão caramelo ao lado

A mail server without TLS gives two things away for free to anyone in the middle: the contents of the messages and each user's password, which travels every time the phone checks the mailbox. And it's not just about privacy: since February 2024, Gmail has required a TLS connection from every the sender. Below, full SSL/TLS on a Postfix + Dovecot server — Let's Encrypt certificate, the right ports, minimum versions, authentication only with encryption, and protection against the attack that strips STARTTLS. Everything tested in a lab on Ubuntu 24.04 and on 26.04, which ship Dovecots of different versions, with different syntaxes.

Why the mail server needs TLS

  • Passwords. IMAP, POP3, and authenticated SMTP send the username and password at the beginning of every session. Without TLS, anyone on the café Wi-Fi, the ISP, or a compromised router reads it in plain text. A single captured login gives access to the entire mailbox — and to the password reset of everything tied to that email.
  • Content. Contracts, bills, invoices, medical exams: the entire message travels in cleartext between the client and the server and between servers.
  • Deliverability. The Gmail's rules for any sender include “Use a TLS connection for transmitting email”, alongside SPF/DKIM and reverse DNS — the same package as the post about DKIM and DMARC in Postfix.
  • Old protocol is broken protocol. A RFC 8996 (March 2021) says that TLS 1.0 and 1.1 “MUST NOT be used”. The minimum today is TLS 1.2, and TLS 1.3 is what modern clients negotiate.

The ports and the type of TLS for each one

Diagrama das portas: o usuário envia pela 465 com TLS implícito ou pela 587 com STARTTLS obrigatório, lê a caixa pela 993 ou 995 com TLS implícito; o Postfix troca mensagens com outros servidores pela 25 com STARTTLS oportunista; o Dovecot autentica o SMTP do Postfix por socket local

  • 25 (SMTP) — server talking to server. Uses opportunistic STARTTLS (RFC 3207): the conversation starts in plain text and upgrades to TLS if both sides offer it. You can't require TLS here for everyone — much of the internet still delivers without it — so the standard is may.
  • 465 (submissions) — the user submitting, with implicit TLS: the connection is born encrypted, like HTTPS.
  • 587 (submission) — the user sending with STARTTLS, but required: the server refuses any command before TLS.
  • 993 (IMAPS) and 995 (POP3S) — mailbox reading with implicit TLS.
  • 143 and 110 — IMAP and POP3 without implicit TLS. Do not expose it to the internet.

A RFC 8314 (January 2018) recommends implicit TLS for sending and reading — 465, 993 and 995 — and accepts 587 with STARTTLS during the transition. In practice, offer 465 and 587: older clients and some phones still come configured on 587.

Versions in the lab

Package Ubuntu 24.04 Ubuntu 26.04 Debian 13
postfix 3.8.6 3.10.6 3.10.13
dovecot-core 2.3.21 2.4.2 2.4.1
certbot 2.9.0 4.0.0 4.0.0
openssl 3.0.13 3.5.5

The line that matters is the one from Dovecot: 2.4 changed the names of the TLS options and does not accept the configuration from 2.3 without an adjustment. I'll show both.

The certificate: Let's Encrypt on the server's name

The certificate must have the name that clients and other servers use to reach you — the one from the MX record and the one configured in Thunderbird and on the phone. In this post, mail.example.com. If you use aliases such as smtp. and imap., include them all in the same certificate with multiple -d.

Mail server usually doesn't have a website; the certbot standalone mode spins up a temporary web server on port 80 just for validation:

sudo apt install -y certbot
sudo ufw allow 80/tcp
sudo certbot certonly --standalone -d mail.example.com \
  --agree-tos -m postmaster@example.com --no-eff-email

If there is already an nginx or Apache on the machine, use --webroot -w /var/www/html instead of --standalone. The result goes into /etc/letsencrypt/live/mail.example.com/: fullchain.pem (certificate + intermediates) and privkey.pem (the key). The live and archive directories are readable only by root — and that's fine: Postfix and Dovecot open the certificate as root, before dropping privileges. Don't change the permissions.

Renewal and the deadlines that are getting shorter

The package installs the certbot.timer, which renews itself. What it doesn't do is notify Postfix and Dovecot: they keep serving the old certificate from memory until they reload. A deploy hook resolve — certbot only runs the scripts in that directory when a renewal succeeds:

sudo tee /etc/letsencrypt/renewal-hooks/deploy/recarrega-email.sh >/dev/null <<'EOF'
#!/bin/sh
systemctl reload postfix dovecot
EOF
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/recarrega-email.sh
sudo certbot renew --dry-run

On Ubuntu 24.04 (certbot 2.9), the hooks in that directory only run with the subcommand renew — which is what the timer uses, so automatic renewal works; on the first issuance, reload it manually.

This is no longer a minor detail. Let’s Encrypt announced that the default certificate of 90 days drops to 64 days on 10 February 2027 and to 45 days on 16 February 2028. And the CA/Browser Forum approved in 2025 a cap for all CAs: 200 days from 15 March 2026, 100 days starting March 2027 and 47 days starting March 2029. Manual renewal is over; renewal without reload is an expired certificate in production.

Postfix: TLS in main.cf

Debian and Ubuntu already install Postfix with TLS enabled, but with the self-signed certificate ssl-cert-snakeoil and no version floor on user ports. Swap the certificate and fix the rest:

sudo postconf -X smtpd_tls_cert_file smtpd_tls_key_file
sudo postconf -e \
  'smtpd_tls_chain_files = /etc/letsencrypt/live/mail.example.com/privkey.pem, /etc/letsencrypt/live/mail.example.com/fullchain.pem' \
  'smtpd_tls_security_level = may' \
  'smtpd_tls_auth_only = yes' \
  'smtpd_tls_mandatory_protocols = >=TLSv1.2' \
  'smtp_tls_security_level = may' \
  'smtp_tls_mandatory_protocols = >=TLSv1.2' \
  'smtpd_tls_loglevel = 1' \
  'smtp_tls_loglevel = 1' \
  'smtpd_tls_received_header = yes'
  • smtpd_tls_chain_files (Postfix 3.4+): the key first, then the chain. Replace the pair smtpd_tls_cert_file/smtpd_tls_key_file, so the postconf -X before.
  • smtpd_* is Postfix receiving; smtp_* is Postfix delivering to other servers. Both need configuration.
  • security_level = may on port 25, in both directions: TLS when the other side offers it. The user ports stay with encrypt in master.cf, below.
  • smtpd_tls_auth_only = yes: the server neither advertises AUTH before TLS. Plain-text passwords become impossible, not just inadvisable.
  • *_mandatory_protocols = >=TLSv1.2 (Postfix 3.6+ syntax): applies where TLS is required — the user ports 465 and 587, below. In the lab, with this line, both refused TLS 1.1 (Cipher is (NONE)) on both Ubuntu.
  • Why not require TLS 1.2 on port 25 as well? Because there TLS is opportunistic and, if the handshake fails, Postfix delivers in plain text. Refusing TLS 1.0 from an old server doesn't block the message — it only makes it travel with no encryption at all, which is worse. That's why port 25 stays with the Postfix default (smtpd_tls_protocols and smtp_tls_protocols in >=TLSv1, without SSLv2/SSLv3). In the lab, the stock Postfix on 24.04 and 26.04 negotiated real TLS 1.0 and 1.1 on 25 — this is expected. Those who want truly strong TLS between servers use MTA-STS or DANE, at the end of the post.
  • loglevel = 1 records one line per TLS connection, and received_header writes the version and cipher in the header Received: of each message — useful for later proving that it arrived encrypted.

Postfix: ports 465 and 587 in master.cf

The /etc/postfix/master.cf in the package already has both entries commented out. Uncomment or add, using in the 5th column the same value as the line smtp inet in your file (y in 24.04, which runs the smtpd in chroot; n in 26.04):

submission inet n       -       y       -       -       smtpd
  -o syslog_name=postfix/submission
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
submissions inet n      -       y       -       -       smtpd
  -o syslog_name=postfix/submissions
  -o smtpd_tls_wrappermode=yes
  -o smtpd_tls_security_level=encrypt
  -o smtpd_sasl_auth_enable=yes
  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
  -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject

smtpd_tls_security_level=encrypt makes TLS mandatory — that's what activates the smtpd_tls_mandatory_protocols — and smtpd_tls_wrappermode=yes is the implicit TLS of 465. In both, only those who authenticated get through. Dovecot checks the password via SASL:

sudo postconf -e 'smtpd_sasl_type = dovecot' 'smtpd_sasl_path = private/auth'
sudo postfix check && sudo systemctl restart postfix

The path private/auth is relative to /var/spool/postfix and works both with and without chroot.

Dovecot 2.3 (Ubuntu 24.04)

Create /etc/dovecot/conf.d/99-tls.conf — the 99 ensures it is read last and takes precedence over the 10-ssl.conf of the package:

ssl = required
ssl_cert = </etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_key = </etc/letsencrypt/live/mail.example.com/privkey.pem
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes
disable_plaintext_auth = yes

In 2.3, the < before the path is mandatory: it says “read the contents of this file”. Without it, Dovecot tries to use the path itself as the certificate.

Dovecot 2.4 (Ubuntu 26.04 and Debian 13)

The same file, with the new names:

ssl = required
ssl_server_cert_file = /etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_server_key_file = /etc/letsencrypt/live/mail.example.com/privkey.pem
ssl_min_protocol = TLSv1.2
auth_allow_cleartext = no
  • ssl_cert/ssl_key became ssl_server_cert_file/ssl_server_key_file, with a pure path, without <.
  • disable_plaintext_auth = yes became an official auth_allow_cleartext = no (which is already the default).
  • ssl_prefer_server_ciphers became an official ssl_server_prefer_ciphers, and ssl_dh became an official ssl_server_dh_file.
  • The dovecot.conf must start with dovecot_config_version and having dovecot_storage_version — the package already includes it. A configuration copied from a server 2.3 will not come up: see the migration guide 2.3 → 2.4.

The authentication socket for Postfix (2.3 and 2.4)

In /etc/dovecot/conf.d/99-postfix-auth.conf, the same in both versions:

service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}
sudo doveconf -n >/dev/null && echo config OK
sudo systemctl restart dovecot

ssl = required with non-plaintext authentication makes Dovecot refuse login outside TLS. In the lab, coming from another machine on port 143:

# Dovecot 2.4
a1 NO [PRIVACYREQUIRED] Cleartext authentication disallowed on non-secure (SSL/TLS) connections.
# Dovecot 2.3 (POP3 na 110)
-ERR [AUTH] Plaintext authentication disallowed on non-secure (SSL/TLS) connections.

Be careful when testing: Dovecot considers connections from the server itself 127.0.0.1. to be safe. Login without TLS from the server works — the test that matters is from another machine.

Firewall

sudo ufw allow 25,465,587,993,995/tcp

Keep 143 and 110 closed to outsiders. Those who use webmail on the same machine access it via localhost; external clients go through 993 and 995.

Testing each port

The openssl s_client shows version, cipher, and whether the certificate chain was verified:

openssl s_client -brief -starttls smtp -connect mail.example.com:25  </dev/null
openssl s_client -brief -connect mail.example.com:465 </dev/null
openssl s_client -brief -starttls smtp -connect mail.example.com:587 </dev/null
openssl s_client -brief -connect mail.example.com:993 </dev/null
openssl s_client -brief -connect mail.example.com:995 </dev/null
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Verification: OK

That was the output on all five ports, on both Ubuntu versions. To confirm that old TLS is refused on the user ports, force the version — and look at the cipher, not the protocol line, which the openssl prints even when the handshake fails:

openssl s_client -tls1_1 -cipher 'DEFAULT:@SECLEVEL=0' \
  -connect mail.example.com:465 </dev/null 2>&1 | grep 'Cipher is'
# Cipher is (NONE)   ← recusado, como deve ser

Authenticated submission, verifying the certificate, with the swaks:

sudo apt install -y swaks
swaks --server mail.example.com:465 --tlsc --tls-verify \
  --auth PLAIN --auth-user ana --from ana@example.com --to voce@gmail.com
=== TLS started with cipher TLSv1.3:TLS_AES_256_GCM_SHA384:256
<~  235 2.7.0 Authentication successful
<~  250 2.0.0 Ok: queued as 4D83AB47D87

Replace :465 --tlsc by :587 --tls to test the other port. On 587, without STARTTLS, Postfix responds 530 5.7.0 Must issue a STARTTLS command first to any command — and the EHLO not even lists AUTH.

For the server-to-server side, Postfix brings the posttls-finger, which connects as Postfix would connect to an MX and reports the trust level obtained:

posttls-finger -c -P /etc/ssl/certs "[mail.example.com]:25"

The result ends in Verified TLS connection established when the name matches the certificate and the chain is valid. Untrusted indicates an unknown chain or a different name — in the lab, this appeared when connecting by IP instead of the name. For a complete analysis of ciphers and vulnerabilities, the testssl.sh (GPLv2) accepts --starttls smtp; and the internet.nl tests STARTTLS, DANE, and DNSSEC of your domain over the internet.

The attack that removes STARTTLS

The weak point of the 25 port is the “opportunist” itself. STARTTLS is advertised in clear text; anyone in the middle can remove the line 250-STARTTLS from the response, and the sender, configured with may, delivers in clear text thinking the other side doesn't support TLS. On ports 587 and 993 this doesn't happen, because TLS is mandatory and the connection simply drops.

Diagrama comparando dois cenários: sem proteção, um intermediário apaga o STARTTLS da resposta e a mensagem segue em texto puro; com MTA-STS ou DANE, o remetente já sabe que o destino exige TLS, recusa a conexão rebaixada e mantém a mensagem na fila

Two solutions that protect the messages that arrive at your domain:

  • MTA-STS (RFC 8461): you publish, via HTTPS, a policy stating “my MX servers require TLS with a valid certificate.” Senders that implement the standard will then refuse to deliver without it.
  • DANE (RFC 7672): the MX's certificate is pinned in a TLSA record signed with DNSSEC. It requires DNSSEC on the domain.

MTA-STS is the simplest to get started with. Two records and a file:

_mta-sts.example.com.   IN TXT  "v=STSv1; id=20260923T120000;"
_smtp._tls.example.com. IN TXT  "v=TLSRPTv1; rua=mailto:tls-reports@example.com"

And in https://mta-sts.example.com/.well-known/mta-sts.txt, served with a valid certificate:

version: STSv1
mode: testing
mx: mail.example.com
max_age: 604800

Start with mode: testing: senders deliver normally, but send failure reports to the address in the record _smtp._tls (TLS-RPT, RFC 8460). With no surprises for a few weeks, switch to mode: enforce and change the id of the TXT record — that's the change of id that tells senders to look up the policy again. Each MX of the domain must be on a line mx:.

To protect messages that leave, Postfix knows how to use DANE on the sender side: smtp_dns_support_level = dnssec and smtp_tls_security_level = dane. Destinations with a TLSA record start requiring verified TLS; those without a record continue with may. This depends on a local resolver that validates DNSSEC (unbound, for example) — without it, do not enable it.

What usually breaks

  • Client complains about an invalid certificate — it is configured with a name that is not in the certificate (imap.example.com, the IP, the server's old name). Issue it with all the names used, or standardize the clients.
  • It works in Thunderbird but fails on another server — you used cert.pem instead of fullchain.pem and missed the intermediate. Browsers and some clients complete the chain on their own; mail servers do not.
  • The certificate expired, but the renewal ran — the deploy hook was missing and the service kept using the old certificate in memory. openssl s_client ... | openssl x509 -noout -enddate shows what is actually being served.
  • Dovecot does not start after upgrading to 26.04 — configuration 2.3 in 2.4. doveconf -n points to the line.
  • certbot --standalone failure — port 80 is occupied by a web server or blocked by the firewall. Use --webroot.
  • Old clients stop connecting after requiring TLS 1.2 — very old mail systems and clients, without TLS support 1.2. It is the right price; TLS 1.0 is forbidden by RFC 8996.
  • Nothing goes out on port 25 — many cloud providers block outgoing 25 by default. It is not TLS: it is a request for clearance in the provider's panel.

Summary

Valid certificate on the MX name, renewal with reload, TLS 1.2 as the floor on everything that carries a password (465, 587, 993, 995), port 25 opportunistic as it should be, password only inside TLS, 465/587/993/995 open and 143/110 closed. With this the server is up to date; with MTA-STS, it also starts to protect what arrives. The next steps of the same stack: DKIM and DMARC in Postfix, the panel go-postfixadmin for the virtual mailboxes and the story of the pieces in The history of Postfix and The history of Dovecot.