When a small team asks for its own email and the budget is one VM, the old answer was six daemons glued together with Postfix maps. Stalwart mail server replaces that stack with one Rust process that speaks SMTP, IMAP, POP3, JMAP, ManageSieve and CalDAV/CardDAV, stores everything in RocksDB, and ships its own web admin. Version 1.0 landed on 26 October 2026 with a new first-run flow: the container starts in bootstrap mode, prints a temporary admin password to its log and waits for you on a setup wizard bound to loopback.
This guide covers the Stalwart mail server Docker Compose install on Ubuntu 26.04, the setup wizard, the DKIM, SPF and DMARC records it generates, swaks and openssl tests, Roundcube webmail and a comparison with Mailcow, Mailu and Postal. We ran every step on Ubuntu 26.04.1 LTS against Stalwart 1.0.0, the release current in October 2026, on a lab VM with no public IP, so delivery to outside mailboxes was not tested and nothing below claims it was.
Understanding how Stalwart 1.0 fits together
Stalwart runs as a single container with two volumes, and that’s the whole server. /etc/stalwart holds a config.json that only says where the data store lives. Every other setting, from domains and accounts to DKIM keys and ACME, lives inside the RocksDB store under /var/lib/stalwart, which is why you configure it in the web admin rather than a TOML file. Lose the config volume and the server boots back into the setup wizard. Lose the data volume and it starts normally on an empty store, with every account and setting gone, which is worse because nothing looks wrong at first.
Your users submit mail on 465 and read it on 993, and other mail servers reach you on 25. You manage the server through the web admin on 443, or through the wizard on 8080 during first setup only:

