Dalius's blog

RSS

Saturday, September 5, 2026

Running mox mail server alongside Kamal

This article was written by AI (Claude) based on an actual setup session, and is meant to be usable as a prompt for doing the same thing.

The problem: mox needs its own TLS certificate, because SMTP and IMAP TLS are terminated by mox itself and cannot pass through an HTTP reverse proxy. But kamal-proxy already owns ports 80 and 443, and holds the certificate for the website on the same box.

The key insight

kamal-proxy installs its ACME challenge handler only for services with automatic TLS enabled (certManager.HTTPHandler(handler), per service). A service deployed with ssl: false never sees that handler, so /.well-known/acme-challenge/ passes straight through to the target, and there is no HTTP→HTTPS redirect either.

mox, for its part, tries tls-alpn-01 first, fails (kamal-proxy answers on 443), and falls back to http-01 on its own. So:

  • kamal-proxy keeps doing ACME for the website
  • mox does its own ACME for the mail host, over a port-80 passthrough
  • neither knows about the other

Architecture

                  :25 :465 :587 :993 :8443    (published; DNAT keeps client IP)
                                |
 internet -:80--> kamal-proxy --+--> mox   (app role)
           :443->               |      :80  AccountHTTP  <- acme http-01 + healthcheck
                                |      :443 webmail/admin (mox's own cert)
                                |
                                +--> nginx (accessory, ssl: true)
                                       static mta-sts.txt + autoconfig XML

 host: example.com          -> website, ssl: true
 host: mail.example.com     -> mox, ssl: false, path-prefix /.well-known/acme-challenge/
 host: mta-sts.example.com  -> nginx, ssl: true

Webmail and admin are served by mox on published port 8443 (container 443), using mox’s own certificate.

deploy.yml, the parts that matter

service: mail
image: you/mail            # Dockerfile is one line: FROM r.xmox.nl/mox:vX.Y.Z
primary_role: mail

servers:
  mail:
    hosts: [ SERVER_IP ]
    options:
      workdir: /mox
      publish:
        - "0.0.0.0:25:25"
        - "0.0.0.0:465:465"
        - "0.0.0.0:587:587"
        - "0.0.0.0:993:993"
        - "0.0.0.0:8443:443"
    proxy:
      hosts: [ mail.example.com ]
      ssl: false                  # never true, see below
      app_port: 80
      path_prefixes: [ /.well-known/acme-challenge/ ]
      strip_path_prefix: false
      healthcheck:
        path: /metrics            # see below
        interval: 10

volumes:
  - /storage/mail/config:/mox/config
  - /storage/mail/data:/mox/data

Plus .kamal/hooks/pre-app-boot, which stops the running container so the new one can claim the ports:

#!/usr/bin/env sh
set -eu
for host in $(echo "$KAMAL_HOSTS" | tr ',' ' '); do
  ssh "root@$host" "docker ps -q --filter label=service=mail --filter label=role=mail \
    | xargs -r docker stop -t 30"
done

Bootstrap

mkdir -p /storage/mail/config /storage/mail/data
chown -R 1000:1000 /storage/mail

docker run --rm -e MOX_DOCKER=yes -w /mox \
  -v /storage/mail/config:/mox/config \
  -v /storage/mail/data:/mox/data \
  r.xmox.nl/mox:vX.Y.Z \
  mox quickstart -hostname mail.example.com you@example.com 1000

Then edit mox.conf: delete the generated internal listener, set the public listener’s IPs to 0.0.0.0 with NATIPs set to the real public IP, keep AccountHTTP on port 80, add MetricsHTTP on port 80, put account/admin/webmail HTTPS on 443, and disable AutoconfigHTTPS, MTASTSHTTPS and the webserver.

Things to watch out for

Never set ssl: true on the mail service. It is the one change that silently breaks certificate renewal forever, and you will not notice for 30 days.

Only one challenge type will ever work. tls-alpn-01 cannot succeed here, so http-01 through the passthrough is the sole path. There is no fallback. Monitor renewals.

The healthcheck needs /metrics. kamal-proxy health-checks the target with the container id as the Host header, and mox dispatches HTTP by host, so every normal path 404s and the deploy fails. MetricsHTTP registers /metrics as a system handler with a nil host matcher, which answers for any host. Enable it on port 80. Only the challenge prefix is routed from outside, so it stays private.

Kamal boots the new container before stopping the old one, which collides on the published mail ports. Hence the pre-app-boot hook. Accept a few seconds of downtime per deploy; SMTP senders retry.

mox serve is stricter than mox config test. A domain declaring MTA-STS without an MTASTS listener is a warning in the test and fatal at boot. If nginx serves the policy, delete the MTASTS block from domains.conf.

DNS wildcards stop applying to any name that has any record. If *.example.com covers your mail host, adding a TXT record there (an SPF policy, say) removes the wildcard’s A record and takes down the MX. It breaks an hour later, when caches expire, looking unrelated to the edit. Give the mail host an explicit A record.

Wait out DNS TTLs before sending the first message. Publishing SPF/DKIM does not make them visible; resolvers hold the previous record set for its TTL. A first send inside that window is rejected as unauthenticated — and then mox adds the recipient to its suppression list, after which every later attempt fails locally without contacting the remote server. The bounce names the suppression, not the original error. Check mox queue suppress list, find the real failure in the logs, and use mox queue hold while waiting.

mox logs only the first line of multi-line SMTP replies. The line naming which check failed is line 3 or 4, so it never reaches the log. Talk to the remote MX directly from the mail container with nc to see the whole thing.

Mail from your domain to your own domain gets rejected. mox always delivers via SMTP, so the message leaves the container and returns through the published port, where Docker’s hairpin NAT rewrites the source to the bridge gateway. SPF then finds no match, and mox rejects unknown senders with a soft-failing SPF. The message lands in the Rejects mailbox; moving it to the Inbox teaches mox the reputation and later mail is accepted.

MTA-STS in enforce mode removes your safety net. An expired certificate normally degrades to opportunistic TLS; under enforce, senders refuse delivery. Publish the policy only after certificates verify, keep max_age in weeks, and remember that rolling back is done by bumping the id in the TXT record — not by waiting out max_age.

At the hosting provider: unblock outbound port 25, and set reverse DNS for the IP to the mail host. Without rDNS most large providers reject your mail.

If you monitor from a container with busybox crond: it logs to syslog by default and there is no syslog daemon in the container, so job output vanishes. Use -L /dev/stdout. It does pass the container environment to jobs, so Kamal’s env: reaches them.

Was it worth it

Yes, but budget an evening for DNS rather than for Kamal. Every problem after the first deploy was a cache that had not expired yet, and each one looked like a misconfiguration.