Set Up A Mailserver Within A Docker Swarm

Photo by Wayhomestudio / Freepik

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

Paul Knulst  in  Docker Mar 11, 2022 7 min read

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 and
  • labels.

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.

Mailserver is deployed with Docker within Docker Swarm but need SSL
Yoda meme generated with memegenerator

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.

Set important DNS records to have a good score for your mailserver
Screenshot of GitHub page from hardware/mailserver

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
Docker Swarm mailserver is installed and set up with SSL, PTR record, DKIM, SPF, DMARC
House meme from memegenerator

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.

Connect with me on Twitter, LinkedIn, Medium, and GitHub!


🙌 Support this content

If you like this content, please consider supporting me. You can share it on social media or buy me a coffee! Any support helps!

See the contribute page for all (free or paid) ways to say thank you!

Thanks! 🥰

By Paul Knulst

I'm a husband, dad, lifelong learner, tech lover, and Senior Engineer working as a Tech Lead. I write about projects and challenges in IT.