Licensing is worth reading before you deploy. The repository is dual-licensed under AGPL-3.0 and the Stalwart Enterprise License v2. The project says about 85% of the features are AGPL. Multi-tenancy, SCIM, sharded storage, live telemetry and the AI spam classifier sit in the Enterprise edition, which is what the lock icons next to Dashboard and Tenants in the admin mean. The v1.0.0 release notes also introduce a new database layout, so a 0.16 server cannot be upgraded in place. That matters for the upgrade section at the end.
Preparing the server, DNS and port 25
Most self-hosted mail failures are decided before you run a single container command, because the mail server is only as good as the IP address it sends from. Check these before you start:
- Ubuntu 26.04 or 24.04, or Debian 13, with sudo access
- A static public IP with a PTR record that matches the hostname
- Port 25 open inbound and outbound at your provider
- Control of the domain’s DNS zone
- An x86-64-v2 CPU (or arm64); on Proxmox, not the old
kvm64CPU type - Outbound HTTPS to GitHub, where the container fetches its web UI and spam rules
Sizing follows memory, not CPU. Stalwart gives RocksDB a block cache of about one eighth of RAM and lets messages in transit use up to a quarter of it. Each idle IMAP connection costs roughly 70 KB, so the number of people holding a mail client open is what pushes RAM up. Disk runs close to one gigabyte on disk per gigabyte of mail on RocksDB, plus headroom. Our lab VM had 2 vCPU, 4 GB RAM and a 20 GB disk, which is a floor for following along, not a production recommendation. The official requirements page lists the rest.
Every later command uses your mail hostname and domain, so set them once for this shell session. Swap in your real names, and re-run the two lines if you reconnect:
export MAIL_HOST="mail.example.com"
export MAIL_DOMAIN="example.com"
Create the DNS records that the first certificate order depends on now, before the setup wizard. Stalwart requests one Let’s Encrypt certificate covering the hostname and four client-discovery names, and if any of them fails validation no certificate is issued, so all of these must resolve and port 443 must be reachable from the internet before you click Finish setup:
- A record:
mail.example.comto the server’s public IP - MX record:
example.comtomail.example.com, priority 10 - CNAMEs to
mail.example.com:mta-sts,autoconfig,autodiscover,ua-auto-config - PTR for the public IP set to
mail.example.com, at your hosting provider
The DKIM, SPF and DMARC records come later, because Stalwart generates the keys during setup. Then test outbound port 25, since many cloud providers block it on new accounts and Stalwart can’t fix that from inside the box:
nc -vz -w 6 gmail-smtp-in.l.google.com 25
Our lab network allows it, so the connection succeeded. A timeout here means your provider filters port 25, and you’ll need to request an unblock or relay through a smarthost before outside delivery can work:
Connection to gmail-smtp-in.l.google.com (142.251.127.26) 25 port [tcp/smtp] succeeded!
The CPU check matters more than it looks. Stalwart 1.0 builds target x86-64-v2, and a Proxmox VM on the old kvm64 CPU type (the default before Proxmox VE 8, and still the default for VMs created from the CLI or API) doesn’t expose SSE4.2 or POPCNT. Our template was one of those. If this command prints nothing, the container will crash-loop (the exact error is in the troubleshooting section):
grep -o -w -E 'sse4_2|popcnt' /proc/cpuinfo | sort -u
Both flags should be listed. On our first boot with kvm64 the output was empty, and after switching the VM to CPU type host it showed popcnt and sse4_2.
Installing Docker CE on Ubuntu 26.04
Stalwart’s image needs nothing on the host except Docker Engine and the Compose plugin. Docker’s own repository already publishes a resolute suite for Ubuntu 26.04, so the standard repo setup works. The Docker CE guide for Ubuntu 26.04 explains each step; this is the short version we ran:
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
Log out and back in so the docker group applies, then confirm both tools answer:
docker --version
docker compose version
Both should print a version string; this is what our VM reported:
Docker version 29.9.2, build a82e528
Docker Compose version v5.6.1
On Debian 13 only this step changes: swap ubuntu for debian in both download.docker.com URLs and the rest of the guide is the same container. We ran the lab on Ubuntu 26.04 only. The Docker Compose guide for Ubuntu 26.04 covers the plugin in more depth.
Deploying Stalwart with Docker Compose
Keep the Compose project in its own directory so the volume names stay predictable for backups later. Compose prefixes volumes with the directory name, so ~/stalwart gives you stalwart_stalwart-data and stalwart_stalwart-etc:
mkdir -p ~/stalwart && cd ~/stalwart
vim compose.yaml
Paste this file. It’s the exact compose.yaml from our lab, which follows the official Docker page and adds a file descriptor limit, because the docs recommend 65536 and a container can otherwise inherit 1024:
services:
stalwart:
image: stalwartlabs/stalwart:v1
container_name: stalwart
init: true
restart: unless-stopped
ulimits:
nofile:
soft: 65536
hard: 65536
ports:
- "25:25"
- "465:465"
- "993:993"
- "995:995"
- "4190:4190"
- "443:443"
- "127.0.0.1:8080:8080"
volumes:
- stalwart-etc:/etc/stalwart
- stalwart-data:/var/lib/stalwart
volumes:
stalwart-etc:
stalwart-data:
A few choices in that file are deliberate. The v1 tag follows the 1.x line, so docker compose pull brings patch releases without jumping to a future 2.0. Port 8080 is published on 127.0.0.1 only because it serves the setup wizard over plain HTTP with no brute-force protection. The public ports map to these services:
- 25 for mail from other servers
- 465 for client submission over TLS
- 993 for IMAP and 995 for POP3, both TLS
- 4190 for ManageSieve filter rules
- 443 for the web admin, JMAP, CalDAV and ACME
Ports 587, 143 and 110 are missing on purpose. Stalwart 1.0 doesn’t open those listeners by default, and the startup log after setup (shown further down) lists listeners on 25, 465, 993, 995, 4190, 443 and 8080 only.
Validate the file and start the container:
docker compose config -q && docker compose up -d
The first run pulls about 122 MB of image layers (484 MB unpacked) and took 21 seconds on our VM. Check that the health check goes green:
docker compose ps --format "{{.Name}} {{.Status}}"
You want (healthy). A status of Restarting (1) almost always means the CPU problem from the prerequisites:
stalwart Up 10 seconds (healthy)
Healthy at this point only means the process is up in bootstrap mode. No domain, mailbox or certificate exists yet, and the next two sections create them.
Getting the bootstrap admin password
With no configuration on disk, Stalwart starts in bootstrap mode and writes a one-time administrator account to its log. Nothing else will show it to you, and it changes on every start until the wizard finishes, so read it straight from the container:
docker logs stalwart 2>&1 | grep -A8 'bootstrap mode'
The block looks like this (we replaced the password; yours is a random 16-character string):
🔑 Stalwart bootstrap mode - temporary administrator account
username: admin
password: Q4xV9mTzL2pRk7Wd
Use these credentials to complete the initial setup at the
/admin web UI. Once setup is done, Stalwart will provision a
permanent administrator and this temporary account will no
longer apply.
The next log line confirms the wizard port: Server started in bootstrap mode ... "No configuration file was found. Port 8080 is open for initial setup.". If you’d rather pin the password than read logs, the docs support STALWART_RECOVERY_ADMIN=admin:<password> as an environment variable, to be removed once setup is done. We used the log.
Since 8080 only listens on the server’s loopback, reach it from your workstation through an SSH tunnel. Leave this running in its own terminal:
ssh -N -L 8080:127.0.0.1:8080 [email protected]
Then open http://127.0.0.1:8080/admin in your browser and sign in as admin with the password from the log.
Running the Stalwart 1.0 setup wizard
The wizard has five screens and only the first one needs real thought. The hostname field arrives pre-filled with the container ID (ours said a3c3d8c020bd), which is useless as an SMTP greeting, so replace it with your ${MAIL_HOST} and set the default email domain. Leave both toggles on: automatic TLS orders a Let’s Encrypt certificate for the hostname, and the signing-keys switch creates the DKIM keys you’ll publish later.

