Setting up Stalwart on Coolify
By Aldert Vaandering
December 7, 2024

Recently I've started self-hosting (well, by self-hosting I mean on my own Hetzner server) many of my own projects to cut down on excessive cloud costs.
One of the tools I use to still be able to prototype quickly with all the comfort of automated deployments is Coolify, a really cool and easy to use open-source Heroko/Netlify/Vercel-type platform.
Using it, I quickly was able to host an instance of Infisical, an open source secrets manager, Directus an open-source alternative to contentful, a few of my applications and, why you're probably here, Stalwart - my first introduction to self-hosting a mail server.
It took me a few hours over a couple of days to get it set up, so I thought I'd write this post helping out anyone else wanting to do the same.
The problem
Stalwart (or any mail server, for that matter) requires quite a bit of set up before you can start sending/receiving e-mails. The main issue I ran into was getting TLS set up. The main thing is that Coolify runs all your containers behind Traefik, a reverse proxy. So we want Traefik to be handling the generation of the required TLS certificates instead of handing that responsibility to Stalwart.
For clarity I will take you through each step required to get Stalwart working, not just the parts I ran into trouble. However I won't be covering the installation of Coolify and your domain itself, there are plenty resources available for that on the web. I will cover some of the configuration you will need to do on your server (Hetzner in my case).
Let's begin:
First off, create a new resource of type Docker Compose Empty
Then copy the following configuration as the docker compose file:
services:
stalwart-mail:
image: 'stalwartlabs/mail-server:latest'
container_name: stalwart-mail
networks:
- coolify
ports:
- '25:25'
- '587:587'
- '465:465'
- '143:143'
- '993:993'
- '4190:4190'
- '110:110'
- '995:995'
volumes:
- '/var/lib/stalwart-mail:/opt/stalwart-mail'
- /etc/localtime:/etc/localtime:ro
- /data/coolify/certs:/data/certs:ro
labels:
- traefik.enable=true
- 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`)
- traefik.http.routers.mailserver.entrypoints=http
- traefik.http.routers.mailserver.service=mailserver
- traefik.http.services.mailserver.loadbalancer.server.port=8080
- traefik.http.routers.mailserver.tls.certresolver=letsencrypt
- traefik.http.routers.mailserver.tls=true
- traefik.http.routers.mailserver.tls.domains[0].main=mail.YOUR_DOMAIN.com
- 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
- traefik.tcp.routers.smtp.rule=HostSNI(`*`)
- traefik.tcp.routers.smtp.entrypoints=smtp
- traefik.tcp.routers.smtp.service=smtp
- traefik.tcp.services.smtp.loadbalancer.server.port=25
- traefik.tcp.services.smtp.loadbalancer.proxyProtocol.version=2
- traefik.tcp.routers.jmap.rule=HostSNI(`*`)
- traefik.tcp.routers.jmap.tls.passthrough=true
- traefik.tcp.routers.jmap.entrypoints=https
- traefik.tcp.routers.jmap.service=jmap
- traefik.tcp.services.jmap.loadbalancer.server.port=443
- traefik.tcp.services.jmap.loadbalancer.proxyProtocol.version=2
- traefik.tcp.routers.smtps.rule=HostSNI(`*`)
- traefik.tcp.routers.smtps.tls.passthrough=true
- traefik.tcp.routers.smtps.entrypoints=smtps
- traefik.tcp.routers.smtps.service=smtps
- traefik.tcp.services.smtps.loadbalancer.server.port=465
- traefik.tcp.services.smtps.loadbalancer.proxyProtocol.version=2
- traefik.tcp.routers.imaps.rule=HostSNI(`*`)
- traefik.tcp.routers.imaps.tls.passthrough=true
- traefik.tcp.routers.imaps.entrypoints=imaps
- traefik.tcp.routers.imaps.service=imaps
- traefik.tcp.services.imaps.loadbalancer.server.port=993
- traefik.tcp.services.imaps.loadbalancer.proxyProtocol.version=2
tty: true
stdin_open: true
restart: always
volumes:
data:
networks:
coolify:
external: true
(do make sure you replace YOUR_DOMAIN with whatever your domain is, ofcourse).
What we're doing here is mounting /data/coolify/certs
from your server to the /data/certs:ro
directory in the docker container (with read only permissions). At the moment this doesn't do much, but we will fix that in the coming steps.
We are also configuring Traefik to generate a certificate that encompasses all the domains required for Stalwart (smtp, mx, etc.). If you don't or do use certain protocols, feel free to add or remove them.
Make sure to enable Connect To Predefined Network
and give your stalwart mail server a domain link, for example mail.yourdomain.com (the 8080 after the domain name is important by the way, don't remove it. You will still be able to open the stalwart web UI without adding the port to your URL)
Important! You get the admin user and password for the web interface by deploying and checking the logs. Otherwise you can get fallback credentials from the config (we get to the config later)
Next you will want to adjust your Traefik settings on your Coolify server by going to Servers -> your server -> Proxy
.
Here you need to add an extra service that extracts and dumps the certs that are generated in your Traefik acme.json
.
Here's my personal Traefik config:
networks:
coolify:
external: true
services:
traefik:
container_name: coolify-proxy
image: 'traefik:v3.1'
restart: unless-stopped
environment:
- NAMECHEAP_API_KEY=xxxxxxxx
- NAMECHEAP_API_USER=xxxxxxxx
extra_hosts:
- 'host.docker.internal:host-gateway'
networks:
- coolify
ports:
- '80:80'
- '443:443'
- '443:443/udp'
- '8080:8080'
healthcheck:
test: 'wget -qO- http://localhost:80/ping || exit 1'
interval: 4s
timeout: 2s
retries: 5
volumes:
- '/var/run/docker.sock:/var/run/docker.sock:ro'
- '/data/coolify/proxy:/traefik'
- /etc/localtime:/etc/localtime:ro
- /etc/traefik:/etc/traefik
command:
- '--ping=true'
- '--ping.entrypoint=http'
- '--api.dashboard=true'
- '--api.insecure=false'
- '--entrypoints.http.address=:80'
- '--entrypoints.https.address=:443'
- '--entrypoints.http.http.encodequerysemicolons=true'
- '--entryPoints.http.http2.maxConcurrentStreams=50'
- '--entrypoints.https.http.encodequerysemicolons=true'
- '--entryPoints.https.http2.maxConcurrentStreams=50'
- '--entrypoints.https.http3'
- '--providers.docker.exposedbydefault=false'
- '--providers.file.directory=/traefik/dynamic/'
- '--providers.file.watch=true'
- '--certificatesresolvers.letsencrypt.acme.httpchallenge=true'
- '--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json'
- '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=http'
- '--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=namecheap'
- '--providers.docker=true'
labels:
- traefik.enable=true
- traefik.http.routers.traefik.entrypoints=http
- traefik.http.routers.traefik.service=api@internal
- traefik.http.services.traefik.loadbalancer.server.port=8080
- coolify.managed=true
- coolify.proxy=true
traefik-certs-dumper:
image: ghcr.io/kereis/traefik-certs-dumper:latest
container_name: traefik-certs-dumper
restart: unless-stopped
depends_on:
- traefik
volumes:
- /etc/localtime:/etc/localtime:ro
- /data/coolify/proxy:/traefik:ro
- /data/coolify/certs:/output
I use namecheap and have Traefik automatically configure certain things due to the following lines, but you might have a different setup for domain configuration.
traefik:
{...}
environment:
- NAMECHEAP_API_KEY=xxxxxxxx
- NAMECHEAP_API_USER=xxxxxxxx
{...}
command:
{...}
- '--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=namecheap'
The main lines that we care about to get things to work with Stalwart are these:
traefik:
{...}
command:
{...}
# The following line shows where traefik stores the generated certificates:
- '--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json'
# This service dumps the certs in the above file to /output in the container
traefik-certs-dumper:
image: ghcr.io/kereis/traefik-certs-dumper:latest
container_name: traefik-certs-dumper
restart: unless-stopped
depends_on:
- traefik
volumes:
- /etc/localtime:/etc/localtime:ro
- /data/coolify/proxy:/traefik:ro
- /data/coolify/certs:/output
# and this volume bind to the output, so that we can later use the certs in Stalwart
With this the most important steps are done and all we need to do next is making sure the certs are getting generated and available to Stalwart.
Connecting the dots
Configuring Stalwart to use the certs.
If everything went well your certificates should now be generated and mounted in Stalwart. However Stalwart won't know about them yet.
To fix this we need to do some manual configuration (at least I haven't figured out how to do this from the web ui yet).
Open a terminal in the stalwart container.
if you ls
you should see etc
. CD into it and double check if you have a config.toml
there.
The stalwart container doesn't have any editors so install one (you can also copy the config out and back in but this is easier) apt install nano
and open the file to edit: nano config.toml
Then add the end of the file add the following lines:
certificate.default.cert = "%{file:/data/certs/mail.YOUR_DOMAIN.com/cert.pem}%"
certificate.default.default = true
certificate.default.private-key = "%{file:/data/certs/mail.YOUR_DOMAIN.com/key.pem}>
while you're here you may as well set your hostname up in the top of the file:
lookup.default.hostname = "mail.YOUR_DOMAIN.com"
but you can do this through the web interface as well.
Now save the file (ctrl+x if you're using nano) and restart stalwart in coolify.
Configuring your name server
Open the web interface for stalwart (mail.YOUR_DOMAIN.com or whatever subdomain you used) and login with the credentials you got from the logs or your config.toml.
Go to Directory -> Domains
and Create domain
. For Domain name simply use YOUR_DOMAIN.com
(whatever is supposed to be behind the @ of email addresses basically), you don't need to fill out anything else. Click save changes and click ... -> DNS records
for your newly created domain entry.
Now you have the fun task of making sure the entries on display here are properly set on your name server. How you do this depends on your provider.
That's it. You've just configured your stalwart server and are ready to mail!
Loose ends?
If things still don't work, you might want to check if you have properly configured your firewall on your server provider. For Hetzner you want to set up a firewall with the ports for stalwart and add apply it to your server:
Troubleshooting
How do I check if my certs are generated and/or available in <...>
Go to the Terminal
tab in coolify. Here you can use the terminal and go step by step to see if things are linked up correctly. If you set things up correctly, you should be able to see servername -> traefik-certs-dumper
and servername -> stalwart-mail-{id}
.
Simply open the terminal on each step and check the directories.
Check acme.json:
servername -> cd .. && cd data/coolify/proxy && cat acme.json | grep main
Check if acme.json is properly dumped to certs:
servername -> traefik-certs-dumper -> cd output && ls -1
Check if volume is properly mounted from traefik-certs-dumper to server (this should output the same as above):
servername -> cd .. && cd data/coolify/certs && ls -1
Check if certs are available in stalwart (again output should be the same as above):
servername -> stalwart-mail-{id} -> cd ../.. && cd data/certs && ls -1
My certificates are not generated, what do?
Firstly, try to restart the proxy. You can use the UI in coolify, or either the terminal in Coolify or SSH into your server and run docker compose down coolify-proxy && docker compose up coolify-proxy
(note you can always use docker ps
to check the state of currently running containers).
If that doesn't seem to work, your next step would be to rename your existing acme.json
and restart traefik. To do this open a terminal in your server (through coolify web or SSH) and cd /data/coolify/proxy
. If you do ls
you should see acme.json
. It could be that it has not generated newly added configuration. You can force this to happen by renaming it mv acme.json acme.json.bak
and restarting traefik: docker compose down && docker compose up
. Make sure to do this from /data/coolify/proxy so that the certs dumper is also pulled down and up.
The acme.json should be regenerated with any updated configuration after this and any certs dumped.
I/you broke my proxy, now what!?
SSH into your server. How you do this depends on your server. For Hetzner, I have generated a SSH key and added it to my server via the Hetzner server dashboard.
Then I simply log in by ssh root@serverip -i 'path/to/ssh.pub'
(you might not need to provide the path).
Now you can cd
to /data/coolify/proxy
and edit the docker-compose.yml
using nano
or whatever you prefer. Restart Traefik using docker compose up coolify-proxy
(add -d
to not connect to the console output)
Everything seems set up, how do I start emailing?
Login to the web interface (mail.YOUR_DOMAIN.com), with the credentials you got from the config.toml or from the logs after deploy.
Then create an account under Directory -> Accounts
Make sure to assign a password under Authentication.
Now download thunderbird or whatever and login with the email and password you just created
I can receive email but not send
Most likely your server provider is blocking port 25. To test this you can go into the terminal of your server and run telnet alt4.gmail-smtp-in.l.google.com 25
. If it connects the issue is in your configuration with docker/stalwart. If it doesn't, port 25 is blocked by your provider.
Providers often block this port to combat spam. You will have to contact them to resolve this. Hetzner requires you to have been a customer for 30 days and have paid your first invoice before you can request port 25 to be unblocked.
Amsterdam
The Netherlands
Arusan
KvK 84379057
1-201 taartskceeblaW