Doctor
When to use: anything looks wrong, after install/upgrade, before contacting support. Command: mailctl doctor [--dir /opt/mailserver]. Read-only.
What it checks
Section titled “What it checks”- Host: Docker engine + Compose plugin, memory (✖ below 2 GiB), disk free at the install dir and, as a separate
docker diskrow when it is another filesystem, at Docker’s data root, where the mail, database and queue volumes live — both ⚠ below 10 GiB, anddocker disk⚠ cannot measure when the data root exists but this user cannot read it (run as root), public IP, andcurl/taron the path — the last one a ⚠.mailctl backupandrestoreno longer need them on the host (they runtarinside a pinned image), so this row is informational and a host may ignore it. - Outbound SMTP: opens a real TCP connection to a public MX on port 25 and waits for the greeting.
portFree(25)cannot answer this — it binds locally and says nothing about egress. Most clouds block outbound 25 by default (GCP and Azure permanently; AWS, Oracle and Hetzner until a support request), and a host that blocks it passes every other check and then silently never delivers. - Outbound relay: when Postfix’s own
relayhostis set, it is reported as aninfoline naming the relay — and that is also what decides whether the blocked-:25check above is fatal.doctorasks Postfix rather than guessing, because an operator who configured a relay has a working server without egress on 25. - Mail queue: how much is waiting, when the postfix container is running. Reported at
info, not as a pass/fail, because a few deferred messages is the normal state of any server that talks to the internet and a check that goes amber on healthy servers is one people learn to ignore. Only “unreadable” is flagged (⚠) — “unreadable” and “empty” are deliberately kept apart, since telling someone their queue is empty when nobody could count it is the failure worth designing against. This is the number Upgrade tells you to compare before and after an upgrade, so it is the one check here whose value matters rather than its status. - IPv6, when
IPV6_ENABLED=true— the row isipv6instead ofipv6 exposure: with the stack fully up it reads ip6tables’ natDOCKERchain and fails (✖) when :25 is not forwarded by DNAT, because an IPv6 client would then arrive as the bridge gateway; it warns (⚠) when ip6tables cannot be read (run as root), when a published port does not answer on IPv6, or when the host has no routable IPv6; ✔ means every published port answers on IPv6 and :25 is DNAT-forwarded; with the stack not fully up it is a ⚠ not tested.docker ipv6✖ on an engine older than Docker 27, here and inmailctl install --ipv6, which refuses it (overridable with--skip-preflight);mailctl upgraderefuses it too, with exit 5, when.envhas IPv6 on. - IPv6 exposure (IPv6 off, the default): when all ten services are running, connects to
::1on each of the eleven published ports (25, 80, 110, 143, 443, 465, 587, 993, 995, 4190, 8081). The stack publishes IPv4 only, so anything that answers is a ✖ — Docker re-originates an IPv6 connection from its bridge address, which every address-based control in the product trusts as the stack itself. This is probed, not read from.env: whether Docker binds[::]for a published port depends on the daemon’s version and the host’s IPv6, so the compose file cannot tell you. On a host with no routable IPv6 it reportsinfoand stops. With any service down it does not probe — a probe against a stopped service would answer nothing and read as a ✔ — and reports the host’s own IPv6 addresses as a ⚠ instead. - POP3: with the
dovecotservice running, reads Dovecot’s ownprotocolsline (doveconf -h protocols, the applied render). Off by configuration is aninforow saying so — a choice, not a failed listener — and the plaintext-authentication probe below then leaves the POP3 port alone. On and greeting+OKon the published port isinfo; on and not greeting is a ✖, because a client the administrator meant to serve is being refused. It reads the banner rather than testing the connect, because Docker’s userland proxy accepts a connection to a published port whether or not anything in the container listens. Dovecot not answerable at all is a ⚠ not tested. - Plaintext authentication: with the
dovecotservice running, reads the IMAP greeting and asks POP3 forCAPAon the published ports — what a scanner sees, not what the configuration says — and reports whether a password is accepted before TLS. Withtls.modeacmeorbyonothing is offered before STARTTLS and the row isinfo. Withnoneit is: a ⚠ whenPUBLISH_BINDis loopback (a dev stack, which the mode is for), and a ✖ on any other bind, because that server takes passwords in clear text from every address that can reach it, which is what CERT-Bund scans for and reports to hosting providers — the report onmaildock-throwawayarrived while this command said 24 ✔. Nothing answering is a ⚠ not tested, never a pass. - Certificates: with the
caddyservice running, one TLS handshake per front-end name — the mail hostname,postmaster.andwebmail.— onHTTPS_PORT, with SNI set to that name and validated against the host’s trust store, as a browser would. Acertificate <name>row each: ✔ with its expiry, ⚠ within 14 days of it (renewal is overdue), ✖ when a browser would refuse it (expired, a name it does not cover, self-signed) or nothing answers TLS for that name. These matter more since the console sendsStrict-Transport-Security: a browser that has seen it cannot be clicked past a bad certificate. With no TLS on the port at all (tls.mode = none) it is onecertificatesinforow. - DNS: hostname A record resolves — any A record passes; one that does not match the public IP is only noted in the detail line — and the PTR record for the public IP equals the hostname. With IPv6 on, also
public ipv6(PUBLIC_IP6from.env, detected when empty), the hostname’s AAAA and the IPv6 PTR. - Services: every compose service (
postgres redis rspamd dovecot postfix api worker web caddy cert-sync) running and healthy. - Daemon → database: Postfix’s virtual-domain map through
postmap -q, and Dovecot’s userdb throughdoveadm user. These use each daemon’s own credentials, which the API never touches, so they catch the one failure that stops all mail while every other signal stays green. - API:
GET /api/v1/health(version, setup state).
Exit code 1 with a list of ✖ items when something blocks mail; ⚠ items are deliverability or capacity warnings. Exit code 2, before any check, when the install directory has no .env with a MAIL_HOSTNAME (no install found in <dir>).
Certificates: the certificate <name> rows probe what a browser sees on the three front-end names (not the certificate on the mail ports) — ✖ when a browser would refuse it, ⚠ within 14 days of expiry (apps/cli/src/lib/certificates.ts). The worker’s hourly reputation job also raises an expiry alert on the dashboard.
Common findings
Section titled “Common findings”| Finding | Fix |
|---|---|
service postfix not running |
docker logs mailserver-postfix-1; usually a render the watcher rejected — GET /api/v1/server/config/history shows validator output; roll back with POST /api/v1/server/config/rollback. |
reverse DNS (PTR) ⚠ |
Set the PTR at your VPS provider to the mail hostname. |
hostname A record ⚠ |
Create the A record; ACME cannot issue until it resolves. |
hostname AAAA record ⚠, reverse DNS (IPv6) ⚠ |
With IPv6 on only. Publish the AAAA for the mail hostname with the address doctor names, once its ipv6 row is ✔; set the IPv6 PTR at your VPS provider. Receiving works without either, but outbound IPv6 will be refused while the PTR is missing. |
api unreachable |
docker logs mailserver-api-1; check DB_ADMIN_PASSWORD and JWT_SECRET in .env. There is no DATABASE_URL in .env: Compose builds it from DB_ADMIN_USER, DB_ADMIN_PASSWORD and DB_NAME. |
postfix → postgres ✖ or dovecot → postgres ✖ |
The daemons cannot reach or authenticate to Postgres — this stops all mail while every container still reports healthy. Check postgres is up first. If it is, the credentials disagree: the API sets the mail_daemon password from DB_DAEMON_PASSWORD on boot, so restarting api (or mailctl upgrade) repairs it. Until 2026-09-05 doctor could not see this at all, and neither can the container healthchecks — postfix status and doveadm service status imap-login both pass with a dead database. |
outbound smtp :25 ✖/⚠ blocked |
This host cannot deliver mail directly. Ask your provider to lift the block (AWS, Oracle and Hetzner grant it on request; GCP and Azure never do), or configure an outbound relay. It is an ⚠ during install — a relay is chosen later — and a ✖ in doctor when Postfix has no relayhost set, because then no mail can leave at all. A configured relay demotes this check to ⚠ and adds an outbound relay ✔ line naming it — nothing tests the relay itself, so a relay that cannot authenticate is invisible to doctor — if mail is deferred with SASL authentication failed … no mechanism available, the image is missing libsasl2-modules (guarded since 2026-09-05 by scripts/ci/postfix-sasl-guard.sh); check docker exec mailserver-postfix-1 grep status=deferred /var/log/mail/postfix.log for status=deferred after configuring one. |
outbound smtp :25 ⚠ unverified |
No MX could be resolved to test against, so egress was never proven either way. Not a finding about the host — check DNS. |
pop3 ✖ sends no +OK greeting |
Dovecot’s applied render enables POP3 but nothing greets with +OK on the published port (Docker’s proxy may still accept the connection). docker logs mailserver-dovecot-1; if POP3 is not wanted, turn it off under Settings → General instead (the row then reads off by configuration). |
ipv6 ✖ not forwarded by DNAT |
IPv6 is on but Docker carries it through its proxy, so every IPv6 client looks like the stack itself. Upgrade Docker to 27 or later (ip6tables is then the default), or set ip6tables: true in /etc/docker/daemon.json, restart Docker and recreate the stack — or set IPV6_ENABLED=false and run mailctl upgrade. |
certificate <name> ✖ or ⚠ |
A browser is refused, or soon will be, on that name — and with HSTS it cannot click past it. Restore a valid certificate over SSH: Locked out of the console by a certificate. |
plaintext auth ✖ |
This server accepts passwords in clear text on 143 and 587, and 110 while POP3 is on, from any address that can reach it: tls.mode is none on a public bind, which someone accepted with --allow-plaintext-auth (or the wizard’s checkbox). The probe tests IMAP and POP3; 587 follows from the same mode. Switch tls.mode to acme or byo with PUT /api/v1/server/config (Settings shows the mode but cannot change it; see Bring your own TLS certificate), or publish on loopback only (PUBLISH_BIND=127.0.0.1 in .env, then recreate the stack). Until then expect a CERT-Bund report through your hosting provider’s abuse desk. |
Deeper daemon status for support: GET /api/v1/server/status (watcher heartbeat + applied render id per daemon).
Incident run-books pick up where a finding leaves off: Incoming mail is not arriving (mail is not arriving), Outgoing mail is deferring (the queue is growing), The disk is full and Security incident.