Storage defaults to RocksDB in /var/lib/stalwart/, which is the volume the Compose file persists. PostgreSQL, MySQL, SQLite and FoundationDB are options, but RocksDB is what the project recommends for a single node and it means one less container to back up:

Step 3 asks for the account directory; keep “Use the internal directory” unless you already run LDAP or an OIDC provider. Step 4 is the one Docker users get wrong. The default log destination is a file under /var/log/stalwart/ inside the container, which docker logs never sees, so pick Console:

Step 5 offers automatic DNS management through your DNS provider’s API (Cloudflare, Route 53, Google Cloud DNS, RFC 2136 and others). We kept “Manual DNS server management”, which works with any registrar and only means you paste the records yourself. Click Finish setup and the wizard prints the permanent administrator, admin@ plus your domain, with a new password:

Copy that password before you close the tab. It is shown once and never logged, and it replaces the bootstrap one. With the password saved, restart the container, because Stalwart keeps running in bootstrap mode until it does:
docker restart stalwart
On the restart our server spent about 30 seconds downloading GeoIP data and the web UI bundle from GitHub before the listeners came up. Watch for these lines in docker logs stalwart:
INFO Network listener started (network.listen-start) listenerId = "smtp", localIp = ::, localPort = 25, tls = false
INFO Network listener started (network.listen-start) listenerId = "submissions", localIp = ::, localPort = 465, tls = true
INFO Network listener started (network.listen-start) listenerId = "imaps", localIp = ::, localPort = 993, tls = true
INFO Network listener started (network.listen-start) listenerId = "pop3s", localIp = ::, localPort = 995, tls = true
INFO Network listener started (network.listen-start) listenerId = "sieve", localIp = ::, localPort = 4190, tls = false
INFO Network listener started (network.listen-start) listenerId = "https", localIp = ::, localPort = 443, tls = true
INFO MTA queue started (queue.started)
INFO DKIM signature created (dkim.signature-created) name = "v1-ed25519-20261027", domain = "example.com"
INFO DKIM signature created (dkim.signature-created) name = "v1-rsa-20261027", domain = "example.com"
Two DKIM keys were created, one Ed25519 and one RSA, and the selectors carry the date. Both show up again in the zone file and in the headers of the test message.
If you ever lose the admin password, don’t wipe the volumes. The docs describe a recovery mode: add STALWART_RECOVERY_ADMIN=admin:<password> and STALWART_RECOVERY_MODE=1 under an environment: key in compose.yaml, run docker compose up -d to recreate the container (a plain docker restart doesn’t pick up new variables), and fix the account through the SSH tunnel on port 8080. Mail services, the queue and scheduled tasks stop while recovery mode is on, so remove both variables and recreate again as soon as you’re done; a recovery credential left in the environment is a permanent full-access login.
Creating mailboxes in the admin console
After the restart the admin console moves to https://${MAIL_HOST}/admin and only accepts sign-ins over HTTPS on that hostname. Port 8080 now just redirects, so close the tunnel. Your workstation needs to resolve that name to the server and reach port 443. If public DNS isn’t live yet, add a line such as 10.0.1.50 mail.example.com to your workstation’s hosts file, which is how we reached the admin on our private LAN. Sign in as [email protected] with the password from the wizard. Until a real certificate arrives your browser will warn about a self-signed one; the server presents a placeholder called rcgen self signed cert in the meantime.
Go to Directory > Accounts > Create user. Enter the local part as the username, pick the domain, and under Authentication > Credentials click Add item to give the account a password. An account without a credential exists but can’t log in to IMAP or SMTP:

We created alice and bob this way so there would be a sender and a recipient for the tests. The Accounts list then shows both next to the administrator:

Set up a postmaster address too, either as its own account or as an alias on an existing one, because the DMARC and TLS reporting records Stalwart generates send their reports to postmaster@ your domain.
Publishing the DKIM, SPF and DMARC records
Stalwart writes the zone for you; your job is to copy it to your DNS host. Open Domains > Domains, click the menu at the end of your domain’s row and choose View Zone File. The page shows a BIND-format block you can paste into most DNS panels:

