How To Set Up A Mailserver Within A Docker Swarm
Ever wanted to have your own mail server? Learn how to set up your own personal mail server with this step-by-step guide
I run my own mail server to have generalized email addresses for different services.
While I searched for a nice solution to put my mail server into a dockerized mail server I found the famous docker-mailserver. Unfortunately, I could not use this mail server because I had many errors while configuring it.
Because of this, I searched for an alternative and found hardware-mailserver which is something like an optimized usage of a docker-mailserver with multiple predefined functionalities.
If I had a normal docker environment without any other services I could use this docker-compose.yml and start it by executing:
docker-compose up -d
Because I run a Docker Swarm environment with a Traefik load balancer that creates SSL certificates for my domains I have to make some adjustments within the Compose file to set up and configure the mail server. Every adjustment will be explained later on service by service.
Mailserver
mailserver:
image: hardware/mailserver:1.1-stable
restart: ${MAILSERVER_RESTART_MODE}
domainname: ${MAILSERVER_DOMAIN}
hostname: ${MAILSERVER_HOSTNAME}
deploy:
placement:
constraints:
- node.role == manager
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
- traefik.constraint-label=traefik-public
- traefik.frontend.rule=Host:mail.${MAILSERVER_DOMAIN}
- traefik.http.routers.mailserver-spam-http.rule=Host(`${MAILSERVER_FQDN?Variable not set}`) || Host(`pop3.${MAILSERVER_DOMAIN?Variable not set}`) || Host(`smtp.${MAILSERVER_DOMAIN?Variable not set}`) || Host(`imap.${MAILSERVER_DOMAIN?Variable not set}`) || Host(`${MAILSERVER_DOMAIN_RSPAMD?Variable not set}`)
- traefik.http.routers.mailserver-spam-http.entrypoints=http
- traefik.http.routers.mailserver-spam-http.middlewares=https-redirect
- traefik.http.routers.mailserver-spam-https.rule=Host(`${MAILSERVER_FQDN?Variable not set}`) || Host(`pop3.${MAILSERVER_DOMAIN?Variable not set}`) || Host(`smtp.${MAILSERVER_DOMAIN?Variable not set}`) || Host(`imap.${MAILSERVER_DOMAIN?Variable not set}`) || Host(`${MAILSERVER_DOMAIN_RSPAMD?Variable not set}`)
- traefik.http.routers.mailserver-spam-https.entrypoints=https
- traefik.http.routers.mailserver-spam-https.tls=true
- traefik.http.routers.mailserver-spam-https.tls.certresolver=le
- traefik.http.services.mailserver-spam.loadbalancer.server.port=11334
ports:
- "25:25" # SMTP - Required
- "110:110" # POP3 STARTTLS - Optional - For webmails/desktop clients
- "143:143" # IMAP STARTTLS - Optional - For webmails/desktop clients
- "465:465" # SMTPS SSL/TLS - Optional - Enabled for compatibility reason, otherwise disabled
- "587:587" # Submission STARTTLS - Optional - For webmails/desktop clients
- "993:993" # IMAPS SSL/TLS - Optional - For webmails/desktop clients
- "995:995" # POP3S SSL/TLS - Optional - For webmails/desktop clients
- "4190:4190" # SIEVE STARTTLS - Optional - Recommended for mail filtering
environment:
- FQDN=${MAILSERVER_FQDN}
- DOMAIN=${MAILSERVER_DOMAIN}
- DBPASS=${MAILSERVER_DATABASE_USER_PASSWORD} # MariaDB database password (required)
- RSPAMD_PASSWORD=${MAILSERVER_RSPAMD_PASSWORD} # Rspamd WebUI password (required)
#- ADD_DOMAINS=aa.tld, www.bb.tld... # Add additional domains separated by commas (needed for dkim keys etc.)
- ENABLE_POP3=true # Enable POP3 protocol
#
# Full list : https://github.com/hardware/mailserver#environment-variables
#
volumes:
- mail:/var/mail
#- ./cert:/etc/letsencrypt/live/${MAILSERVER_FQDN}
depends_on:
- mariadb
- redis
networks:
- traefik-public
- default
This is the main service used by the mail server suite. The most important thing I had to add was the environment variables. Because I run a Docker Swarm setup hostname does not work correctly and I have to develop something else. Within a pull request on the GitHub page, I found a possible solution.
I have to add FQDN
and DOMAIN
as environment variables. Another section I have to change is the labels section. I added the deploy
section and created two important properties:
placement-constraint
andlabels
.
placement-constraints
are used to always deploy the mail server on my manager node and labels
is filled with all information that is needed for my Traefik instance.
Finally, I have to change the volume entries. I want to use docker volumes instead of a local shared folder (which is achieved by adding a ./
in front of the volume name)
Also, there is one very important entry within the volumes section:
#- ./cert:/etc/letsencrypt/live/${MAILSERVER_FQDN}
I will explain what it does and why its comment out later in detail
Postfix-admin
postfixadmin:
image: hardware/postfixadmin
restart: ${MAILSERVER_RESTART_MODE}
domainname: ${MAILSERVER_DOMAIN}
hostname: ${MAILSERVER_HOSTNAME}
deploy:
placement:
constraints:
- node.role == manager
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
- traefik.constraint-label=traefik-public
- traefik.http.routers.mailserver-postfixadmin-http.rule=Host(`${MAILSERVER_DOMAIN_POSTFIXADMIN?Variable not set}`)
- traefik.http.routers.mailserver-postfixadmin-http.entrypoints=http
- traefik.http.routers.mailserver-postfixadmin-http.middlewares=https-redirect
- traefik.http.routers.mailserver-postfixadmin-https.rule=Host(`${MAILSERVER_DOMAIN_POSTFIXADMIN?Variable not set}`)
- traefik.http.routers.mailserver-postfixadmin-https.entrypoints=https
- traefik.http.routers.mailserver-postfixadmin-https.tls=true
- traefik.http.routers.mailserver-postfixadmin-https.tls.certresolver=le
- traefik.http.services.mailserver-postfixadmin.loadbalancer.server.port=8888
environment:
- DBPASS=${MAILSERVER_DATABASE_USER_PASSWORD}
- FQDN=${MAILSERVER_FQDN}
- DOMAIN=${MAILSERVER_DOMAIN}
depends_on:
- mailserver
- mariadb
networks:
- traefik-public
- default
Like I did before I have to add environment variables FQDN
and DOMAIN
, adjust the placement-constraint
, labels
and updated the volumes.
Rainloop
rainloop:
image: hardware/rainloop
restart: ${MAILSERVER_RESTART_MODE}
deploy:
placement:
constraints:
- node.role == manager
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
- traefik.constraint-label=traefik-public
- traefik.http.routers.mailserver-rainloop-http.rule=Host(`${MAILSERVER_DOMAIN_RAINLOOP?Variable not set}`)
- traefik.http.routers.mailserver-rainloop-http.entrypoints=http
- traefik.http.routers.mailserver-rainloop-http.middlewares=https-redirect
- traefik.http.routers.mailserver-rainloop-https.rule=Host(`${MAILSERVER_DOMAIN_RAINLOOP?Variable not set}`)
- traefik.http.routers.mailserver-rainloop-https.entrypoints=https
- traefik.http.routers.mailserver-rainloop-https.tls=true
- traefik.http.routers.mailserver-rainloop-https.tls.certresolver=le
- traefik.http.services.mailserver-rainloop.loadbalancer.server.port=8888
volumes:
- rainloop:/rainloop/data
depends_on:
- mailserver
- mariadb
networks:
- traefik-public
- default
I only add the deploy
section and change volumes.
MariaDB
mariadb:
image: mariadb:10.2
hostname: mariadb.db
restart: ${MAILSERVER_RESTART_MODE}
# Info : These variables are ignored when the volume already exists (if databases was created before).
environment:
- MYSQL_RANDOM_ROOT_PASSWORD=yes
- MYSQL_DATABASE=postfix
- MYSQL_USER=postfix
- MYSQL_PASSWORD=${MAILSERVER_DATABASE_USER_PASSWORD}
deploy:
placement:
constraints:
- node.role == manager
volumes:
- sql:/var/lib/mysql
networks:
- default
The MariaDB container gets a placement-constraint
so I don't lose data and docker volumes that are used. Additionally, it is very important that the MYSQL_PASSWORD
is the same as defined within the mailserver-service.
Redis
redis:
image: redis:4.0-alpine
hostname: redis.db
restart: ${MAILSERVER_RESTART_MODE}
command: redis-server --appendonly yes
deploy:
placement:
constraints:
- node.role == manager
volumes:
- redis:/data
networks:
- default
The last service is the Redis container which gets a placement-constraint
and adjusted volumes.
Going Further
After the services were adjusted within one file (docker-compose.mailserver.yml
) the next step was to deploy the Docker Swarm stack. Because I declared several environment variables I had to export them first (and I have to export them every time I recreate the service):
export MAILSERVER_DOMAIN=$PRIMARY_DOMAIN
export MAILSERVER_HOSTNAME=mail
export MAILSERVER_DOMAIN_POSTFIXADMIN=postfixadmin.$MAILSERVER_DOMAIN
export MAILSERVER_DOMAIN_RSPAMD=mail-spam.$MAILSERVER_DOMAIN
export MAILSERVER_DOMAIN_RAINLOOP=emails.$MAILSERVER_DOMAIN
export MAILSERVER_FQDN=$MAILSERVER_HOSTNAME.$MAILSERVER_DOMAIN
export MAILSERVER_DATABASE_USER_PASSWORD=lkhsaf98oihn53oysf9usaih5nb3rysa98fh3lnoiushdfisdugfbn32thsd9gpiesgs
export MAILSERVER_RSPAMD_PASSWORD=wsdofytesdf#*TYFkjhasd
export MAILSERVER_DOCKER_TAG=1.1-stable
export MAILSERVER_RESTART_MODE=unless-stopped
After exporting these variables I could deploy the mail server to my Docker Swarm by executing:
docker stack deploy -c docker-compose.mailserver.yml mailserver
Now the mail server was deployed BUT it does not use SSL. This is because the certificates are generated by traefik the first time AFTER the website was visited for the first time. Because I declared within the mailserver-service that there is a domain called mail.$PRIMARY_DOMAIN
. I opened this website and it showed rspam BUT it also creates the SSL certificate which is needed for the mail server.
How To Use SSL?
For the prior created SSL certificates I need a function to transfer them to the mail server. Unfortunately, the automatic transfer process from the mail server could not be used because I installed the second version of Traefik.
I had to create a small script that uses dumpcerts (from the mailserver GitHub) for extracting certs from traefik-acme.json and storing them into a file that the mail server can use to have an SSL certificate:
#!/bin/sh
mkdir -p /tmp/dump
./dumpcerts.traefik.v2.sh ./acme.json /tmp/dump
cp /tmp/dump/certs/$MAILSERVER_FQDN.crt cert/fullchain.pem
cp /tmp/dump/private/$MAILSERVER_FQDN.key cert/privkey.pem
This script assumes that the acme.json
file that traefik uses to store certificates is located in the same folder as the mail server. The dumpcert script can be found within the GitHub repository.
If the scripts exit without error the mail server can be turned down and redeployed (because SSL data is only checked at the start of the mail server stack ).
Additionally, I created an update script that uses all commands together:
./update-cert
docker stack rm mailserver
docker stack deploy -c docker-compose.mailserver.yml mailserver
Unfortunately, you have to do this every three months because traefik generated certs only have a life span of three months.
After install stuff
If everything is started the next task is configuring postfix-admin
and rainloop
. There are two very easy info pages for this where you can find all information about configuring these services: rainloop-initial, postfix-initial
Furthermore, the DNS setup is required so that the mail server works! This step is very important.
Notes:
- Make sure that the PTR record of your IP matches the FQDN (default: mail.domain.tld) of your mail server host. This record is usually set in your web hosting interface.
- DKIM, SPF, and DMARC records are recommended to build a good reputation score.
- DMARC record can be created on Dmarcian.com
- SPF needs the public IP of the mail server!:
v=spf1 a mx ipYOURMAINHERE ~all
- The DKIM public (
mail._domainkey
) key will be available on the host after the container startup here:
/var/lib/docker/volumes/mailserver_mail/_data/dkim/domain.tld/public.key
Closing Notes
Now the mail server is running and you can use postfix admin (postfixadmin.yourdomain.de) to create accounts that can be accessed with Rainloop (webmail.yourdomain.de) or another email client (Thunderbird/Betterbird).
I hope you find this tutorial helpful and are now able to add a mail server to your Docker Swarm environment.
Also, if you have any questions, ideas, recommendations, or want to share cool Docker commands or tools, please contact me. I try to answer your question if possible and will test your recommendations.
🙌 Support this content
If you like this content, please consider supporting me. You can share it on social media, buy me a coffee, or become a paid member. Any support helps.
See the contribute page for all (free or paid) ways to say thank you!
Thanks! 🥰