tool

Stalwart Mail Server

created 2026-10-06 mail · smtp · imap · jmap · acme · self-hosting · rust

Stalwart Mail Server

All-in-one mail server in Rust: SMTP, IMAP, POP3, JMAP, CalDAV/CardDAV, spam filtering, DKIM/SPF/DMARC, plus a web admin console. Single binary, single container. It is the answer to “I want mail-in-a-box but it has to share a box with everything else”, because Mail-in-a-Box is an OS appliance that demands a whole dedicated machine and Stalwart is just a container.

First deployed 2026-10-06 for sikod.hr. See mail for that build.

The one that costs an hour: ACME is a scheduled task

Certificate issuance does not run at boot. It runs as an AcmeRenewal task, and if no task is queued, nothing happens, ever. Restarting the server does not help and produces no log line at all, which makes it look like a config problem.

Trigger it by hand at Management > Tasks > Scheduled, creating an AcmeRenewal task with the domainId. Also available via the JMAP API as x:Task/set.

Symptom when this bites: WARN No TLS certificates available total = 0, and every TLS port serving CN=rcgen self signed cert, which is Stalwart’s placeholder. Grepping the log for acme returns zero lines across restarts.

A one-shot task is created when ACME management is first enabled on a domain, so the very first boot often does issue a certificate. If that order is interrupted (a container restart mid-flight will do it) it never retries, because the task already fired.

/api/reload/certificate only reloads a certificate you already hold. It is no use when you have none.

Creating an object never activates it

The single most useful thing to know about configuring Stalwart. Objects are inert until something references them, and references are inert until the config is reloaded. Every instance looks like “the setting isn’t working” and is really “nothing is reading the setting yet”.

Three in one evening:

  1. An AcmeProvider existed, but the Domain’s certificateManagement did not point at it. No certificate, and no log line at all.
  2. A relay MtaRoute existed, but the outbound strategy did not reference it. Mail went to MX lookup and timed out.
  3. The strategy was saved, but config is cached in memory, so the running server kept using the old expression.

Config is cached, so a save is not a reload. The WebUI’s Actions > Reload > Server settings is meant to do it without a restart; in 0.16.13 it returned “Action failed” with nothing logged, and a container restart was the reliable path.

Domain object vs certificate hostname

Two settings that look interchangeable and are not. Getting them backwards breaks mail delivery outright.

fieldvaluemeaning
Domain Nameexample.comthe mail domain, the part after the @
Additional Hostnamesmailcertificate SANs, domain auto-appended

Renaming the Domain object moves your entire mail domain. Change it from example.com to mail.example.com and the server stops considering example.com local, so mail for user@example.com is treated as relay and rejected with 550 5.1.2 Relay not allowed. The MX still points at you, so mail bounces.

Additional Hostnames takes bare labels, not FQDNs, because the domain is appended. With Domain Name set to example.com, enter mail to get mail.example.com. Enter it while the domain still reads mail.example.com and you get mail.mail.example.com.

Leaving Additional Hostnames empty is documented as “request a wildcard when possible, or use the default SANs”. In practice it can fall through to the latter and certify only the apex, which does not cover the MX hostname. Be explicit.

Why the MX hostname is the one that matters

A certificate binds a name. Senders look up the MX (mail.example.com), connect to it, and validate the certificate against that name. A certificate for example.com fails, and a wildcard *.example.com would not cover the apex either. Same for IMAP and submission clients, which connect to the mail host, not the domain.

Admin credentials

  • The bootstrap password is printed once to stdout on first start and regenerates on every container recreate. Do not save it anywhere.
  • STALWART_RECOVERY_ADMIN=admin:<password> pins a credential, but only applies in recovery mode. It is break-glass, not a stable login.
  • STALWART_RECOVERY_MODE=true keeps the server in a troubleshooting state that only opens the admin port and never starts the mail listeners. It is not a login aid. Setting it to get in leaves you with no working mail.
  • The wizard creates the real admin as admin@<your-domain>, not admin.

