Stalwart: an all-in-one mail server on a VPS
What Stalwart's single Rust binary replaces in a Postfix and Dovecot stack, what it cannot fix, and when a mailcow install is still the better call.
What Stalwart collapses into one binary
Stalwart is a mail server you run on one VPS as a single Rust binary. It answers SMTP, IMAP, POP3, JMAP, CalDAV, CardDAV and WebDAV from the same process, and it carries its own spam filter, its own message store and its own ACME client. A conventional stack does that same work with Postfix, Dovecot, Rspamd, a database for accounts and a separate certificate tool. Stalwart replaces all of it with one service unit and one config file at /etc/stalwart/config.json.
Every figure, setting name and command below is quoted from Stalwart's own documentation, release pages and install script, read on 28 August 2026, against release v0.16.19 (published 24 August 2026). These are commands for you to run on your own server. Each one is followed by the check that tells you whether it worked.
Stalwart is dual licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) and the Stalwart Enterprise License v2. Some features are enterprise only. The documented HTTP endpoint list marks /scim/v2/* that way. Read the licence terms before you plan a deployment around a feature you have not run yourself.
One binary is a real cut in moving parts. It is not a cut in the two things that decide whether your mail arrives.
Port 25 and DNS reputation do not care which software you run
Outbound TCP port 25 is the first gate. Many VPS providers block it by default on new accounts, and a blocked port 25 means your server can talk to itself and to nobody else. Test it before you install anything.
sudo apt update && sudo apt install -y netcat-openbsd
nc -vz -w 5 alt1.aspmx.l.google.com 25A healthy result prints Connection to alt1.aspmx.l.google.com ... 25 port [tcp/smtp] succeeded! in about a second. A blocked port hangs for the full five seconds and then prints nc: connect to alt1.aspmx.l.google.com port 25 (tcp) failed: Connection timed out, because the packets are dropped upstream and nothing sends a reset back to you. If you see that, open a ticket with your provider. No mail server can route around a dropped packet.
The second gate is what receiving networks think of your IP address and your domain. That means reverse DNS on the IP, SPF, DKIM, DMARC, and the sending history of the address block you landed in. Stalwart's DNS setup page is blunt about where that work happens: reverse DNS records "are typically configured by the hosting provider, not by Stalwart itself". The same is true of the rest of the category. It lives in your DNS zone and in your provider's control panel, not in the mail server.
So this page does not re-teach those records. We have one that does: SPF, DKIM and DMARC set up once for everything that sends mail. If you have not yet decided whether to run your own mail at all, start with our honest look at whether self-hosted email is still worth it. Choosing Stalwart changes none of that arithmetic.
What a small VPS needs to run a Stalwart mail server
Stalwart's system requirements page, read on 28 August 2026, gives these numbers. Idle memory use is around 100 MB. A small deployment of 5 to 10 users is fine on 1 GB of RAM. A low-traffic setup of about 5 users runs on a single CPU core, and the page adds that "as concurrency and activity increase, more CPU cores will be necessary to maintain low latency and high throughput". The default cap is 8,192 concurrent connections across all services, and it is configurable.
That page states no minimum disk size, so size the disk for the mail you expect to keep, plus headroom for the store to compact.
Three outbound paths must work, or the server looks broken in ways that have nothing to do with mail. It fetches the web interface bundle from https://github.com/stalwartlabs/webui/releases/latest/. It reaches https://acme-v02.api.letsencrypt.org/directory for certificates. It needs DNS on UDP and TCP port 53 for MX and authentication record lookups. A locked-down egress firewall that blocks the first one gives you a running mail server with no admin interface.
Install a pinned release, not "latest"
The official installer is a shell script. Read it before you run it.
curl --proto '=https' --tlsv1.2 -sSf https://get.stalw.art/install.sh -o install.sh
less install.sh
sudo sh install.shReading that script on 28 August 2026 shows exactly what it does. It creates a stalwart service account and the directories it needs. It then downloads from https://github.com/stalwartlabs/stalwart/releases/latest/download. The binary lands at /usr/local/bin/stalwart with mode 0755. Config goes to /etc/stalwart/config.json, data to /var/lib/stalwart, logs to /var/log/stalwart, all three directories mode 0750 owned by stalwart. An environment file is written to /etc/stalwart/stalwart.env with mode 0640 owned by root:stalwart. The script takes one optional install prefix and a --fdb flag for the FoundationDB build. It takes no version argument.
That last point matters. The script always pulls the newest release, so two servers built a week apart are not running the same code. Pin the binary yourself right after, which is also the upgrade path the v0.16.19 release notes give: "If you are upgrading from v0.16.x, replace the binary (or run docker pull)."
STALWART_TAG=v0.16.19
curl -fsSLO "https://github.com/stalwartlabs/stalwart/releases/download/${STALWART_TAG}/stalwart-x86_64-unknown-linux-gnu.tar.gz"
tar zxf stalwart-x86_64-unknown-linux-gnu.tar.gz
sudo systemctl stop stalwart
sudo install -m 0755 -o root -g root stalwart /usr/local/bin/stalwart
sudo systemctl start stalwart
systemctl is-active stalwartsystemctl is-active stalwart should print active. Anything else, read journalctl -u stalwart -n 50. Every release asset ships with a matching .sigstore.json bundle, so the download's signature is verifiable before you install it.
The unit the script writes runs as User=stalwart and sets AmbientCapabilities=CAP_NET_BIND_SERVICE. That capability is the reason an unprivileged account can bind ports 25, 443, 465 and 993. If you write your own unit later and leave that line out, the service fails at startup, because a normal user cannot bind a port below 1024.
Where the first admin password is printed
Stalwart starts in bootstrap mode and writes a temporary 16-character password to the service log once.
sudo journalctl -u stalwart -n 200 | grep -A8 'bootstrap mode'The setup wizard listens on plain HTTP on port 8080, so do not expose that port. Tunnel it from your laptop over SSH instead:
ssh -N -L 8080:127.0.0.1:8080 you@your-vpsThen open http://127.0.0.1:8080/admin and sign in as admin with the password from the log. The wizard asks for the server hostname, the default mail domain, TLS, storage, the account directory, logging and DNS handling. Restart the service when it finishes, then use https://<your-host>/admin from that point on.
If the password has scrolled out of the log, set a fixed one instead. /etc/stalwart/stalwart.env ships commented entries for exactly this, including STALWART_RECOVERY_ADMIN=admin:changeme, STALWART_RECOVERY_MODE=true and STALWART_RECOVERY_MODE_PORT (default 8080). Uncomment, restart, sign in, then comment it out again. Stalwart's hardening page says to keep that credential for emergencies only, and to never sign in to IMAP, JMAP or WebDAV with an administrator account.
The same page lists which listeners to keep: port 25 for inbound SMTP, 465 for submission with implicit TLS, 993 for IMAPS, and 443 for everything HTTP. It calls 587, 143, 4190, 110, 995 and 8080 non-essential, and says to turn 8080 off once setup is done.
Running it under Docker with a pinned tag
The documented image is stalwartlabs/stalwart. The tag v0.16.19 was present on Docker Hub on 28 August 2026, alongside a -alpine variant. Pin the patch version, not the v0.16 floating tag, for the same reason as the binary above.
services:
stalwart:
image: stalwartlabs/stalwart:v0.16.19
container_name: stalwart
restart: unless-stopped
ports:
- "25:25"
- "465:465"
- "993:993"
- "443:443"
- "127.0.0.1:8080:8080"
volumes:
- stalwart-etc:/etc/stalwart
- stalwart-data:/var/lib/stalwart
volumes:
stalwart-etc:
stalwart-data:That file is the documented docker run command written as Compose, with the non-essential listeners left out and the setup port bound to localhost. Bring it up and read the same bootstrap line:
docker compose up -d
docker compose logs stalwart 2>&1 | grep -A8 'bootstrap mode'The Docker page also documents -e STALWART_RECOVERY_ADMIN=admin:mySecretPass as the way to set a fixed credential at startup, which is the Compose environment: key if you prefer that to reading logs.
The full documented port list, and why this file is shorter
Stalwart's Docker page publishes 443, 8080, 25, 587, 465, 143, 993, 110, 995 and 4190. Its hardening page calls 587, 143, 110, 995 and 4190 non-essential, and says to disable 8080 after setup. Add back only what your clients actually ask for. If a phone insists on STARTTLS submission, publish 587. If your users write Sieve rules from a desktop client, publish 4190.
If Compose is new to you, our Docker Compose walkthrough for a VPS covers the file layout and the named-volume model this uses. One warning that bites mail servers in particular: Docker publishes ports by writing its own firewall rules, and those rules are matched before ufw's, so ufw deny 8080 does not close a port Compose published. Binding to 127.0.0.1 in the port mapping is what actually closes it, which is why the file above does that and the SSH tunnel still applies.
TLS with no certbot, and what that costs you
Stalwart implements ACME (automatic certificate management environment) directly, so there is no certbot and no renewal hook. The documentation lists four validation methods. HTTP-01 answers a challenge request on port 80. TLS-ALPN-01 presents a purpose-built certificate on port 443 using the ACME-specific ALPN protocol. DNS-01 publishes temporary TXT records and is one of the two methods that can issue wildcards. DNS-PERSIST-01 uses long-lived authorization TXT records instead of writing a new one at every renewal.
The cost is that Stalwart wants to own the port. TLS-ALPN-01 works by completing the TLS handshake itself, so it cannot succeed behind a reverse proxy that terminates TLS for you. If nginx or Caddy already holds 443 on that box, either move Stalwart to DNS-01 or give it its own IP address.
DANE and MTA-STS, and the defaults you should know
Both are configured per TLS strategy on the MtaTlsStrategy object, under Settings, MTA, Outbound, TLS Strategies in the web interface. The dane field defaults to optional, which attempts DANE validation when the recipient publishes TLSA records and falls back to ordinary STARTTLS when they do not. Set to require and delivery only proceeds against a verifiable TLSA record. The mtaSts field behaves the same way, defaulting to optional. Related timeouts are tlsTimeout (default 3 minutes) and mtaStsTimeout (default 5 minutes).
On the inbound side Stalwart can publish your own MTA-STS policy at https://mta-sts.<domain>/.well-known/mta-sts.txt, which needs port 443 open. The MtaSts singleton has mode (default testing), maxAge (default 7 days) and mxHosts, which falls back to the hostnames in your TLS certificate when empty. You supply two DNS records: a mta-sts CNAME pointing at the mail host, and a _mta-sts TXT record carrying the policy identifier.
dig +short TXT _mta-sts.example.org
curl -s https://mta-sts.example.org/.well-known/mta-sts.txtThe TXT lookup should return a v=STSv1; id=... string and the curl should return the policy body. If curl returns nothing, port 443 is closed or the certificate for mta-sts.example.org was never issued.
Leave mode at testing until both of those checks pass. A policy in enforce mode with a broken certificate stops other servers from delivering to you, and you will hear about it from your users rather than from a log. DANE has a matching trap: it requires a DNSSEC-signed zone, and a TLSA record that pins the leaf certificate must be republished every time ACME renews. Pin the issuing CA instead, or accept the renewal chore.
Encryption at rest is not end to end encryption
This is the feature most often misread, so here is exactly what the documentation says. Each user's plain-text messages are automatically encrypted using their OpenPGP or S/MIME certificate before the messages are written to disk. encryptAtRest is enabled by default and applies to messages arriving over SMTP or LMTP, provided the recipient has registered an encryption key. encryptOnAppend defaults to false, "which leaves appended messages untouched so that clients retain full control over the contents they store". OpenPGP uses PGP/MIME rather than the older PGP/Inline, with AES-256 or AES-128. Stalwart does not generate keys: users export an ASCII-armored public key and register it as a PublicKey object under Account, Public Keys.
So it protects a stolen disk image, a stolen backup, and an operator reading the store after delivery. Without the private key the stored bytes are unreadable, and the administrator cannot decrypt them either.
It does not protect the message in transit. A message crosses the internet under whatever TLS the two servers agreed on, arrives in plain text, and Stalwart encrypts it at that moment. The sender, the sender's provider and any hop that stripped TLS have already seen the plain text.
Three more limits are worth stating plainly. Your Sent and Drafts folders are written by your client, which is an append, and encryptOnAppend is false by default, so those stay in the clear unless you change it. The documentation I read on 28 August 2026 describes message contents only and does not say that envelope data, headers or index entries are encrypted, so do not assume they are. It also does not say that messages already stored before you upload a key are re-encrypted, so assume they are not and check. Whether full-text search still works across encrypted bodies is not stated either. Test that on a throwaway account before you promise it to anyone.
And if a user loses their private key, their mail is gone. There is no recovery path, by design.
WKD is a web server job, not a mail server job
WKD (Web Key Directory) is the other half of the OpenPGP story, and it solves a different problem. It publishes your public key at a fixed HTTPS URL under your domain so that a sender's mail client can find it and encrypt the message before it ever leaves their machine. That is end to end encryption. Stalwart's at-rest encryption is about the copy sitting on your disk. Setting up one does not give you the other.
Stalwart does not serve WKD. Its documented HTTP endpoints, read on 28 August 2026, list well-known paths for jmap, caldav, carddav, oauth-authorization-server, openid-configuration, acme-challenge, mta-sts.txt, mail-v1.xml and autoconfig. There is no openpgpkey path. Serve it from an ordinary static web server.
The specification defines two layouts. The advanced method uses https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe. The direct method uses https://example.org/.well-known/openpgpkey/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe. That 32-character string is the SHA-1 of the lowercased local part encoded in z-base-32, which is why you never build these filenames by hand. GnuPG builds them for you.
gpg --export --armor you@example.org > you.asc
gpg-wks-client --print-wkd-url you@example.org
gpg-wks-client --install-key you.asc you@example.org--print-wkd-url prints the URL a client will fetch, using the sub-domain form. --install-key writes the key into a local directory tree that mirrors the WKD layout, under a top-level directory named openpgpkey by default and changed with -C dir. Copy that tree into your web root, add the required policy file next to the hu directory (an empty file is valid), and fetch your own URL with curl to confirm it returns key bytes and not a 404.
Storage on a single VPS
Stalwart splits storage into four roles: a data store for structured records like mailbox state, a blob store for raw message bytes and attachments, a search store for full-text indexing, and an in-memory store for rate limiters, authentication tokens and session data. Each can point at a different backend. The supported list includes RocksDB, FoundationDB, PostgreSQL, MySQL, SQLite, S3-compatible object storage, Azure Blob Storage, Redis, ElasticSearch and Meilisearch.
On one VPS the answer is short. The documentation calls RocksDB "the recommended backend for single-node installations of Stalwart because of its speed and reliability". Redis is supported only as an in-memory store and cannot be the data store or the blob store, so no separate Redis container is required to get started. Move the blob store to S3 later if mailboxes outgrow the disk.
Backups follow the backend. For external databases, use that database's own procedure. For the embedded ones the FAQ says to copy the /var/lib/stalwart directory. Do that with the service stopped, or from a filesystem or volume snapshot. A file-level copy of a running key-value store can catch it mid-write, and you will not find out until you try to restore it.
The spam filter that replaces Rspamd
Filtering runs inside the same process, so there is no second daemon to keep alive. The classifier is configured on the SpamClassifier singleton under Settings, Spam Filter, Classifier. It uses the FTRL-Proximal algorithm with feature hashing. FtrlFh is the recommended default for most deployments. FtrlCcfh swaps in cuckoo feature hashing to reduce hash collisions and is aimed at large-scale deployments. It trains continuously: when users mark a message as spam or ham, that label feeds straight back into future decisions.
Around the classifier sit DNS blocklists, greylisting, phishing detection, spam traps and Pyzor, plus the option to call SpamAssassin over milter if you have rules you refuse to give up.
When mailcow is still the right answer
Stalwart has no webmail. That is the single biggest gap and it is not close. The mailcow documentation, read on 28 August 2026, lists sixteen components including SOGo, which gives your users a browser inbox and a CalDAV and CardDAV interface out of the box. Stalwart's roadmap post of 20 June 2025 says a built-in webmail is "in our plans, but it's not currently our immediate priority", to be built in Rust with Dioxus after version 1.0, "most likely sometime in 2026". As of 28 August 2026 the project blog carries no post announcing one. So with Stalwart you are either deploying Roundcube yourself or telling every user to configure a mail client.
Stalwart does have a web admin interface, so that is not the gap people expect. The second gap is version maturity. The FAQ says Stalwart is at 0.x and that data layout and configuration may change before v1.0, which can require migration. The project's own June 2026 post is titled "Zero open bug reports: The road to Stalwart 1.0", which tells you where it is: close, but not there.
The third gap is the one nobody puts in a feature list. Postfix, Dovecot and Rspamd have a decade of written answers behind them. At two in the morning, with mail queued and users waiting, a search that returns a matching error string is worth more than an elegant architecture. If that is your situation, our mailcow install guide walks the whole stack end to end and it will be a shorter night.
Pick Stalwart when you want one binary, one config file and JMAP, and you are comfortable being early. Pick mailcow when you want webmail today and a large body of existing answers.
Moving existing mail in
The generic path is IMAP to IMAP with imapsync, which does not care what either end runs. Do a dry run first.
imapsync --dry \
--host1 old.example.org --user1 you@example.org --passfile1 /root/.old.pw \
--host2 mail.example.org --user2 you@example.org --passfile2 /root/.new.pw--dry makes imapsync "do nothing for real; it just prints what would be done", so read that output before you drop the flag. Each password file holds the password on its first line, so chmod 600 both and delete them afterwards.
Stalwart also ships newer tooling that most third-party guides have not caught up with. Its blog documents Vandelay, a JMAP importer and exporter (29 May 2026), and a migration proxy for zero-downtime upgrades (10 June 2026). Read both before you plan a large move, because they are more recent than almost anything you will find written elsewhere.
Failure modes and the strings you will see
The admin interface never loads. The FAQ names this one directly: the web interface bundle is downloaded from GitHub at first run, so a server with no outbound HTTPS to github.com gives you a running service and a blank page. Check with curl -sI https://github.com/stalwartlabs/webui/releases/latest/ from the server. The other common causes it lists are a mismatched HTTP or HTTPS scheme and a reverse proxy that does not forward the client IP.
No bootstrap password in the log. It is printed once, at startup, in bootstrap mode. If the service has restarted since, widen the window with sudo journalctl -u stalwart --since today | grep -A8 'bootstrap mode'. If it is genuinely gone, set STALWART_RECOVERY_ADMIN in /etc/stalwart/stalwart.env and restart.
Relay through a local proxy is refused. The v0.16.19 release notes record a fix for relay routes being rejected with host resolves loopback address. If you meet that exact string, you are running an older build. Pin forward, do not work around it.
The service will not start after you wrote your own unit. Without AmbientCapabilities=CAP_NET_BIND_SERVICE, the stalwart user cannot bind port 25, 443, 465 or 993, and startup fails on the first listener. Copy the capability line from the unit the installer generated.
Certificates never issue. HTTP-01 needs port 80 reachable and free. TLS-ALPN-01 needs Stalwart itself to answer the TLS handshake on 443. If something else on the box holds either port, ACME will keep failing quietly while everything else looks healthy.
FAQ
Does Stalwart replace Postfix, Dovecot and Rspamd on one VPS?
Yes. One Rust binary answers SMTP, IMAP, POP3, JMAP, CalDAV, CardDAV and WebDAV, and it includes the spam filter, the message store and an ACME client. There is one systemd unit and one config file at /etc/stalwart/config.json instead of four daemons and their glue. What it does not replace is your DNS zone or your provider's port 25 policy, which is where self-hosted mail actually succeeds or fails.
How much RAM does a Stalwart mail server need?
Stalwart's system requirements page, read on 28 August 2026, gives roughly 100 MB at idle and says 1 GB of RAM suits a small deployment of 5 to 10 users. A low-traffic setup of about 5 users runs on a single CPU core. The default limit is 8,192 concurrent connections across all services and it is configurable, so the ceiling rises with connection count and mail volume rather than with user count alone. No minimum disk size is published, so size the disk for the mail you keep.
Will switching to Stalwart improve my email deliverability?
No. Deliverability is decided by whether outbound TCP port 25 is open on your VPS, by reverse DNS on your IP, and by SPF, DKIM and DMARC on your domain. Stalwart does support DANE, MTA-STS and SMTP TLS reporting, and it can publish your MTA-STS policy for you, but those govern transport security rather than whether a receiving network trusts your address. Test port 25 with nc -vz -w 5 alt1.aspmx.l.google.com 25 before you install anything.
Does Stalwart include webmail?
Not as of 28 August 2026. It ships a web admin interface, which is a different thing. The project's roadmap post of 20 June 2025 says a webmail client is planned after version 1.0, built in Rust with Dioxus, "most likely sometime in 2026", and the project blog carries no announcement of one yet. If your users need a browser inbox now, deploy Roundcube alongside it or use a stack that bundles SOGo.
What does Stalwart's encryption at rest protect against?
It encrypts each user's messages with their own OpenPGP or S/MIME public key before writing them to disk, so a stolen disk, a stolen backup or an administrator reading the store cannot recover the contents. It is not end to end encryption: the message arrives in plain text and is encrypted on delivery, so every hop before that saw it. encryptOnAppend defaults to false, so Sent and Drafts written by your client stay in the clear unless you change it. The documentation covers message contents only and says nothing about metadata, index entries or re-encrypting mail stored before the key was uploaded, so verify those yourself rather than assuming.