Stalwart on coolify
Published
Intro
Hosting stalwart on coolify was not an easy task. There was very little documentation on what to do other than this post: https://aldertvaandering.com/posts/setting-up-stalwart-on-coolify. I struggled a lot to set it up - failed to get certificates, getting timed out by acme servers, things just not working as it is usual for me, just things not working and not knowing where to find the logs. It was frustrating. I tried to setup stalwart on coolify for a week, kind of succeeded, I could receive emails, but could not send them, then I tried to fix sending, and something broke and i could not fix it, so I had to start over. So I am writing this so that I could reference some mistakes and have a nice setup later if things don't change too much.
Traefik config
The first thing I had to do was configure coolify proxy traefik by editing its configuration. It can be edited through coolify dashboard by going into Servers -> Your server and then the Proxy tab. Here I copied most of the config from other post I mentioned, but I configured mine for Cloudflare.
Here is the my configuration for traefik:
1name: coolify-proxy2networks:3 coolify:4 external: true5services:6 traefik:7 container_name: coolify-proxy8 image: 'traefik:v3.6'9 restart: unless-stopped10 environment:11 - CF_DNS_API_TOKEN=CF_TOKEN12 extra_hosts:13 - 'host.docker.internal:host-gateway'14 networks:15 - coolify16 ports:17 - '80:80'18 - '443:443'19 - '443:443/udp'20 - '8080:8080'21 healthcheck:22 test: 'wget -qO- http://localhost:80/ping || exit 1'23 interval: 4s24 timeout: 2s25 retries: 526 volumes:27 - '/var/run/docker.sock:/var/run/docker.sock:ro'28 - '/data/coolify/proxy/:/traefik'29 - '/etc/localtime:/etc/localtime:ro'30 - '/etc/traefik:/etc/traefik'31 command:32 - '--ping=true'33 - '--ping.entrypoint=http'34 - '--api.dashboard=true'35 - '--entrypoints.http.address=:80'36 - '--entrypoints.https.address=:443'37 - '--entrypoints.http.http.encodequerysemicolons=true'38 - '--entryPoints.http.http2.maxConcurrentStreams=50'39 - '--entrypoints.https.http.encodequerysemicolons=true'40 - '--entryPoints.https.http2.maxConcurrentStreams=50'41 - '--entrypoints.https.http3'42 # Entrypoints for Mail43 - "--entrypoints.smtp.address=:25"44 - "--entrypoints.smtps.address=:465"45 - "--entrypoints.imaps.address=:993"46 - "--entrypoints.submission.address=:587"47 # Providers48 - '--providers.file.directory=/traefik/dynamic/'49 - '--providers.file.watch=true'50 # use dnschallenge instead of httpchallenge51 # - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true'52 # - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http'53 - '--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare'54 - '--certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=0'55 - '--certificatesresolvers.letsencrypt.acme.keytype=EC256'56 # Probably not necessary57 - '--certificatesresolvers.letsencrypt.acme.email=YOUR_EMAIL_ADDRESS'58 # staging59 #- '--certificatesresolvers.letsencrypt.acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory'60 - '--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json'61 - '--api.insecure=false'62 - '--providers.docker=true'63 - '--providers.docker.exposedbydefault=false'64 labels:65 - traefik.enable=true66 - traefik.http.routers.traefik.entrypoints=http67 - traefik.http.routers.traefik.service=api@internal68 - traefik.http.services.traefik.loadbalancer.server.port=808069 - coolify.managed=true70 - coolify.proxy=true7172 traefik-certs-dumper:73 image: ghcr.io/kereis/traefik-certs-dumper:latest74 container_name: traefik-certs-dumper75 restart: unless-stopped76 depends_on:77 - traefik78 volumes:79 - /etc/localtime:/etc/localtime:ro80 - /data/coolify/proxy:/traefik:ro81 - /data/coolify/certs:/output
There are a few things to note:
- When I was testing I reached acme api rate limit and started getting errors, which did not make sence to me. So for testing i recommend using staging endpoint which has higher rate limits. Uncomment certificatesresolvers.letsencrypt.acme.caServer line when testing.
- The http challenge did not work for me, or I did not set it up correctly, so I tried using DNS challenge. For that I had to go and grab a Cloudflare API token which can be obtained by going into Cloudflare -> User Icon -> Profile -> API tokens -> Create token. I added IP filtering and set 3 permissions:
Account - Account settings - Read
Zone - Zone - Read
Zone - DNS - Edit
Selected my account for account resources, for zone resources I selected all zones. I tried being more restrictive, but I was getting errors from acme, I don't know if it was because I reached rate limit or not, but these settings worked. - I remember seeing somewhere in configs that you have to provide certificatesresolvers.letsencrypt.acme.email, but when testing everything worked without it, but I still included it.
I decided to use smtp relay for sending mail. For that I am using 587 port for sending emails, because Hetzner is blocking ports 25 and 465 because of spammers. Thats why I included submission port in the config.
Traefik handles all of the TLS certificates, but they are not exposed and I can't see them, for that I used traefik-certs-dumper service. It exports all TLS certificates for all domains on my coolify server into /data/coolify/certs.
A few times I made mistakes configuring traefik and the dashboard and all of my websites became inaccessible. To fix this I had to ssh into my server, then go into /data/coolify/proxy and edit docker-compose.yml file there to fix my mistakes. After fixing I just started the containers with docker compose up.
Stalwart
Stalwart version in the references post was old and did not work for me. I had to google and go into stalwart docs to figure out that they renamed a few things. First they changed image name from stalwartlabs/mail-server:latest to stalwartlabs/stalwart:latest, then the config directory was changed from /opt/stalwart-mail to /opt/stalwart. There probably was more changes but these are the most memorable that I struggled with.
To deploy stalwart go into and create a new resource and choose Docker Compose Empty. Somewhere there should be a place where you can write docker compose config. Here is mine:
1services:2 stalwart-mail:3 image: 'stalwartlabs/stalwart:latest'4 container_name: stalwart-mail5 networks:6 - coolify7 ports:8 - '25:25'9 - '587:587'10 - '465:465'11 - '143:143'12 - '993:993'13 - '4190:4190'14 - '110:110'15 - '995:995'16 volumes:17 - '/var/lib/stalwart2:/opt/stalwart'18 - '/etc/localtime:/etc/localtime:ro'19 - '/data/coolify/certs:/data/certs:ro'20 labels:21 - traefik.enable=true22 - 'traefik.http.routers.mailserver.rule=Host(`mail.YOUR_DOMAIN.com`) || Host(`autodiscover.YOUR_DOMAIN.com`) || Host(`autoconfig.YOUR_DOMAIN.com`) || Host(`mta-sts.YOUR_DOMAIN.com`) || Host(`mx.YOUR_DOMAIN.com`) || Host(`smtp.YOUR_DOMAIN.com`) || Host(`pop.YOUR_DOMAIN.com`) || Host(`imap.YOUR_DOMAIN.com`)'23 - traefik.http.routers.mailserver.entrypoints=http24 - traefik.http.routers.mailserver.service=mailserver25 - traefik.http.services.mailserver.loadbalancer.server.port=808026 - traefik.http.routers.mailserver.tls.certresolver=letsencrypt27 - traefik.http.routers.mailserver.tls=true28 - 'traefik.http.routers.mailserver.tls.domains[0].main=mail.YOUR_DOMAIN.com'29 - 'traefik.http.routers.mailserver.tls.domains[0].sans=autodiscover.YOUR_DOMAIN.com,autoconfig.YOUR_DOMAIN.com,mta-sts.YOUR_DOMAIN.com,mx.YOUR_DOMAIN.com,smtp.YOUR_DOMAIN.com,pop.YOUR_DOMAIN.com,imap.YOUR_DOMAIN.com'30 - 'traefik.tcp.routers.smtp.rule=HostSNI(`*`)'31 - traefik.tcp.routers.smtp.entrypoints=smtp32 - traefik.tcp.routers.smtp.service=smtp33 - traefik.tcp.services.smtp.loadbalancer.server.port=2534 - traefik.tcp.services.smtp.loadbalancer.proxyProtocol.version=235 - 'traefik.tcp.routers.jmap.rule=HostSNI(`*`)'36 - traefik.tcp.routers.jmap.tls.passthrough=true37 - traefik.tcp.routers.jmap.entrypoints=https38 - traefik.tcp.routers.jmap.service=jmap39 - traefik.tcp.services.jmap.loadbalancer.server.port=44340 - traefik.tcp.services.jmap.loadbalancer.proxyProtocol.version=241 - 'traefik.tcp.routers.smtps.rule=HostSNI(`*`)'42 - traefik.tcp.routers.smtps.tls.passthrough=true43 - traefik.tcp.routers.smtps.entrypoints=smtps44 - traefik.tcp.routers.smtps.service=smtps45 - traefik.tcp.services.smtps.loadbalancer.server.port=46546 - traefik.tcp.services.smtps.loadbalancer.proxyProtocol.version=247 - 'traefik.tcp.routers.submission.rule=HostSNI(`*`)'48 - traefik.tcp.routers.submission.tls.passthrough=true49 - traefik.tcp.routers.submission.entrypoints=submission50 - traefik.tcp.routers.submission.service=submission51 - traefik.tcp.services.submission.loadbalancer.server.port=58752 - traefik.tcp.services.submission.loadbalancer.proxyProtocol.version=253 - 'traefik.tcp.routers.imaps.rule=HostSNI(`*`)'54 - traefik.tcp.routers.imaps.tls.passthrough=true55 - traefik.tcp.routers.imaps.entrypoints=imaps56 - traefik.tcp.routers.imaps.service=imaps57 - traefik.tcp.services.imaps.loadbalancer.server.port=99358 - traefik.tcp.services.imaps.loadbalancer.proxyProtocol.version=259 tty: true60 stdin_open: true61 restart: always62volumes:63 data: null64networks:65 coolify:66 external: true67
I struggled a lot with the ports. If a new port is defined in stalwart docker compose labels section, then that port must be defined in the traefik config.
Now stalwart docker compose can be deployed. After deployment check the logs in the logs tab. There should be admin password, if the deployment was successful, make sure to grab that. Now go into /terminal and open your server terminal for localhost. Then cd into /var/lib/stalwart/etc and open config.toml using nano config.toml. In this file I had to tell where the certificates for my mail domain was and I setup credentials to relay emails to other domains to resend.com. Before making any changes I recommend making a copy of current config with cp config.toml config.toml.bak just in case something happens.
Make sure to add or configure these config lines for certificates, hostname and other ports that are need:
1certificate.default.cert = "%{file:/data/certs/mail.YOUR_DOMAIN.com/cert.pem}%"2certificate.default.default = true3certificate.default.private-key = "%{file:/data/certs/YOUR_DOMAIN.com/key.pem}%"4server.hostname = "mail.YOUR_DOMAIN.com"5server.listener.submission.bind = "[::]:587"6server.listener.submission.protocol = "smtp"7server.listener.submissions.bind = "[::]:465"8server.listener.submissions.protocol = "smtp"9server.listener.submissions.tls.implicit = true
By default the config looks very nice and structured, but after a few restarts and visits to dashboard, it becomes this de-structured mess. I assume because the dashboard edits this config file as well.
Now also if you're using Hetzner or other VPN provider you can also setup relay using resend.com or other service.
1[server.listener.smtp]2bind = "[::]:25"34[session.rcpt]5catch-all = true6protocol = "smtp"78[certificate.default]9cert = "%{file:/data/certs/mail.domain.com/cert.pem}%"10default = true11private-key = "%{file:/data/certs/mail.domain.com/key.pem}%"1213[queue.route."resend"]14type = "relay"15address = "smtp.resend.com"16port = 58717protocol = "smtp"1819[queue.route."resend".tls]20implicit = false2122[queue.route."resend".auth]23username = "resend"24secret = "re_API_KEY"2526[queue.strategy]27route = [28 { if = "is_local_domain('', rcpt_domain)", then = "'local'" },29 { else = "'resend'" }30]3132[smtp.outbound.tls]33dane = false34mta-sts = false3536[lookup.default]37hostname = "mail.domain.com"3839[server.listener.submission]40bind = "[::]:587"41protocol = "smtp"4243[server.listener.submissions]44bind = "[::]:465"45protocol = "smtp"46tls.implicit = true4748[server.listener.imap]49bind = "[::]:143"50protocol = "imap"5152[server.listener.imaptls]53bind = "[::]:993"54protocol = "imap"55tls.implicit = true5657[server.listener.pop3]58bind = "[::]:110"59protocol = "pop3"6061[server.listener.pop3s]62bind = "[::]:995"63protocol = "pop3"64tls.implicit = true6566[server.listener.sieve]67bind = "[::]:4190"68protocol = "managesieve"6970[server.listener.https]71protocol = "http"72bind = "[::]:443"73tls.implicit = true7475[server.listener.http]76protocol = "http"77bind = "[::]:8080"7879[storage]80data = "rocksdb"81fts = "rocksdb"82blob = "rocksdb"83lookup = "rocksdb"84directory = "internal"8586[store.rocksdb]87type = "rocksdb"88path = "/opt/stalwart/data"89compression = "lz4"9091[directory.internal]92type = "internal"93store = "rocksdb"9495[tracer.log]96type = "log"97level = "info"98path = "/opt/stalwart/logs"99prefix = "stalwart.log"100rotate = "daily"101ansi = false102enable = true103104[authentication.fallback-admin]105user = ""106secret = ""
Hetzner
Now if hetzner is being used then it is recommended to setup a firewall to protect some of the ports. This can be done by going into hetzner dashboard -> choose your server -> firewall. Here a new firewall can be created. I have inbound rules set up for ports 22, 25, 80, 110, 143, 443, 456, 587, 993, 995, 4190 all for tcp and I allow any IPv4 or IPv6