A stale session cookie from the bootstrap instance survives the config switch and fails with Failed to decode token ... external OIDC provider, which reads like an auth misconfiguration and is really “clear your cookie”.

Hosting a webmail inside the server

The admin console is itself an Application: a static SPA bundle in a zip that the server downloads, unpacks and serves at a URL prefix. Its own entry points at github.com/stalwartlabs/webui/releases/latest/download/webui.zip.

So any static SPA can be mounted the same way, with no second container, no reverse proxy entry and no extra DNS name. bulwark-webmail ships a bulwark-lite-stalwart.zip asset built for exactly this.

Settings > Web Applications, then:

Resource URL   https://github.com/<org>/<repo>/releases/latest/download/<bundle>.zip
URL Prefix     /inbox

Use the /latest/ alias so the Update Frequency field has something to do. Avoid prefixes Stalwart owns: /admin, /account, /api, /jmap, /.well-known.

Registering it is not enough: the bundle downloads on Actions > Application Management > Update applications. Before that, the prefix 302s to add a trailing slash (proving the mount exists) and then 404s.

The admin console is not a webmail. /account is self-service settings. There is no inbox in the shipped UI.

Automatic DNS is excellent

Point it at a DNS provider (Cloudflare, DigitalOcean, deSEC, OVH, Porkbun, RFC2136 and others) and it publishes MX, SPF, DMARC, DKIM for both ed25519 and RSA, MTA-STS, TLSRPT, SRV records for every service, and autoconfig and autodiscover CNAMEs. That is a more complete and more correct zone than most hand-built mail servers ever get, including the DKIM public keys, which are the genuinely annoying ones to transcribe.

Note it does not create the MX automatically in every configuration. Check.

DNS management and certificate management are separate settings. Working DNS publication tells you nothing about whether certificates are wired up.

DNS-01 and why

Stalwart supports http-01, dns-01 and tls-alpn-01. On a box where a reverse proxy already owns ports 80 and 443, dns-01 is the only one that works, because the others need a port Stalwart cannot have. DNS-01 proves control by publishing a TXT record instead, so there is no port conflict.

This means handing the mail server an API token that can write your DNS. Scope it to the single zone and allowlist the container’s egress IP.

Relaying outbound through a smarthost

Needed whenever the host blocks egress on port 25, which most cloud providers do. Define an MtaRoute of type Relay, then reference it from the MtaOutboundStrategy routing expression:

if is_local_domain(rcpt_domain) then 'local' else 'relay'

The is_local_domain guard is load-bearing: without it, mail to your own domain is shipped to the smarthost rather than delivered locally.

Match the TLS mode to the port. 587 is STARTTLS, 465 is implicit TLS. Configuring 587 with implicitTls on fails as received corrupt message of type InvalidContentType, because a TLS ClientHello is sent to a server expecting plaintext SMTP and each side reads the other’s bytes as garbage.

There is no fallback between routes. Deleting a route does not cascade to another, it just fails delivery, so do not delete the local route hoping outbound will find the relay.

A direct-to-MX attempt on a blocked egress does not fail fast. It burns the full TCP timeout, measured at 135 seconds per MX host, then moves to the next one.

Behind a reverse proxy, its own brute-force protection locks you out

The sharpest operational trap, and it takes down the thing you would use to fix it.

Stalwart auto-bans IPs that accumulate failed authentications. Behind a reverse proxy that does not forward the client address, every HTTP request arrives from the proxy, so the only IP it can ever ban is the proxy. One run of failed logins (a stale session cookie replaying is enough) and the admin UI and webmail return 502 for everyone, including the administrator.

Mail is unaffected: SMTP, submission and IMAP listeners are published directly and see genuine client addresses. Only the proxied HTTP side dies, which makes the symptom look like a web outage rather than a security control firing.

INFO Blocked IP address (security.ip-blocked) listenerId = "http", remoteIp = <proxy>

