Skip to content

Instantly share code, notes, and snippets.

@puRe1337
Last active October 18, 2025 09:55
Show Gist options
  • Select an option

  • Save puRe1337/cdecc97307cc19c6d899a204b510834e to your computer and use it in GitHub Desktop.

Select an option

Save puRe1337/cdecc97307cc19c6d899a204b510834e to your computer and use it in GitHub Desktop.
Stalwart Traefik configuration

Notice

I created a new repo to get a better overview, and also updated the setup there.

Please use that repo as a reference from now on.


Test commands with openssl

SMTP 25

openssl s_client -quiet -crlf -starttls smtp -connect mail.example.com:25

SMTP 587

openssl s_client -starttls smtp -quiet -crlf -connect mail.example.com:587

SMTP 465

openssl s_client -quiet -crlf -connect mail.example.com:465

IMAP 143

openssl s_client -crlf -connect mail.example.com:143 -starttls imap -servername mail.example.com

IMAP 993

openssl s_client -quiet -crlf -connect mail.example.com:993

SIEVE 4190

openssl s_client -showcerts -servername mail.example.com -connect mail.example.com:4190

JMAP 443

curl -u admin:'password' https://mail.example.com/.well-known/jmap

authentication.fallback-admin.secret = "SECRET"
authentication.fallback-admin.user = "admin"
certificate.traefik.cert = "%{file:/opt/certs/mail.example.com/cert.pem}%"
certificate.traefik.default = true
certificate.traefik.private-key = "%{file:/opt/certs/mail.example.com/key.pem}%"
cluster.node-id = 1
directory.internal.store = "rocksdb"
directory.internal.type = "internal"
lookup.default.hostname = "mail.example.com"
server.http.permissive-cors = false
server.http.url = "protocol + '://' + key_get('default', 'hostname') + ':' + local_port"
server.http.use-x-forwarded = false
server.listener.http.bind = "[::]:8080"
server.listener.http.protocol = "http"
server.listener.https.bind = "[::]:443"
server.listener.https.protocol = "http"
server.listener.https.tls.implicit = true
server.listener.imap.bind = "[::]:143"
server.listener.imap.protocol = "imap"
server.listener.imap.proxy.override = false
server.listener.imap.socket.override = false
server.listener.imap.tls.implicit = false
server.listener.imap.tls.override = false
server.listener.imaptls.bind = "[::]:993"
server.listener.imaptls.protocol = "imap"
server.listener.imaptls.proxy.override = true
server.listener.imaptls.proxy.trusted-networks.0000 = "172.18.0.0/16"
server.listener.imaptls.socket.override = false
server.listener.imaptls.tls.implicit = true
server.listener.imaptls.tls.override = false
server.listener.sieve.bind = "[::]:4190"
server.listener.sieve.protocol = "managesieve"
server.listener.sieve.proxy.override = true
server.listener.sieve.proxy.trusted-networks.0000 = "172.18.0.0/16"
server.listener.sieve.socket.override = false
server.listener.sieve.tls.implicit = true
server.listener.sieve.tls.override = false
server.listener.smtp.bind = "[::]:25"
server.listener.smtp.protocol = "smtp"
server.listener.smtp.proxy.override = false
server.listener.smtp.socket.override = false
server.listener.smtp.tls.implicit = false
server.listener.smtp.tls.override = false
server.listener.submission.bind = "[::]:587"
server.listener.submission.protocol = "smtp"
server.listener.submission.proxy.override = false
server.listener.submission.socket.override = false
server.listener.submission.tls.implicit = false
server.listener.submission.tls.override = false
server.listener.submissions.bind = "[::]:465"
server.listener.submissions.protocol = "smtp"
server.listener.submissions.proxy.override = true
server.listener.submissions.proxy.trusted-networks.0000 = "172.18.0.0/16"
server.listener.submissions.socket.override = false
server.listener.submissions.tls.implicit = true
server.listener.submissions.tls.override = false
server.max-connections = 8192
server.socket.backlog = 1024
server.socket.nodelay = true
server.socket.reuse-addr = true
server.socket.reuse-port = true
server.tls.certificate = "traefik"
server.tls.enable = true
storage.blob = "rocksdb"
storage.data = "rocksdb"
storage.directory = "internal"
storage.fts = "rocksdb"
storage.lookup = "rocksdb"
store.rocksdb.compression = "lz4"
store.rocksdb.path = "/opt/stalwart-mail/data"
store.rocksdb.type = "rocksdb"
tracer.log.ansi = false
tracer.log.enable = true
tracer.log.level = "info"
tracer.log.path = "/opt/stalwart-mail/logs"
tracer.log.prefix = "stalwart.log"
tracer.log.rotate = "daily"
tracer.log.type = "log"
services:
mail-server:
volumes:
- ./data:/opt/stalwart-mail
- /home/docker/traefik/letsencrypt/dumped:/opt/certs:ro
container_name: stalwart-mail
image: stalwartlabs/mail-server:latest
security_opt: [no-new-privileges:true]
ports:
- 25:25
- 143:143
- 587:587
labels:
- "traefik.enable=true"
# admin ui
- traefik.http.routers.stalwart.rule=Host(`mail.example.com`)
- traefik.http.routers.stalwart.entrypoints=https
- traefik.http.routers.stalwart.tls.certresolver=le
- traefik.http.routers.stalwart.service=stalwart
- traefik.http.services.stalwart.loadbalancer.server.port=8080
# jmap
- traefik.tcp.routers.jmap.rule=HostSNI(`*`)
- traefik.tcp.routers.jmap.entrypoints=https
- traefik.tcp.routers.jmap.tls.passthrough=true
- traefik.tcp.routers.jmap.service=jmap
- traefik.tcp.services.jmap.loadbalancer.server.port=443
- traefik.tcp.services.jmap.loadbalancer.proxyProtocol.version=2
# esmtp
- traefik.tcp.routers.esmtp.rule=HostSNI(`*`)
- traefik.tcp.routers.esmtp.entrypoints=esmtp
- traefik.tcp.routers.esmtp.tls.passthrough=true
- traefik.tcp.routers.esmtp.service=esmtp
- traefik.tcp.services.esmtp.loadbalancer.server.port=465
- traefik.tcp.services.esmtp.loadbalancer.proxyProtocol.version=2
# imap-ssl
- traefik.tcp.routers.imap-ssl.rule=HostSNI(`*`)
- traefik.tcp.routers.imap-ssl.entrypoints=imap-ssl
- traefik.tcp.routers.imap-ssl.tls.passthrough=true
- traefik.tcp.routers.imap-ssl.service=imap-ssl
- traefik.tcp.services.imap-ssl.loadbalancer.server.port=993
- traefik.tcp.services.imap-ssl.loadbalancer.proxyProtocol.version=2
# sieve
- traefik.tcp.routers.sieve.rule=HostSNI(`*`)
- traefik.tcp.routers.sieve.entrypoints=sieve
- traefik.tcp.routers.sieve.tls.passthrough=true
- traefik.tcp.routers.sieve.service=sieve
- traefik.tcp.services.sieve.loadbalancer.server.port=4190
- traefik.tcp.services.sieve.loadbalancer.proxyProtocol.version=2
networks:
proxy:
ipv4_address: 172.18.0.21
networks:
proxy:
external: true
services:
traefik:
image: traefik:latest
container_name: traefik
restart: always
ports:
- "80:80"
- "443:443"
# Mailserver
- "465:465" # ESMTP submission, implicit TLS
- "993:993" # IMAP4 secure, implicit TLS
- "4190:4190" # sieve
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
- ./traefik:/etc/traefik
networks:
proxy:
ipv4_address: 172.18.0.20
environment:
- CLOUDFLARE_EMAIL=MAIL
- CLOUDFLARE_DNS_API_TOKEN=TOKEN
certdumper:
image: ghcr.io/kereis/traefik-certs-dumper:latest
volumes:
- ./letsencrypt:/traefik:ro
- ./letsencrypt/dumped:/output:rw
networks:
proxy:
name: proxy
ipam:
driver: default
config:
- subnet: 172.18.0.0/16
gateway: 172.18.0.1
global:
checkNewVersion: true
sendAnonymousUsage: false # true by default
api:
# Dashboard
#
# Optional
# Default: true
#
dashboard: true
providers:
docker:
exposedByDefault: false
network: proxy
file:
# watch for dynamic configuration changes
directory: /etc/traefik
watch: true
entryPoints:
http:
address: ":80"
http:
redirections:
entryPoint:
priority: 1000
to: "https"
scheme: "https"
https:
address: ":443"
http:
tls:
certResolver: le
esmtp:
address: ":465"
proxyProtocol:
trustedIPs:
- 172.18.0.20
- 172.18.0.21
imap-ssl:
address: ":993"
proxyProtocol:
trustedIPs:
- 172.18.0.20
- 172.18.0.21
sieve:
address: ":4190"
proxyProtocol:
trustedIPs:
- 172.18.0.20
- 172.18.0.21
certificatesResolvers:
le:
acme:
# tlschallenge: true
email: "[email protected]"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
dnsChallenge:
provider: cloudflare
@tvx-matt
Copy link

