This guide will guide you through setting up Stalwart as your own email server. Optionally it can also help you hide it behind a Tailscale network and use Roundcube for webmail.
You will need your own domain name. This guide also assumes that you are installing on a VPS that has a static IP and a configurable firewall. The assumed OS is Ubuntu 24.04.
Stalwart doesn't use a lot of resources, so if you are just using your VPS for this, buy cheap. Hetzner Cloud is a good choice.
We will be using emaildomain.com
as the domain. It's assumed that your emaildomain.com
has an A record pointing to your VPS IP.
We will be installing everything in docker containers using docker compose. Lets install it
apt install docker.io docker-compose-v2
If you are not running as root
you should add your user to the docker group to avoid sudo'ing every command: adduser USER docker
Create a directory for the docker compose file. This directory will also hold data and configuration files:mkdir -p stacks/stalwart
. This is the directory we will be using from now.
Create a compose.yml file with the following contents:
services:
stalwart-mail:
image: stalwartlabs/mail-server:latest
volumes:
- ./stalwart-mail:/opt/stalwart-mail
restart: unless-stopped
ports:
- 443:443
- 8080:8080
- 25:25
- 587:587
- 465:465
- 143:143
- 993:993
- 4190:4190
- 110:110
- 995:995
Make sure to open the ports in your firewall (if you are using one).
Start the service. For this first run we are not going to run it in the backround, as Stalwart will log the admin user password to the log
docker compose up
.
The output will look like this:
[+] Running 8/8
✔ stalwart-mail Pulled 8.5s
✔ 3815f79548aa Pull complete 4.9s
✔ d05ac034a050 Pull complete 5.1s
✔ 69a176fe4a1f Pull complete 5.4s
✔ ff59faa70d57 Pull complete 5.8s
✔ 13eed8633459 Pull complete 6.0s
✔ 599733dffedd Pull complete 6.0s
✔ 6cc142849f3d Pull complete 6.1s
[+] Running 2/2
✔ Network stalwart_default Created 0.2s
✔ Container stalwart-stalwart-mail-1 Created 0.1s
Attaching to stalwart-mail-1
stalwart-mail-1 | ✅ Configuration file written to /opt/stalwart-mail/etc/config.toml
stalwart-mail-1 | 🔑 Your administrator account is 'admin' with password 'f0tAKRWSHJ'.
Open http://emaildomain.com:8080 and login with the user/password combo printed in the log.
Note
If you are fine with using RocksDB for your server you can skip this section.
I already had a PostgreSQL server setup with working backup. For that reason I wanted to use that instead of RocksDB. I also opted for using S3 storage (Minio) for blobs (actual emails) because I can do streaming backup of that using Minios mc
command.
Make sure you do this before you create domains, users etc. as that will be lost when you change the storage type.
If you don't already have a PostgreSQL server but still want to use it, you can add this to your compose file:
db:
image: postgres
restart: unless-stopped
shm_size: 128mb
environment:
POSTGRES_USER: stalwart
POSTGRES_PASSWORD: example
POSTGRES_DB: stalwart
volumes:
- ./postgres:/var/lib/postgresql/data
You will also want to add this to the existing stalwart container:
depends_on:
- db
In the Stalwart web admin go to Settings -> Storage -> Stores
and click + Create store
. Create a store using your PostgreSQL info:
Now you need to actually use it. Go to Storage -> Settings
. Change the stores to your newly created store. Skip Blob store
if you want to use Minio for that.
You also have to change the internal directory to use PostgreSQL. Open Authentication -> Directories
and click edit on the internal directory. Set the storage backend to your PostgreSQL store.
If you don't have an existing Minio setup, add this to the compose file:
minio:
image: quay.io/minio/minio:latest
restart: unless-stopped
volumes:
- ./minio:/data
ports:
- 9001:9001
environment:
- MINIO_ROOT_USER=admin
- MINIO_ROOT_PASSWORD=examplepassword
command: server /data --console-address ":9001"
You will also want to add this to the existing stalwart container:
depends_on:
- minio
Open Minio admin at http://emaildomain.com:9001 and login with the provided user and password.
Create a bucket and name it stalwart
. Create an access key and note down the access key and the secret key.
In the Stalwart web admin go to Settings -> Storage -> Stores and click + Create store. Create a store using your Minio info:
Warning
The current version of Stalwart admin insists on you also specifying Profile and Security Token even though they are both optional. Put in deleteme
in both. After saving edit stalwart-mail/etc/config.toml
and delete the two lines with deleteme
in them.
In Storage -> Settings
change the blob store to the newly create minio store.
Restart Stalwart to initialize the new stores: docker compoes restart stalwart-mail
You will probably want to use a relay for your outgoing emails, otherwise you need to deal with deliverability issues and/or blocked outgoing port 25. I recommend using SMTP2Go.com. They offer 1,000 monthly emails. Whichever provider you chose, make sure you configure your DNS they way they specify.
In Stalwart admin go to Settings -> SMTP -> Outbound -> Relay Hosts
and click + Create host
. Fill in your relay host info:
Configure Stalwart to use the relay for external mails. Go to Settings -> SMTP -> Outbound -> Routing
. Add smtp2go as the next hop:
When using relays you have to disable DANE and MTA-STS. In Settings -> SMTP -> Outbound -> TLS
change them to disabled
:
Open Settings -> Server -> Network
and put mail.emaildomain.com
into the Hostname field.
To avoid self signed certificates when connecting to secure endpoints, you need to add an ACME provider to get SSL certificates. This guide is using Letsencrypt and Cloudflare DNS.
Go to Settings -> Server -> TLS -> ACME Providers
and click + Create ACME provider
.
Fill it out like so:
Set the DNS settings to Cloudflare and enter your API key. If you are not using Cloudflare you might want to select another challenge type.
Restart Stalwart to reload the new settings: docker compose restart stalwart-mail
.
We are now ready to add the domain to Stalwart. In Stalwart go to Management -> Directory -> Domains
and click + Create domain
. Put emaildomain.com
in the Domain name field. Back at the Domains list click the three dots on emaildomain.com and select View DNS records
.
Add those DNS records to your domain. If you are using Cloudflare you can save contents of Zonefile to a file and import it. If you already have an SPF field from your relay hosts, make sure you merge it into the one provided from Stalwart.
Open Management -> Directory -> Accounts
and click + Create account
.
- Login name is the username used to login. It can either be user or [email protected]. That is up to you. You can always change it.
- Name is Firstname Lastname
- Email is the actual email, [email protected] In Authentication make sure you put a password. Save changes.
Tip
If you want a catch-all address add an alias as @emaildomain.com
.
It should now work. Connect to IMAP using your favorite client. The DNS SRV records should give your client all the info it needs about ports and domains. If not, use mail.emaildomain.com as the IMAP/SMTP host.
- Send an email to [email protected] from https://sendtestemail.com/
- Send an email from [email protected] to https://www.mail-tester.com/
- Check your DNS etc from mxtoolbox.com
You could skip this step, but adding a reverse proxy in front of Stalwart makes it a lot easier to add more services without using non-standard port numbers.
Add this to your compose file:
caddy:
restart: unless-stopped
image: ghcr.io/hotio/caddy:latest
volumes:
- ./caddy:/config
And reload docker compose: docker compose up -d
. This will pull Caddy and create caddy/Caddyfile
. Open that file and change it to something like this:
{
http_port 80
https_port 443
acme_dns cloudflare your-cloudflare-api-key
}
mail.emaildomain.com {
reverse_proxy stalwart-mail:8080
}
webmail.emaildomain.com {
reverse_proxy roundcube:80
}
From your compose file, remove ports 443 and 8080 from stalwart-mail.
Restart Caddy: docker compose restart caddy
. If you're using a firewall (you should) make sure you open port 80 and 443. Also close port 8080.
The admin should now show up on https://mail.emaildomain.com
Add this to the compose file and docker compose up -d
roundcube:
image: roundcube/roundcubemail:latest
restart: unless-stopped
volumes:
- ./roundcube/db:/var/roundcube/db
- ./roundcube/config/:/var/roundcube/config/
environment:
- ROUNDCUBEMAIL_DEFAULT_HOST=ssl://mail.emaildomain.com
- ROUNDCUBEMAIL_DEFAULT_PORT=993
- ROUNDCUBEMAIL_SMTP_SERVER=ssl://mail.emaildomain.com
- ROUNDCUBEMAIL_SMTP_PORT=465
- ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=50M
In your DNS settings you need to add an entry for webmail.emaildomain.com. I suggest a CNAME pointing to mail.emaildomain.com. Roundcube will now be at https://webmail.emaildomain.com
This part is entirely optional. I am running Tailscale on all my devices, at least the ones where I read emails, so I opted to hide everything but the incoming port 25 behind Tailscale.
To make this work we will need a Tailscale sidecar and a way to forward port 25 from the host network into Tailscale. We will be using HAProxy for that. We also need to change a few DNS records.
First add this to the compose file:
tailscale-sidecar:
image: tailscale/tailscale:latest
hostname: stalwart
environment:
- TS_AUTHKEY=tskey-auth-key-from-your-account
- TS_STATE_DIR=/var/lib/tailscale
- TS_USERSPACE=false
volumes:
- /dev/net/tun:/dev/net/tun
- ./tailscale:/var/lib/tailscale
cap_add:
- net_admin
- sys_module
restart: unless-stopped
haproxy:
image: haproxy:alpine
ports:
- 25:2525
volumes:
- ./haproxy/:/usr/local/etc/haproxy/
restart: unless-stopped
We will also need a few changes to existing records in the compose file.
For stalwart-mail
add:
network_mode: service:tailscale-sidecar
depends_on:
- tailscale-sidecar
and remove all ports
entries as well.
Open haproxy/haproxy.cfg
and insert this:
global
daemon
maxconn 256
log stdout format raw local0 info
defaults
log global
mode tcp
option tcplog
option dontlognull
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
frontend smtp-in
bind *:2525
mode tcp
option tcplog
default_backend bk_smtp
backend bk_smtp
mode tcp
server stalwart_smtp stalwart:25 send-proxy-v2
It's using stalwart:25
because the stalwart-mail
container isn't directly connectable anymore, as it is using the sidecar network.
Start everything up docker compose up -d
.
Because we are now proxying from HAProxy to Stalwart, we need to add the docker network IP range to Stalwart: Settings -> Server -> Network -> Proxy networks
. To find the network IP range you can run docker network inspect stalwart_default
or just use 172.0.0.0/8
. Save and reload.
You can now use the hostname stalwart
to connect to your IMAP server. That's not optimal as the DNS SRV records doesn't specify that and we also don't have a certificate. To fix this we can edit the DNS configuration at Cloudflare.
- Change the A record for mail.emaildomain.com from the external IP to the Tailscale IP for the VPS.
- Add a new A record smtp-in.emaildomain.com with the external IP
- Change the MX record from mail.emaildomain.com to smtp-in.emaildomain.com
With these changes incoming mail will now go to smtp-in.emaildomain.com while everything else uses the Tailscale IP.
Tip
Set this machine to non-expiry in Tailscale admin.
Tip
You could also add Minio and PostgreSQL to the tailscale-sidecar network, but you will then need to update the hostname to stalwart
in the storage configuration.
Warning
If you don't add Minio to the Tailscale network, at least remove the ports
configuration for it or firewall port 9001.
just to suggest to change the store also in authentication directories items. for me it didn't work until I changed to postgres in the internal directory.