The ban is persisted, so restarting does not clear it.

Way back in: tunnel to the container directly, bypassing the proxy, so requests arrive from a different source address.

ssh -N -L 8081:<container-ip>:8080 <host>
# then http://localhost:8081/admin

Then unblock the proxy address and add it to the trusted/allowed IPs, or it recurs on the next failed-login run. The config change needs a reload before it takes effect, like every other setting here.

The real fix is configuring Stalwart to trust the proxy’s forwarded header so bans land on actual clients. Exempting the proxy is the fast one.

Other sharp edges

  • Inbound throttle defaults to roughly 25 messages/hour/IP (MtaInboundThrottle). Easy to trip while testing by hand. It answers 452 4.4.5 Rate limit exceeded, which is a temporary code, so real senders retry and nothing is lost.
  • 0.16.13 ships broken spam rules: three SpamDnsblServer objects fail validation with Invalid variable or constant "bit_and". Those DNSBL checks are silently disabled. Not something you configured.
  • DNSSEC and DANE: in a container the host resolver usually cannot validate DNSSEC, so Stalwart disables DANE to avoid deferring mail and warns about it. MTA-STS covers similar ground.
  • MTA-STS is published but served by Stalwart’s own HTTP listener. If a reverse proxy owns 443 and has no route for mta-sts.<domain>, the policy is unreachable. Harmless while the policy says mode: testing; with mode: enforce and a cached policy it would break delivery from Google and Microsoft.
  • Default DMARC is p=reject, which is aggressive for a new server. The conventional path is p=none, read the aggregate reports, then tighten. The default rua=mailto:postmaster@<domain> also assumes a postmaster mailbox that the wizard does not create.

Backup and restore

stalwart --export <dir> needs exclusive access to the store, so the server must be stopped for it. That is seconds of downtime; SMTP senders retry on a temporary failure, so nothing is lost. A file-level copy of a live RocksDB would avoid the stop but is only crash-consistent, which is a poor trade for mail.

Two traps the export pays for immediately:

  • the image ENTRYPOINT is the stalwart binary, so pass flags only. Passing the binary path as well makes it a stray positional and it prints usage and exits 0, which looks like success and produces nothing.
  • the image runs as uid 2000, so the export directory must be chowned or the export silently writes nothing.

Restoring, and the part that can wreck production

stalwart --config <cfg> --import <dir>

Run the restored instance with no network. A restored store carries the original’s entire configuration, including its ACME account and any DNS provider credential. Boot it with network access and it may renew certificates or rewrite the live domain’s DNS records, because as far as it knows it IS that server. A restore drill that clobbers the production domain is worse than no drill.

docker run -d --network none -v cfg:/etc/stalwart -v data:/var/lib/stalwart <image>
docker exec <container> ...   # JMAP over 127.0.0.1 still answers

A restore is not verified until mail is read back

An import that exits 0 proves bytes moved. Authenticate as a real account over JMAP and count real messages:

GET  /.well-known/jmap            -> primaryAccounts["urn:ietf:params:jmap:mail"]
POST /jmap  Mailbox/get           -> totalEmails per mailbox
POST /jmap  Email/query + Email/get -> actual subjects

Anything less tests the archive format, not the backup.

Storage

RocksDB is recommended for single-node and needs no external database. It can also use PostgreSQL, MySQL, SQLite or FoundationDB, but pointing a mail server at a shared Postgres couples mail availability to that database’s uptime.

Attachments, the full-text index and the cache all default to “use data store”, and the alternatives (S3, a dedicated search backend, Redis) are all for scale this does not have. The consequence is that one volume holds everything and is the single backup target.

In Docker, only mounted paths survive a recreate: /etc/stalwart for config and /var/lib/stalwart for data. The RocksDB path must be under the latter. The default log path /var/log/stalwart/ is not mounted, so file logs die on every rebuild. Log to console and let the Docker driver rotate.

Related: mail, coolify, bulwark-webmail, acme-dns-01