Does this work as of current?

@puRe1337
Copy link
Author

Yes, it should. I don’t think I’ve changed my config since then.

@bkraul
Copy link

bkraul commented Sep 9, 2025

Can someone explain why 587 is not being passed through Traefik?

@puRe1337
Copy link
Author

Can someone explain why 587 is not being passed through Traefik?

If I remember correctly it didn’t work. Port 587 uses STARTTLS (explicit TLS), which starts unencrypted and upgrades later, and Traefik can’t handle that, while 465 (implicit TLS) works fine with TCP passthrough.
You could give it a try with the latest version though.

@bkraul
Copy link

bkraul commented Sep 19, 2025

I did try it with Traefik 3.5, but it did not work. Opted for just not using 587. Never been a fan of STARTTLS anyway, and from what I hear, 465 was initially deprecated but it came back and so that's what I use.

@chachoCL
Copy link

chachoCL commented Oct 9, 2025

Thank pure for this guide i got stalwart working with traefik. but i got this logs on traefik

2025-10-09T02:49:07Z ERR Error while handling TCP connection error="writeto tcp 172.18.0.20:47252->172.18.0.21:465: read tcp 172.18.0.20:47252->172.18.0.21:465: read: connection reset by peer"
2025-10-09T02:51:39Z ERR Error while handling TCP connection error="readfrom tcp 172.18.0.20:43602->172.18.0.21:993: read tcp 172.18.0.20:993->115.231.78.3:32476: read: connection reset by peer"
Does this alter the functioning of Stalwart in any way?

@bkraul
Copy link

bkraul commented Oct 9, 2025

I get those often in the traefik log. Unless I am missing something, it is not much of an issue.

@puRe1337
Copy link
Author

puRe1337 commented Oct 9, 2025

I also get them quite often. 443, 465, 993 and 995 are affected, but I haven't experienced any issues. My thought is that this only appears if a client disconnects improperly, such as due to a timeout.
just a thought - I've never debugged it

@chachoCL
Copy link

Thank you very much. Perhaps it has something to do with Stalwart's autoblocking IP's

@puRe1337
Copy link
Author

btw, I've created a repo with an updated version: https://github.com/puRe1337/stalwart-traefik-example

Use this as a reference from now on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment