Deploy Stalwart Mail Server with Roundcube, Docker, and Cloudflare DNS
Run a self-hosted mail backend with Stalwart, expose webmail through Roundcube, and publish the DNS records that make mail delivery trustworthy.
Stalwart is a modern self-hosted mail server with SMTP, IMAP, JMAP, DKIM, spam filtering, account management, and a web administration UI. Roundcube is a lightweight webmail frontend that can log in to Stalwart over IMAP and send through Stalwart over SMTP.
This guide documents a two-layer mail stack:
1
2
3
mail.example.com # real mail backend: Stalwart SMTP/IMAP/JMAP/admin
webmail.example.com # browser webmail UI: Roundcube
example.com # hosted mail domain
All domains, IP addresses, passwords, API tokens, account names, and certificate IDs in this post are placeholders. Replace them in your own environment and never publish real secrets or private infrastructure details.
What this service does
Stalwart owns the mail system:
1
2
3
4
5
Inbound SMTP :25
Submission SMTPS :465
IMAPS :993
ManageSieve :4190
JMAP/Admin HTTPS :443 or reverse-proxied HTTP :8080
Roundcube owns only the webmail UI:
1
Browser -> https://webmail.example.com -> Roundcube -> Stalwart IMAPS/SMTPS
DNS tells the internet how to trust the mail domain:
1
2
3
4
5
6
7
MX -> mail.example.com
SPF -> who may send
DKIM -> cryptographic mail signatures
DMARC -> policy and reports
MTA-STS -> SMTP TLS policy
TLS-RPT -> TLS report destination
SRV -> client autodiscovery
Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Remote mail server
↓ SMTP :25
mail.example.com
↓
Stalwart
├── SMTP inbound
├── SMTPS submission
├── IMAPS mailbox access
├── JMAP/API
├── DKIM signing keys
├── outbound relay route to an SMTP smarthost when TCP 25 is blocked
└── local RocksDB data
Browser
↓ HTTPS
webmail.example.com
↓
Roundcube
├── MariaDB session/config database
└── IMAPS/SMTPS to Stalwart over the Docker network
Keep the roles separate. Roundcube does not create mailboxes by itself. The login must exist in Stalwart or whatever backend IMAP server you choose.
Folder layout
1
2
3
4
5
6
7
8
9
10
11
12
13
/home/ubuntu/stalwart/
├── docker-compose.yml
├── .env
├── maildomain-dns-zone.txt
├── current-cert-id
└── renew-mail-cert.sh
/home/ubuntu/roundcube/
├── docker-compose.yml
├── .env
├── config/
│ └── stalwart-tls.inc.php
└── temp/
Use restrictive permissions for files that contain credentials:
1
2
3
chmod 600 /home/ubuntu/stalwart/.env
chmod 600 /home/ubuntu/roundcube/.env
chmod 700 /home/ubuntu/stalwart/renew-mail-cert.sh
Stalwart Docker Compose
This Compose file publishes the mail ports directly and exposes the admin UI through Traefik.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
services:
stalwart:
image: stalwartlabs/stalwart:v0.16
container_name: stalwart
hostname: ${STALWART_HOSTNAME}
restart: unless-stopped
env_file: .env
environment:
STALWART_RECOVERY_ADMIN: ${STALWART_RECOVERY_ADMIN}
STALWART_PUBLIC_URL: ${STALWART_PUBLIC_URL}
volumes:
- etc:/etc/stalwart
- data:/var/lib/stalwart
ports:
- "25:25"
- "465:465"
- "993:993"
- "4190:4190"
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.stalwart.rule=Host(`admin-mail.example.com`)"
- "traefik.http.routers.stalwart.entrypoints=https"
- "traefik.http.routers.stalwart.tls=true"
- "traefik.http.routers.stalwart.tls.certresolver=cloudflare"
- "traefik.http.services.stalwart.loadbalancer.server.port=8080"
volumes:
etc:
data:
networks:
proxy:
external: true
Example .env:
STALWART_ADMIN_USER=admin
STALWART_ADMIN_PASSWORD=<temporary-bootstrap-password>
STALWART_RECOVERY_ADMIN=admin:<temporary-bootstrap-password>
[email protected]
STALWART_PRIMARY_ADMIN_PASSWORD=<admin-password>
STALWART_HOSTNAME=mail.example.com
STALWART_PUBLIC_URL=https://admin-mail.example.com
Start it:
1
2
cd /home/ubuntu/stalwart
docker compose up -d
Admin UI reverse-proxy sidecar
If your reverse proxy returns 502 Bad Gateway when routing directly to Stalwart’s admin service, keep Stalwart focused on mail/TCP traffic and put a tiny nginx sidecar in front of the admin HTTP port.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server {
listen 80;
server_name _;
location / {
proxy_pass http://stalwart:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Then attach Traefik HTTP labels to the sidecar instead of the Stalwart container:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
services:
stalwart:
image: stalwartlabs/stalwart:v0.16
container_name: stalwart
# Keep mail ports and storage here.
# Do not put the admin HTTP Traefik router on this service if it causes 502s.
stalwart-admin-proxy:
image: nginx:stable-alpine
container_name: stalwart-admin-proxy
restart: unless-stopped
depends_on:
- stalwart
volumes:
- ./admin-proxy.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.stalwart-admin.rule=Host(`admin-mail.example.com`)"
- "traefik.http.routers.stalwart-admin.entrypoints=https"
- "traefik.http.routers.stalwart-admin.tls=true"
- "traefik.http.routers.stalwart-admin.tls.certresolver=cloudflare"
- "traefik.http.services.stalwart-admin.loadbalancer.server.port=80"
Verify the admin route and static assets:
1
2
curl -I https://admin-mail.example.com/admin/
curl -I https://admin-mail.example.com/admin/assets/<asset-file>.js
On first boot, Stalwart enters bootstrap mode. You can complete the wizard in the Web UI or drive setup through Stalwart’s API. For a single-node homelab, choose:
1
2
3
4
5
6
7
Hostname: mail.example.com
Default domain: example.com
Storage: RocksDB
Directory: Internal
DNS management: Manual
DKIM: enabled
Logs: console or file
Mail DNS records
After setup, Stalwart can generate a DNS zone file for the domain. Publish those records at your DNS provider.
Minimum manual records look like this:
mail.example.com. IN A <server-public-ip>
example.com. IN MX 10 mail.example.com.
example.com. IN TXT "v=spf1 mx -all"
mail.example.com. IN TXT "v=spf1 a -all"
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:[email protected]"
DKIM records are generated by Stalwart and must be copied exactly:
v1-ed25519-YYYYMMDD._domainkey.example.com. IN TXT "v=DKIM1; k=ed25519; h=sha256; p=<public-key>"
v1-rsa-YYYYMMDD._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; h=sha256; p=<long-public-key>"
Recommended autodiscovery and TLS records:
_imaps._tcp.example.com. IN SRV 0 1 993 mail.example.com.
_submissions._tcp.example.com. IN SRV 0 1 465 mail.example.com.
_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com.
mta-sts.example.com. IN CNAME mail.example.com.
_mta-sts.example.com. IN TXT "v=STSv1; id=<policy-version>"
_smtp._tls.example.com. IN TXT "v=TLSRPTv1; rua=mailto:[email protected]"
Mail records should be DNS-only, not proxied through Cloudflare. SMTP, IMAP, and SMTPS are not normal orange-cloud HTTP services.
If you relay outbound mail through Oracle Cloud Infrastructure Email Delivery, add Oracle’s DKIM CNAME and update SPF to include Oracle’s sender policy:
oracle-YYYYMM._domainkey.example.com. IN CNAME oracle-YYYYMM.example.com.dkim.<oci-region>.oracleemaildelivery.com.
example.com. IN TXT "v=spf1 mx include:rp.oracleemaildelivery.com -all"
Keep the DKIM selector and target exactly as OCI gives them to you. Wait until OCI shows the DKIM record as active before relying on it for production mail.
Dedicated Cloudflare companion for a second zone
If your main Cloudflare Companion only has access to one zone, run a second companion for the mail domain.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
services:
cloudflare-companion-maildomain:
image: tiredofit/traefik-cloudflare-companion:latest
container_name: cloudflare-companion-maildomain
restart: unless-stopped
env_file: .env
environment:
- TIMEZONE=UTC
- TRAEFIK_VERSION=2
- CF_TOKEN=${CF_TOKEN}
- DOMAIN1=${DOMAIN1}
- DOMAIN1_ZONE_ID=${DOMAIN1_ZONE_ID}
- DOMAIN1_PROXIED=FALSE
- DOMAIN1_EXCLUDED_SUB_DOMAINS=mail,autodiscover,autoconfig,mta-sts,ua-auto-config
- TARGET_DOMAIN=example.net
- RC_TYPE=CNAME
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- proxy
networks:
proxy:
external: true
Store the token in .env, never in the post:
CF_TOKEN=<cloudflare-token-with-zone-dns-edit>
DOMAIN1=example.com
DOMAIN1_ZONE_ID=<cloudflare-zone-id>
Exclude the real mail hostnames from generic CNAME automation if you need precise DNS-only A, MX, DKIM, MTA-STS, and SRV records.
TLS certificates
Stalwart can request ACME certificates itself. In some reverse-proxy setups, DNS-01 through Certbot is simpler and more predictable.
Issue a certificate with Cloudflare DNS-01:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
docker run --rm \
-v /home/ubuntu/letsencrypt:/etc/letsencrypt \
-v /home/ubuntu/cloudflare-maildomain/secrets:/secrets:ro \
certbot/dns-cloudflare:latest certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /secrets/cloudflare.ini \
--dns-cloudflare-propagation-seconds 60 \
--non-interactive --agree-tos \
--email [email protected] \
--cert-name maildomain \
-d mail.example.com \
-d autodiscover.example.com \
-d autoconfig.example.com \
-d mta-sts.example.com
Then import the certificate into Stalwart through the admin UI or API and set it as the default certificate.
Verify externally:
1
2
openssl s_client -connect mail.example.com:993 -servername mail.example.com </dev/null \
| openssl x509 -noout -subject -issuer -dates
Expected result: the subject should be mail.example.com, not a self-signed placeholder.
Roundcube Docker Compose
Roundcube needs a database and access to Stalwart over the Docker network.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
services:
db:
image: mariadb:11.4
container_name: roundcube-db
restart: unless-stopped
env_file: .env
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- db_data:/var/lib/mysql
networks:
- internal
roundcube:
image: roundcube/roundcubemail:latest-apache
container_name: roundcube
restart: unless-stopped
env_file: .env
depends_on:
- db
environment:
ROUNDCUBEMAIL_DB_TYPE: mysql
ROUNDCUBEMAIL_DB_HOST: db
ROUNDCUBEMAIL_DB_PORT: 3306
ROUNDCUBEMAIL_DB_USER: ${MYSQL_USER}
ROUNDCUBEMAIL_DB_PASSWORD: ${MYSQL_PASSWORD}
ROUNDCUBEMAIL_DB_NAME: ${MYSQL_DATABASE}
ROUNDCUBEMAIL_DEFAULT_HOST: ${ROUNDCUBEMAIL_DEFAULT_HOST}
ROUNDCUBEMAIL_DEFAULT_PORT: ${ROUNDCUBEMAIL_DEFAULT_PORT}
ROUNDCUBEMAIL_SMTP_SERVER: ${ROUNDCUBEMAIL_SMTP_SERVER}
ROUNDCUBEMAIL_SMTP_PORT: ${ROUNDCUBEMAIL_SMTP_PORT}
ROUNDCUBEMAIL_USERNAME_DOMAIN: ${ROUNDCUBEMAIL_USERNAME_DOMAIN}
ROUNDCUBEMAIL_DES_KEY: ${ROUNDCUBEMAIL_DES_KEY}
volumes:
- ./config:/var/roundcube/config
- ./temp:/tmp/roundcube-temp
networks:
- internal
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.roundcube.rule=Host(`webmail.example.com`)"
- "traefik.http.routers.roundcube.entrypoints=https"
- "traefik.http.routers.roundcube.tls=true"
- "traefik.http.routers.roundcube.tls.certresolver=cloudflare"
- "traefik.http.services.roundcube.loadbalancer.server.port=80"
volumes:
db_data:
networks:
internal:
proxy:
external: true
Example Roundcube .env:
MYSQL_DATABASE=roundcubemail
MYSQL_USER=roundcube
MYSQL_PASSWORD=<database-password>
MYSQL_ROOT_PASSWORD=<database-root-password>
ROUNDCUBEMAIL_DEFAULT_HOST=ssl://stalwart
ROUNDCUBEMAIL_DEFAULT_PORT=993
ROUNDCUBEMAIL_SMTP_SERVER=ssl://stalwart
ROUNDCUBEMAIL_SMTP_PORT=465
ROUNDCUBEMAIL_USERNAME_DOMAIN=example.com
ROUNDCUBEMAIL_DES_KEY=<24-character-random-key>
If Roundcube connects to Stalwart using the Docker service name stalwart, but the certificate is issued for mail.example.com, add a small internal TLS override:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
$config['imap_conn_options'] = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
];
$config['smtp_conn_options'] = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
];
Use this only for the private Docker-network hop. Public clients should still see a valid certificate for mail.example.com.
Verification
Check Stalwart health:
1
2
docker ps --filter name=stalwart
curl -I https://admin-mail.example.com/admin/
Check public DNS:
1
2
3
4
dig +short MX example.com @1.1.1.1
dig +short TXT example.com @1.1.1.1
dig +short TXT _dmarc.example.com @1.1.1.1
dig +short TXT v1-ed25519-YYYYMMDD._domainkey.example.com @1.1.1.1
Check public ports:
1
2
3
for port in 25 465 993 4190 443; do
timeout 6 bash -lc "</dev/tcp/mail.example.com/$port" && echo "$port open"
done
Check Stalwart IMAPS auth:
1
2
3
curl -ksS --url "imaps://mail.example.com:993/" \
--user "[email protected]:<password>" \
--request 'LIST "" "INBOX"'
Check Roundcube can reach Stalwart internally:
1
2
3
4
docker exec roundcube php -r '
$a=@fsockopen("ssl://stalwart",993,$e,$s,5); echo $a?"imaps ok\n":"imaps fail\n";
$b=@fsockopen("ssl://stalwart",465,$e,$s,5); echo $b?"smtps ok\n":"smtps fail\n";
'
Troubleshooting
Roundcube login fails
Check that the account exists in Stalwart. Roundcube is only a client. It does not create Stalwart users automatically.
Public mail still goes to the old provider
Check MX records:
1
dig +short MX example.com @1.1.1.1
Remove stale MX records such as old testing providers before expecting inbound mail to hit Stalwart.
Mail clients see a self-signed certificate
Check the certificate served on IMAPS:
1
2
openssl s_client -connect mail.example.com:993 -servername mail.example.com </dev/null \
| openssl x509 -noout -subject -issuer -dates
If it is self-signed, import a real certificate into Stalwart and set it as the default certificate.
Cloudflare Companion creates the wrong records
For mail hostnames, DNS-only A/CNAME/MX/TXT records often need to be exact. Exclude mail, autoconfig, autodiscover, and mta-sts from generic companion automation if it tries to create broad CNAMEs.
Port 25 is blocked
Many residential and cloud networks block outbound or inbound TCP 25. If inbound TCP 25 is blocked, remote MTAs cannot deliver mail to your server.
If outbound TCP 25 is blocked but inbound mail works, use an SMTP relay/smarthost on port 587. On Oracle Cloud, OCI Email Delivery is the natural option because Oracle commonly blocks direct outbound SMTP 25 on newer tenancies while allowing authenticated submission to OCI Email Delivery.
Outbound relay through OCI Email Delivery
For Oracle Cloud hosts, configure Stalwart to keep local delivery local and send external recipients through OCI Email Delivery on 587 with STARTTLS.
First verify the SMTP credential from the server without printing it in logs or shell history:
1
2
3
4
5
6
7
8
9
10
11
12
python3 - <<'PY'
import getpass, smtplib, ssl
host = "smtp.email.<oci-region>.oci.oraclecloud.com"
user = input("OCI SMTP username: ")
password = getpass.getpass("OCI SMTP password: ")
with smtplib.SMTP(host, 587, timeout=30) as smtp:
smtp.ehlo()
smtp.starttls(context=ssl.create_default_context())
smtp.ehlo()
smtp.login(user, password)
print("OCI SMTP login ok")
PY
In the Stalwart Web UI, create a relay route:
1
2
3
4
5
6
7
8
9
10
Settings -> MTA -> Outbound -> Routes -> Create route
Type : Relay Host
Name : oci-relay
Address : smtp.email.<oci-region>.oci.oraclecloud.com
Port : 587
Protocol : SMTP
Implicit TLS : disabled
Allow invalid cert: disabled
Auth username : <oci-smtp-username>
Auth secret : <oci-smtp-password>
Then update the outbound strategy so local mail stays local and everything else uses the relay:
1
2
3
4
5
6
7
8
9
10
11
{
"route": {
"match": {
"0": {
"if": "is_local_domain(rcpt_domain)",
"then": "'local'"
}
},
"else": "'oci-relay'"
}
}
A successful delivery in Stalwart logs should show the relay hostname, port 587, STARTTLS, and a 250 Ok response:
1
2
3
4
Connecting to remote server ... hostname = "smtp.email.<oci-region>.oci.oraclecloud.com", remotePort = 587
SMTP STARTTLS command ... version = "TLSv1_2"
Message delivered ... code = 250, details = "Ok"
Delivery completed
If OCI rejects the message, confirm that the sender address is an approved sender in OCI Email Delivery, for example [email protected].
Security notes
- Use strong admin passwords and 2FA where possible.
- Keep Cloudflare tokens limited to the required zone and DNS permissions.
- Keep
.env, certificate keys, SMTP relay credentials, and Stalwart bootstrap responses private. - Do not publish real DKIM private keys, API tokens, passwords, SMTP credentials, or server IPs.
- Rotate any SMTP credential that was pasted into chat, tickets, shell logs, screenshots, or other non-secret storage.
- Keep mail DNS records DNS-only unless the record is a normal web route.
- Monitor DMARC and TLS-RPT reports after going live.
Service documentation map
This post connects to the rest of the homelab stack:
- Traefik reverse proxy for the Stalwart admin UI and Roundcube webmail route.
- Cloudflare Companion for DNS automation and zone-specific companion containers.
- Homepage dashboard for adding the Roundcube web UI as a visible service card.
- Jekyll documentation site for publishing service guides like this one.
- OpenClaw Gateway if you later automate mailbox creation or OTP parsing through an internal agent workflow.