This is the text it produced for example.com in our lab. Your DKIM keys and MTA-STS id will differ, so always copy from your own server:
v1-ed25519-20261027._domainkey.example.com. IN TXT "v=DKIM1; k=ed25519; h=sha256; p=tr0Lg61I91eX8KfMaIPXvN6MSUxDhJOhxm3NYgB83Mo="
v1-rsa-20261027._domainkey.example.com. IN TXT (
"v=DKIM1; k=rsa; h=sha256; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqaNCoyFpD2BUPN9SQBI7a3+cxC3JkIAGpiTZqqcLdLfr1aY0UjkZqX+tRXBcy1I/Hw5VEZ5GQ0+BrSKLLIb6iCMXuzLsk3o2IwF7PIClLzqO/ZhRTVN0nONlqw7bTLFYC/3/sZymbqk7XiSWeZn/VNwSmlYzllmf3icOAG3mSWA6NLEAseCTKRa"
"WE9WPOAioXUo+w5FUyrdQsImbOKpYJ7cQZqrEb+eWlv+EQ1F/WROzGeRcSTGkYeTpnvzyH33nHEnc9svxcpY5yx+y3bcHUyjOehU4Gz4RedS7Wo+2QRpkqYzwyweS0wl0mRkuvOlYe1O/HTrwwasc+3RXZ0SdBwIDAQAB"
)
mail.example.com. IN TXT "v=spf1 a -all"
example.com. IN TXT "v=spf1 mx -all"
example.com. IN MX 10 mail.example.com.
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:[email protected]"
_caldavs._tcp.example.com. IN SRV 0 1 443 mail.example.com.
_carddavs._tcp.example.com. IN SRV 0 1 443 mail.example.com.
_imaps._tcp.example.com. IN SRV 0 1 993 mail.example.com.
_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.
_pop3s._tcp.example.com. IN SRV 0 1 995 mail.example.com.
_submissions._tcp.example.com. IN SRV 0 1 465 mail.example.com.
mta-sts.example.com. IN CNAME mail.example.com.
_mta-sts.example.com. IN TXT "v=STSv1; id=17174664717463327589"
_smtp._tls.example.com. IN TXT "v=TLSRPTv1; rua=mailto:[email protected]"
ua-auto-config.example.com. IN CNAME mail.example.com.
_ua-auto-config.example.com. IN TXT "v=UAAC1; a=sha256; d=4PelYnAaYOkI5V2VklhpKxnMk5ou0cd/AVpeYtRzj2w="
autoconfig.example.com. IN CNAME mail.example.com.
autodiscover.example.com. IN CNAME mail.example.com.
Read the DMARC line before you paste it. Stalwart starts at p=reject, the strictest policy, so any other service that sends as your domain (a newsletter tool, a helpdesk, a billing system) will have its mail rejected unless it’s added to SPF or signs with DKIM. If you’re migrating an existing domain, publish p=none first and tighten it once the reports are clean. Each group of records has a job, and skipping one costs you something specific.
| Record | What it does | If you skip it |
|---|---|---|
| MX | Tells other servers where your domain’s mail goes | No inbound mail at all |
| SPF (two TXT) | Lists which hosts may send as the domain and as the hostname | Receivers treat your mail as unauthenticated |
| DKIM (two TXT) | Publishes the Ed25519 and RSA public keys for message signatures | Signatures fail, and DMARC fails with them |
| DMARC | Policy for mail that fails SPF and DKIM, plus a report address | Gmail and Yahoo require DMARC from bulk senders |
| MTA-STS, TLS-RPT | Requires TLS for mail sent to you and asks for failure reports | Inbound mail can fall back to plaintext |
| SRV, autoconfig, autodiscover | Let Thunderbird, Apple Mail and Outlook find the server ports | Users type host and ports by hand |
You already created the MX and the four CNAMEs before the wizard, so add the rest: both DKIM keys, the two SPF records, DMARC, MTA-STS, TLS-RPT, the SRV records and the _ua-auto-config TXT. The A record and PTR from the prerequisites aren’t in the zone file at all. With manual DNS Stalwart also never rotates the DKIM keys on its own, according to its DNS docs, so put a calendar reminder in for that.
Getting a TLS certificate from Let’s Encrypt
Stalwart orders its own certificate, so there is no certbot or reverse proxy in this setup. With manual DNS it uses the TLS-ALPN-01 challenge on port 443, which means Let’s Encrypt must reach port 443 at ${MAIL_HOST} from the internet. The certificate covers more names than the hostname, and the docs say to create their CNAMEs before the post-wizard restart so the first order includes them:
- the mail hostname itself
- mta-sts, for the MTA-STS policy file
- autoconfig and autodiscover, for client setup
- ua-auto-config, the newer client discovery name
Miss one and the order can fail for all of them, since they’re requested together.
Our lab couldn’t get a certificate, and the log shows why. We used the reserved example.com domain, which Let’s Encrypt refuses as a contact address, so the ACME account was never created:
ERROR Resource error (resource.error) reason = "HTTP error: Unexpected status 400 Bad Request: {\n \"type\": \"urn:ietf:params:acme:error:invalidContact\",\n \"detail\": \"Error validating contact(s) :: contact email has forbidden domain \\\"example.com\\\"\",\n \"status\": 400\n}"
With a real domain that registration step goes through, and the only remaining requirement is port 443 open to the internet. Check which certificate the server is actually presenting on IMAPS:
echo | openssl s_client -connect "${MAIL_HOST}:993" -servername "${MAIL_HOST}" 2>/dev/null | openssl x509 -noout -subject -issuer -dates
Ours still served the placeholder, valid from 1975 to the year 4096. On your server the issuer should name Let’s Encrypt once the order succeeds, and until it does mail clients will refuse to connect without an exception:
subject=CN=rcgen self signed cert
issuer=CN=rcgen self signed cert
notBefore=Jan 1 00:00:00 1975 GMT
notAfter=Jan 1 00:00:00 4096 GMT
While no certificate exists, Stalwart logs WARN No TLS certificates available (tls.no-certificates-available) total = 0 on every TLS handshake. We counted 263 of those lines in under 20 minutes of testing, so that warning is the first thing to grep for when clients complain. If port 443 can’t be public, the alternative is DNS-01 through one of the automatic DNS providers (it also covers wildcards), set under Settings > TLS > ACME Providers.
Check one more field if no certificate shows up. The wizard registers the ACME account when you click Finish setup, using postmaster@ your domain as the contact, and the docs say that if registration fails the wizard still completes but leaves the domain on manual certificates. Our lab is the example: the Domains list showed Manual TLS certificate management even though the toggle was on. Fixing DNS or the firewall afterwards doesn’t change that by itself, so open Domains > Domains, edit the domain, and under TLS > Certificate Management switch to automatic management with the Let’s Encrypt provider. If no Let’s Encrypt provider is offered there, create one under Settings > TLS > ACME Providers first. We couldn’t complete that switch on a private LAN, so confirm the issuer with the openssl check afterwards.
Testing SMTP submission and IMAP with swaks and openssl
Test from the server first, so DNS, firewalls and client settings are out of the picture. Install swaks:
sudo apt-get install -y swaks
If your DNS isn’t live yet, a temporary /etc/hosts line lets the hostname resolve on the box itself. We needed it because example.com doesn’t point at our lab; remove it once real DNS answers:
echo "127.0.0.1 ${MAIL_HOST}" | sudo tee -a /etc/hosts
Now send from alice to bob through implicit-TLS submission on 465, authenticating as alice. Use the password you gave alice in the admin; the single quotes stop bash from expanding a ! in it:
swaks --server "${MAIL_HOST}" --port 465 --tls-on-connect --auth LOGIN --auth-user "alice@${MAIL_DOMAIN}" --auth-password 'Al1ce-Lab-Pass!26' --from "alice@${MAIL_DOMAIN}" --to "bob@${MAIL_DOMAIN}" --header "Subject: Stalwart submission test" --body "Sent through port 465 with swaks."
Look for 235 2.7.0 Authentication succeeded and a queue id after the dot. The TLS lines also confirm what the openssl check said, that the self-signed placeholder was in use:
=== Trying mail.example.com:465...
=== Connected to mail.example.com.
=== TLS started with cipher TLSv1.3:TLS_AES_256_GCM_SHA384:256
=== TLS peer certificate failed CA verification (self-signed certificate), failed host verification (using host mail.example.com to verify)
<~ 220 mail.example.com Stalwart ESMTP at your service
~> EHLO mail01
<~ 250-mail.example.com you had me at EHLO
<~ 250-SMTPUTF8
<~ 250-SIZE 104857600
<~ 250-REQUIRETLS
<~ 250-PIPELINING
<~ 250-NO-SOLICITING
<~ 250-ENHANCEDSTATUSCODES
<~ 250-CHUNKING
<~ 250-BINARYMIME
<~ 250-AUTH PLAIN LOGIN XOAUTH2 OAUTHBEARER
<~ 250 8BITMIME
~> AUTH LOGIN
<~ 235 2.7.0 Authentication succeeded.
~> MAIL FROM:<[email protected]>
<~ 250 2.1.0 OK
~> RCPT TO:<[email protected]>
<~ 250 2.1.5 OK
~> DATA
<~ 354 Start mail input; end with <CRLF>.<CRLF>
<~ 250 2.0.0 Message queued with id 4ae0f7396a00000.
~> QUIT
<~ 221 2.0.0 Bye.
We trimmed the TLS detail lines, the base64 username and password exchange and the message headers from that capture. Next, read the message back over IMAPS as bob and ask for the DKIM headers, which proves delivery into the mailbox and signing in one pass. The command sits in single quotes, so edit bob’s address and password in it by hand:
(sleep 1; printf 'a1 LOGIN [email protected] "B0b-Lab-Pass!26"\r\n'; sleep 1; printf 'a2 SELECT INBOX\r\n'; sleep 1; printf 'a3 FETCH 1 (BODY.PEEK[HEADER.FIELDS (FROM TO SUBJECT DKIM-SIGNATURE RECEIVED)])\r\n'; sleep 1; printf 'a4 LOGOUT\r\n'; sleep 1) | openssl s_client -connect "${MAIL_HOST}:993" -quiet -crlf
The message sits in bob’s inbox with two signatures, a=ed25519-sha256 with the v1-ed25519-20261027 selector and a=rsa-sha256 with v1-rsa-20261027, the same selectors as in the zone file (capability list and the RSA signature trimmed):
* OK [CAPABILITY IMAP4rev2 IMAP4rev1 ENABLE SASL-IR LITERAL+ ID UTF8=ACCEPT AUTH=PLAIN AUTH=OAUTHBEARER AUTH=XOAUTH2] Stalwart IMAP4rev2 at your service.
a1 OK [CAPABILITY IMAP4rev2 IMAP4rev1 ... METADATA LIST-METADATA] Authentication successful
* 1 EXISTS
a2 OK [READ-WRITE] SELECT completed
* 1 FETCH (BODY[HEADER.FIELDS (FROM TO SUBJECT DKIM-SIGNATURE RECEIVED)] {940}
DKIM-Signature: v=1; a=ed25519-sha256; s=v1-ed25519-20261027; d=example.com; c=relaxed/relaxed; r=y;
h=Message-Id:Subject:From:To:Date; t=1793080939; bh=uK8icjzCDGqtoGhwvYih+pN
CSGCyDlB7f1PRJXW04sE=; b=x/QjKaJe8UDgofxNwAq+/PIfCC5DDpNiEnZabQN56jtHWJbz3b
yemMyEvobeJ5eKsOjjeK5dX0lu2OqjM/O1Dw==;
DKIM-Signature: v=1; a=rsa-sha256; s=v1-rsa-20261027; d=example.com; c=relaxed/relaxed; r=y;
h=Message-Id:Subject:From:To:Date; t=1793080939; bh=uK8icjzCDGqtoGhwvYih+pN
CSGCyDlB7f1PRJXW04sE=; b=kGnUIhk6Ob1jBdNeswDMF4wbTEvY4O7qMGafF/LXPekqSSJSms
...
To: [email protected]
From: [email protected]
Subject: Stalwart submission test
)
a3 OK FETCH completed
* BYE Stalwart IMAP4rev2 bids you farewell.
a4 OK LOGOUT completed
Two negative tests are worth the extra minute, because a mail server that accepts too much is worse than one that accepts nothing. An unauthenticated client on port 25 asking to send to an outside address should be refused, which is what keeps you off spam blocklists:
swaks --server 127.0.0.1 --port 25 --ehlo client.outside.test --from [email protected] --to [email protected] --quit-after RCPT
Stalwart accepted the sender and refused the recipient, so it’s not an open relay (EHLO lines trimmed below). A wrong password on 465 got 535 5.7.8 Authentication credentials invalid. in the same way:
<- 250 2.1.0 OK
<** 550 5.7.1 Relay not allowed.
<- 221 2.0.0 Bye.
A few minutes after those tests, the container sat idle with three accounts and Roundcube running next to it. Stalwart sat between 86 and 103 MiB across our readings:

That is a measurement on an almost empty server, not a capacity figure. Plan RAM from the sizing drivers above once real users and their open IMAP sessions arrive.
Locking down the ports with a firewall
UFW won’t protect the mail ports in this setup, and it’s better to learn that here than from a scan. Docker publishes container ports through its own iptables rules, which are evaluated before UFW’s. We enabled UFW with only OpenSSH allowed and then probed the server from another machine on the LAN:
sudo ufw allow OpenSSH
sudo ufw --force enable
Ports 993, 465 and 25 were still reachable even though UFW’s status listed nothing but OpenSSH, while 8080 was blocked only because Compose binds it to 127.0.0.1:
from LAN: 993 reachable
from LAN: 465 reachable
from LAN: 25 reachable
from LAN: 8080 blocked
For a mail server that’s mostly fine, since 25, 465, 993, 995, 4190 and 443 are meant to be public. Do the real filtering where Docker can’t bypass it: your cloud provider’s security group or firewall, or rules in the DOCKER-USER chain as Docker’s packet filtering docs describe. If you don’t need POP3 or ManageSieve, the simplest fix is to delete the 995 and 4190 lines from compose.yaml.
Adding webmail with Roundcube (optional)
Stalwart does not ship webmail. Its web UI is /admin for administrators and /account for users to change passwords and settings, so anyone who wants mail in a browser needs a separate client. Roundcube is the usual choice, and SnappyMail is a lighter alternative; both talk plain IMAP and SMTP to Stalwart. Nothing in the core setup depends on this section, so skip it if everyone uses Thunderbird, Apple Mail or a phone.
Run Roundcube as a separate Compose project, so updating it never touches the mail server:
mkdir -p ~/roundcube/config && cd ~/roundcube
vim compose.yaml
This is the file from our lab. The extra_hosts line points the mail hostname at the Docker host, so Roundcube reaches Stalwart locally without relying on your router handling hairpin NAT, and the certificate name still matches:
services:
roundcube:
image: roundcube/roundcubemail:latest
container_name: roundcube
restart: unless-stopped
ports:
- "127.0.0.1:8000:80"
extra_hosts:
- "mail.example.com:host-gateway"
environment:
ROUNDCUBEMAIL_DEFAULT_HOST: ssl://mail.example.com
ROUNDCUBEMAIL_DEFAULT_PORT: "993"
ROUNDCUBEMAIL_SMTP_SERVER: ssl://mail.example.com
ROUNDCUBEMAIL_SMTP_PORT: "465"
ROUNDCUBEMAIL_DB_TYPE: sqlite
volumes:
- roundcube-db:/var/roundcube/db
- ./config:/var/roundcube/config
volumes:
roundcube-db:
Our Stalwart was still on its self-signed placeholder, so Roundcube refused the TLS connection until we told PHP to skip verification. Only add this file while you’re in the same state, and delete it once Let’s Encrypt has issued the real certificate:
vim ~/roundcube/config/selfsigned.inc.php
Add the two connection overrides:
<?php
// Lab only: Stalwart is still serving its self-signed fallback certificate.
$config["imap_conn_options"] = ["ssl" => ["verify_peer" => false, "verify_peer_name" => false]];
$config["smtp_conn_options"] = ["ssl" => ["verify_peer" => false, "verify_peer_name" => false]];
Start it, then tunnel port 8000 the same way you tunnelled the wizard (ssh -N -L 8000:127.0.0.1:8000 [email protected]):
docker compose up -d
Roundcube was up within 20 seconds, and signing in as bob at http://127.0.0.1:8000 showed the swaks message from alice:

It used 64 MiB at idle. For users outside your network, put Roundcube behind HTTPS on its own hostname such as webmail.example.com, with a reverse proxy in front. Stalwart already owns port 443 on this host, so either give the proxy a second IP or move Stalwart’s HTTPS listener; we didn’t test either layout.
Keeping Stalwart backed up and upgraded
Everything Stalwart knows lives in the two named volumes, so a backup is a stopped container and a tarball. Stopping first matters because copying a live RocksDB directory can capture half-written files:
cd ~/stalwart && docker compose stop
mkdir -p ~/backups
docker run --rm -v stalwart_stalwart-data:/data:ro -v stalwart_stalwart-etc:/etc-stalwart:ro -v ~/backups:/backup alpine tar czf /backup/stalwart-$(date +%F).tar.gz -C / data etc-stalwart
docker compose start
Our lab archive was 697 KB and the server was down for about 12 seconds, most of which was pulling the Alpine image the first time. Copy the tarball off the server, because a backup that lives on the disk it protects only covers mistakes, not hardware failure.
A backup you have never restored is a guess. We tested the restore by deleting both volumes and unpacking the archive into fresh ones:
cd ~/stalwart && docker compose down
docker volume rm stalwart_stalwart-data stalwart_stalwart-etc
docker volume create stalwart_stalwart-data
docker volume create stalwart_stalwart-etc
docker run --rm -v stalwart_stalwart-data:/data -v stalwart_stalwart-etc:/etc-stalwart -v ~/backups:/backup:ro alpine tar xzf /backup/stalwart-2026-10-27.tar.gz -C /
docker compose up -d
Change the archive date to yours. If Compose warns that the volumes already exist but weren’t created by Compose, that’s expected after creating them by hand. Forty seconds later the container was healthy, and an IMAP login as bob showed * 1 EXISTS, the same message as before the wipe, so accounts, passwords and mail all came back from the tarball.
Patch upgrades within 1.x are a pull and a recreate, because the v1 tag moves with each patch release. The routine we’d keep is short:
- Read the release notes for breaking changes
- Take the volume backup above
- Pull the new image and recreate the container
- Confirm the version and the health status
Step 3 is one line:
docker compose pull && docker compose up -d
Then check the version string, and run docker compose ps to see the health status:
docker exec stalwart stalwart --version
Moving an existing 0.16 server to 1.0 is a different job. The release notes say 0.16.x deployments can’t be upgraded in place because of the new database layout. Instead you move the data with the separate stalwart-migrate tool into a fresh 1.0 store, starting from 0.16.27. If you simply point a 1.0 container at an old volume, it stops with “The data store was created by a Stalwart version prior to 1.0 … and cannot be opened by this version.” We did not run a migration, so follow the official 0.16 to 1.0 migration guide rather than improvising one.
The one log to watch is the container log filtered for problems. Certificate gaps, rejected deliveries and authentication failures all surface there:
docker logs --since 1h stalwart 2>&1 | grep -E 'WARN|ERROR'
An empty result after the first day is what you want to see.
Troubleshooting Stalwart on Docker
These are the failures we hit in the lab, plus the two that only a public server will show you.
Error: “This build of Stalwart requires an x86-64-v2 compatible CPU”
The container restarts every few seconds and docker logs stalwart repeats this line:
This build of Stalwart requires an x86-64-v2 compatible CPU (SSE3, SSSE3, SSE4.1, SSE4.2, POPCNT, CMPXCHG16B and LAHF/SAHF). If Stalwart runs in a virtual machine, set its CPU type to 'host' or 'x86-64-v2'.
Our Proxmox VM came from an older template on the kvm64 CPU type, which lscpu reports as “Common KVM processor”. Set the VM’s CPU type to host (or x86-64-v2-AES) under Hardware > Processors, then power the VM off and on, because a reboot from inside the guest keeps the old CPU model. After that the container came up healthy without any other change.
Error: “contact email has forbidden domain”
That’s the ACME invalidContact response shown in the TLS section. Let’s Encrypt rejects reserved domains such as example.com as account contacts. It only happens when you test with a placeholder domain; with your real domain the account registers. On a server that already ran the wizard with a bad contact or broken DNS, also switch the domain’s Certificate Management to automatic, as described in the TLS section.
Error: “550 5.5.0 Invalid EHLO domain”
Our first unauthenticated swaks test on port 25 was rejected at EHLO, because swaks announced the VM’s short hostname mail01. Stalwart expects a fully qualified name from other servers, so pass one with --ehlo as in the relay test above. Real mail servers already send their FQDN, so this only bites hand-run tests.
Outbound mail sits in the queue
If local delivery works but nothing reaches Gmail or Outlook, check port 25 first with the nc test from the prerequisites, because a blocked port looks exactly like a slow queue. Next check the PTR: run dig -x on your public IP and confirm it returns ${MAIL_HOST}. Large providers reject or junk mail from IPs whose reverse DNS is missing or generic. We couldn’t test either case from a private LAN, so treat this as a checklist rather than captured output.
The wizard on port 8080 is not reachable
That’s by design: the Compose file binds 8080 to 127.0.0.1, so it only answers through the SSH tunnel. After the wizard and restart, 8080 stops being the way in and the admin console lives at https://${MAIL_HOST}/admin. The docs add one more case. If /admin returns 404, the container couldn’t download its web UI bundle from GitHub, so check outbound HTTPS from the host.
Comparing Stalwart with Mailcow, Mailu and Postal
Stalwart is the lightest of the four by a wide margin, and the only one that is a single process. Mailcow and Mailu are Docker bundles of separate daemons (Postfix, Dovecot, Rspamd and friends) with webmail included, and Postal isn’t a mailbox server at all: it’s a mail delivery platform for applications, with no IMAP. Only the Stalwart memory figure below is our measurement; the others are what each project’s documentation states as the minimum.
| Stalwart | Mailcow | Mailu | Postal | |
|---|---|---|---|---|
| Design | One Rust binary, one container | Many containers around Postfix and Dovecot | Several containers around Postfix and Dovecot | Delivery platform with an HTTP API |
| User mailboxes (IMAP) | Yes, plus JMAP and POP3 | Yes | Yes | No |
| Calendars and contacts | CalDAV and CardDAV built in | Via SOGo | Optional Radicale container | No |
| Webmail | None, add Roundcube or SnappyMail | SOGo included | Roundcube or SnappyMail included | None |
| RAM | 86 to 103 MiB idle (measured, our lab) | 6 GiB plus 1 GiB swap minimum (vendor) | 3 GB with ClamAV, 1 GB without, each plus 1 GB swap (vendor) | At least 4 GB (vendor) |
The vendor figures come from the Mailcow, Mailu and Postal prerequisite pages. They include antivirus and full-text search that Stalwart either builds in or doesn’t run, so this isn’t a like-for-like benchmark. It still decides the VM size you rent.
Pick Stalwart for a small team that wants mail, calendars and contacts on one modest VM and is happy to add webmail separately. Pick Mailcow if you want SOGo groupware and a mature admin UI and have 6 GB or more to spare, or Mailu for a middle ground with webmail in the box. If what you really need is an outbound sender for application mail, Postal is the closer fit. For a traditional stack you can see every piece of, our Postfix guide for Ubuntu 26.04, the iRedMail installer and the Postfix, Dovecot and Roundcube build on Rocky Linux 10 cover that route.