Building, deploying and securing your own mail server with Docker

By Stephen Sorriaux on 8/13/2023
Image representing the use of a mail server

Introduction

This blog post will show how to easily deploy a Mailu server using Docker (and docker-compose). The project is already providing Nginx as a (much needed) reverse-proxy but since I am a fan of Traefik, I will also give the configuration I used in case, like me, you have multiple services deployed on several hosts and are using Traefik to properly handle the HTTP/TCP/UDP routing (yes, Traefik does all of this!).

For the encryption part, we will use Letsencrypt to generate the certificate(s) for our domain(s).

Recommanded setup:

  • CPU: at least 1 CPU @ 2.0Ghz

  • RAM: at least 1GB of RAM (+1GB of swap)

  • Disk: enough disk space available to store your emails: for durability purposes you should use a FS backed by a RAID-1 device, or from a distributed FS like GlusterFS

You would need:

  • a domain (example.com in this post), where email.example.com will be used for Mailu (webmail, admin, SMTP, IMAP, POP3 - all in their secure and unsecure versions)

  • a host with Docker and docker-compose installed

  • a host with a publicly accessible IPv4 (1.2.3.4 in this post)

Host configuration

In this post I will consider the base path to save any of Mailu data is /var/lib/mailu. You are free to use anything else that would better suit your (HA) needs.

Mailu provides a web utility to generate the configurations needed. It can be used to generate the docker-compose.yml and mailu.env files. Below are the same files, adjusted for the example.com example domain.

docker-compose.yml

---
version: '2.1'

services:

  # External dependencies
  redis:
    networks:
      - mail
    image: redis:alpine
    restart: always
    volumes:
      - "/var/lib/mailu/data/redis:/data"

  # Core services
  front:
    image: ghcr.io/${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}nginx:${MAILU_VERSION:-1.9}
    restart: always
    env_file: mailu.env
    networks:
      - mail
    logging:
      driver: json-file
    ports:
      - "1.2.3.4:80:80"
      - "1.2.3.4:443:443"
      - "1.2.3.4:25:25"
      - "1.2.3.4:465:465"
      - "1.2.3.4:587:587"
      - "1.2.3.4:110:110"
      - "1.2.3.4:995:995"
      - "1.2.3.4:143:143"
      - "1.2.3.4:993:993"
    volumes:
    - "/var/lib/mailu/certs:/certs"
#      - "/var/lib/mailu/overrides/nginx:/overrides"

  admin:
    networks:
      - mail
    image: ghcr.io/${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}admin:${MAILU_VERSION:-1.9}
    restart: always
    env_file: mailu.env
    volumes:
      - "/var/lib/mailu/data:/data"
      - "/var/lib/mailu/data/dkim:/dkim"
    depends_on:
      - redis

  imap:
    networks:
      - mail
    image: ghcr.io/${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}dovecot:${MAILU_VERSION:-1.9}
    restart: always
    env_file: mailu.env
    volumes:
      - "/var/lib/mailu/data/mail:/mail"
#      - "/var/lib/mailu/overrides:/overrides"
    depends_on:
      - front

  smtp:
    networks:
      - mail
    image: ghcr.io/${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}postfix:${MAILU_VERSION:-1.9}
    restart: always
    env_file: mailu.env
 #   volumes:
 #     - "/var/lib/mailu/overrides:/overrides"
    depends_on:
      - front

  antispam:
    hostname: antispam
    networks:
      - mail
    image: ghcr.io/${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rspamd:${MAILU_VERSION:-1.9}
    restart: always
    env_file: mailu.env
    volumes:
      - "/var/lib/mailu/data/filter:/var/lib/rspamd"
      - "/var/lib/mailu/data/dkim:/dkim"
#      - "/var/lib/mailu/overrides/rspamd:/etc/rspamd/override.d"
    depends_on:
      - front

  # Webmail
  webmail:
    image: ghcr.io/${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rainloop:${MAILU_VERSION:-1.9}
    restart: always
    env_file: mailu.env
    networks:
      - mail
    volumes:
      - "/var/lib/mailu/data/webmail:/data"
    depends_on:
      - imap


networks:
  mail:
    driver: bridge
    ipam:
      driver: default
      config:
        - subnet: 192.168.203.0/24

The important things about this docker-compose.yml file:

  • all volumes are using the base path /var/lib/mailu, don’t forget to adjust it if you require anything different
  • the front service requires being binded to the host public IPv4 (1.2.3.4 in this post), don’t forget to replace it with your public IPv4
  • the overrides volume mapping are commented and can be used later for any specific configuration
  • if your 80/tcp and/or your 443/tcp port(s) are not available, see the Bonus 1: Traefik enters the chat section

mailu.env

# Mailu main configuration file
#
# Generated for compose flavor
#
# This file is autogenerated by the configuration management wizard.
# For a detailed list of configuration variables, see the documentation at
# https://mailu.io

###################################
# Common configuration variables
###################################

# Set this to the path where Mailu data and configuration is stored
# This variable is now set directly in `docker-compose.yml by the setup utility
# ROOT=/home/stephen/data/mailu

# Mailu version to run (1.0, 1.1, etc. or master)
MAILU_VERSION=1.9
VERSION=1.9

# Set to a randomly generated 16 bytes string
SECRET_KEY=<GENERATE THIS>

# Address where listening ports should bind
# This variables are now set directly in `docker-compose.yml by the setup utility
# PUBLIC_IPV4= 127.0.0.1 (default: 127.0.0.1)
# PUBLIC_IPV6= ::1 (default: ::1)

# Subnet of the docker network. This should not conflict with any networks to which your system is connected. (Internal and external!)
SUBNET=192.168.203.0/24

# Main mail domain
DOMAIN=example.com

# Hostnames for this server, separated with comas
HOSTNAMES=email.example.com

# Postmaster local part (will append the main mail domain)
POSTMASTER=admin

# Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt)
TLS_FLAVOR=mail-letsencrypt

# Authentication rate limit (per source IP address)
AUTH_RATELIMIT=10/minute;1000/hour

# Opt-out of statistics, replace with "True" to opt out
DISABLE_STATISTICS=True

###################################
# Optional features
###################################

# Expose the admin interface (value: true, false)
ADMIN=true

# Choose which webmail to run if any (values: roundcube, rainloop, none)
WEBMAIL=rainloop

# Dav server implementation (value: radicale, none)
WEBDAV=none

# Antivirus solution (value: clamav, none)
#ANTIVIRUS=clamav

#Antispam solution
ANTISPAM=none

###################################
# Mail settings
###################################

# Message size limit in bytes
# Default: accept messages up to 50MB
# Max attachment size will be 33% smaller
MESSAGE_SIZE_LIMIT=50000000

# Networks granted relay permissions
# Use this with care, all hosts in this networks will be able to send mail without authentication!
RELAYNETS=

# Will relay all outgoing mails if configured
RELAYHOST=

# Fetchmail delay
FETCHMAIL_DELAY=600

# Recipient delimiter, character used to delimiter localpart from custom address part
RECIPIENT_DELIMITER=+

# DMARC rua and ruf email
DMARC_RUA=admin
DMARC_RUF=admin

# Welcome email, enable and set a topic and body if you wish to send welcome
# emails to all users.
WELCOME=false
WELCOME_SUBJECT=Welcome to your new email account
WELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!

# Maildir Compression
# choose compression-method, default: none (value: bz2, gz)
COMPRESSION=
# change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL=

###################################
# Web settings
###################################

# Path to redirect / to
WEBROOT_REDIRECT=/webmail

# Path to the admin interface if enabled
WEB_ADMIN=/admin

# Path to the webmail if enabled
WEB_WEBMAIL=/webmail

# Website name
SITENAME=MyGreatMailServer

# Linked Website URL
WEBSITE=https://email.example.com

INITIAL_ADMIN_ACCOUNT=admin
INITIAL_ADMIN_DOMAIN=example.com
INITIAL_ADMIN_PW=password
INITIAL_ADMIN_MODE=ifmissing

###################################
# Advanced settings
###################################

# Log driver for front service. Possible values:
# json-file (default)
# journald (On systemd platforms, useful for Fail2Ban integration)
# syslog (Non systemd platforms, Fail2Ban integration. Disables `docker-compose log` for front!)
# LOG_DRIVER=json-file

# Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME=mailu

# Default password scheme used for newly created accounts and changed passwords
# (value: BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT, MD5-CRYPT, CRYPT)
PASSWORD_SCHEME=BLF-CRYPT

# Header to take the real ip from
REAL_IP_HEADER=

# IPs for nginx set_real_ip_from (CIDR list separated by commas)
REAL_IP_FROM=

# choose wether mailu bounces (no) or rejects (yes) mail when recipient is unknown (value: yes, no)
REJECT_UNLISTED_RECIPIENT=

# Log level threshold in start.py (value: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET)
LOG_LEVEL=WARNING

###################################
# Database settings
###################################
DB_FLAVOR=sqlite

The important things about this mailu.env file:

  • SECRET_KEY must be replaced with a 16 bytes key that you need to generate (use your favourite tool for that)
  • DOMAIN must be adjusted to the domain you are going to use (example.com in this post)
  • HOSTNAMES must be adjusted to the domain you want to use to access all of the services (email.example.com in this post)
  • SITENAME must be adjusted to what you want the webmail to display
  • WEBSITE must be adjusted to one for your HOSTNAMES prefixed with the http scheme that will be used (https + email.example.com = https://email.example.com in this post)
  • INITIAL_ADMIN_ACCOUNT and INITIAL_ADMIN_DOMAIN are the configuration used to create the admin account, those two value will be used to generate the admin account (admin@example.com in this post)
  • INITIAL_ADMIN_PW should be changed for a more robust password

Now that the host is configuration ready, let’s continue with the DNS configuration.

DNS configuration

Basic configuration

Multiple DNS are required to get everything working:

  • A: email.example.com 600 IN A 1.2.3.4 (don’t forget to adjust with your public IPv4)
  • MX: example.com. 600 IN MX 10 email.example.com., if you have another MX record with a lower priority value,this means it will be preferred and should be kept for now and adjusted/deleted once the services are up

Once those DNS created, you can bring the services up with docker-compose up -d on your host.

Our first mailbox

After some seconds/minutes (depending on the time it takes to generate the letsencrypt certs), https://email.example.com should be accessible.

Access https://email.example.com/admin and connect using the admin account that was automatically created using the INITIAL_ADMIN_* variables from the mailu.env file. On the left, in the Administration section, select Mail domains (or go to https://email.example.com/admin/domain) and select New domain button located at the top right corner of the screen (or access https://email.example.com/admin/domain/create).

In the form, you can:

  1. select Domain name to create, in this post we will set example.com but you must adjust it to your own
  2. adjust Maximum user count, Maximum alias count and Maximum user quota to your needs for this domain
  3. if wanted you can Enable sign-up and add a Comment
  4. save and the domain will be added

Now return to the Mail domains page and in the Manage columns you can create a new user. Once created you can go to the webmail page https://email.example.com/webmail and connect using this newly created user to access your first mailbox.

Advanced configuration

Now that our mail server is up and running, we need to add more DNS configuration to protect our domain from spoofing or our mails being considered as spam by the other mail servers.

For this we will configure SPF and DKIM and use DMARC to enforce it.

SPF/DKIM/DMARC

Return to the Mail domains page and, in the Actions column, select the Details button. You will see here some of the DNS configuration that can/needs to be set.

The DNS MX entry line should have a green tick next to it. If not, you need to check your domain MX record (or wait a little since DNS propagation can take some time).

If you don’t have any line referring to DKIM, it means we need to generate the keys. At the top, select Regenerate keys to generate the keys for the DKIM configuration.

Once done, you will need to add all the mentionned DNS records:

  • DNS SPF entries, a TXT record like example.com. 600 IN TXT "v=spf1 mx a:email.example.com ~all" (if your MX record already point to email.example.com, you can delete the a:email.example.com configuration)
  • DKIM public key is not a DNS record, just the public key that we just generated and that will be used in the next entry
  • DNS DKIM entry, a TXT record like dkim._domainkey.example.com. 600 IN TXT "v=DKIM1; k=rsa; p=<DKIM public key>
  • DNS DMARC entry, a TXT record like _dmarc.example.com. 600 IN TXT "v=DMARC1; p=reject; rua=mailto:admin@example.com; ruf=mailto:admin@example.com; adkim=s; aspf=s" (the rua configuration means you will get aggregate reports at admin@example.com, ruf will make failure reports going to admin@example.com)

This is enough for the SPF/DKIM/DMARC configuration.

TLSA

In the Details view of your example.com mail domain, you can find a DNS TLSA entry. This DNS record is useful only in the case your domain DNS server is using DNSSEC to protect your domain. Most providers offer this option but it is not always enabled by default.

TLSA adds an additional layer of validation and verification for TLS connections by giving clients a way to verify the certificate received from a server by querying for its information thanks to this TLSA DNS record.

Once you made sure DNSSEC is activated, you can create a TLSA DNS record: _25._tcp.email.example.com. 86400 IN TLSA 2 1 1 <certificate fingerprint> (basically providing a TLSA protection for the 25/tcp port, which is the SMTP server).

Checks

In order to check that your configuration is correct:

  • you can use this free tool to make sure you are not seen as an open relay (if so, you need to shut everything down!)
  • you can use this other free tool to ensure you get a score > 90 for your SPF/DKIM/DMARC configuration
  • you can use this free tool (just send an email to the displayed email adress and then check the score) to check for a > 9.0 score
  • if you activated TLSA, you can use this other free tool to make sure your configuration is correct

Bonus 1: Traefik enters the chat

I am happy to see you here dear Traefik user. Looking at the configuration you might have thought “well, why do I even need this Nginx anyway?“. I know, I did too. The thing is, the Nginx server used in the front service in the docker-compose.yml file is required because it can be used a mail proxy. Also, it can handle an authentification delegation to a HTTP server, a thing that would require some developments since Traefik only provide a middleware for the HTTP routers (nothing - yet - for TCP routers). Finally, Traefik is not able to handle the STARTTLS email protocol command, required by the SMTP server to perform a secure connection. Because of all of this, the only way to be able to use Mailu with Traefik is by using a TCP router.

Here are the labels that needs to be added to the front service in the docker-compose.yml file (don’t forget to deactivate the 80/tcp and 443/tcp ports declaration):

  # Core services
  front:
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.mailu-http.rule=Host(`email.example.com`)"
      - "traefik.http.routers.mailu-http.entryPoints=web" # adjust to the entrypoint you defined for the 80/tcp entrypoint
      - "traefik.http.routers.mailu-http.priority=1001"
      - "traefik.http.services.mailu-http.loadBalancer.server.port=80"
      - "traefik.tcp.routers.mailu-https.rule=HostSNI(`email.example.com`)"
      - "traefik.tcp.routers.mailu-https.entryPoints=websecure" # adjust to the entrypoint you defined for the 443/tcp entrypoint
      - "traefik.tcp.routers.mailu-https.tls.passthrough=true"
      - "traefik.tcp.services.mailu-https.loadBalancer.server.port=443"
      - "traefik.docker.network=traefik" # adjust to the network you are using
    image: ghcr.io/${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}nginx:${MAILU_VERSION:-1.9}
    restart: always
    env_file: mailu.env
    networks:
      - mail
    logging:
      driver: json-file
    ports:
      #- "1.2.3.4:80:80" # handled by Traefik
      #- "1.2.3.4:443:443" # handle by Traefik
      - "1.2.3.4:25:25"
      - "1.2.3.4:465:465"
      - "1.2.3.4:587:587"
      - "1.2.3.4:110:110"
      - "1.2.3.4:995:995"
      - "1.2.3.4:143:143"
      - "1.2.3.4:993:993"
    volumes:
    - "/var/lib/mailu/certs:/certs"

“But you said we only need a TCP router…“. Well you are right, but if, like me, you are using Traefik and its ACME configuration to take care of all your TLS certificates you are going to have an issue. When you use the Traefik ACME configuration, a acme-http@internal Traefik service and router are created behind the hood and will swallow any HTTP request made to any /.well-known/acme-challenge/ endpoint. This will prevent the front service from Mailu to generate all the certs it needs. As a workaround, a HTTP router http-mailu is added with a priority of 1001. We also need to tell Traefik that its internal acme-http route needs to have a priority. For this, the following labels can be added to your Traefik service declaration:

    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.acme.entrypoints=web"  # adjust to the entrypoint you defined for the 80/tcp entrypoint
      - "traefik.http.routers.acme.rule=PathPrefix(`/.well-known/acme-challenge/`)"
      - "traefik.http.routers.acme.service=acme-http@internal"
      - "traefik.http.routers.acme.priority=1000

This configuration will make sure the acme-http@internal service is used by default and if any Traefik HTTP router is defined with a higher priority, it will take care of the ACME challenge itself.

Restart Traefik and then your Mailu services, and you will be good to go.

Bonus 2: configure MTA-STS

MTA-STS is another security protocol used to prevent some holes (man in the middle mostly) from the STARTTLS use.

To use MTA-STS, you will need to:

  1. add a CNAME DNS record mta-sts.example.com. 600 IN CNAME email.example.com.
  2. add a TXT DNS record _mta-sts.example.com. 600 IN TXT "v=STSv1; id=1" (for any change in the MTA-STS configuration, you will need to increment the id to cancel the cache of the other clients)
  3. serve a mta-sts.txt file from your mta-sts.example.com domain

The 3. requirement can be done using Mailu and its overrides capability. Next to your docker-compose.yml and mailu.env files, create a overrides/nginx folder and, in that folder, create a mta-sts.conf file with the following content:

      location ^~ /.well-known/mta-sts.txt {
        return 200 "version: STSv1
mode: enforce
max_age: 604800
mx: email.example.com\r\n";
      }

Once the mta-sts.conf (lets say its full path is /path/to/my/mailu/overrides/nginx/mta-sts.conf) file is created, adjust your docker-compose.yml file to consider this new Nginx configuration file:

  # Core services
  front:
    image: ghcr.io/${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}nginx:${MAILU_VERSION:-1.9}
    restart: always
    env_file: mailu.env
    networks:
      - mail
    logging:
      driver: json-file
    ports:
      - "1.2.3.4:80:80"
      - "1.2.3.4:443:443"
      - "1.2.3.4:25:25"
      - "1.2.3.4:465:465"
      - "1.2.3.4:587:587"
      - "1.2.3.4:110:110"
      - "1.2.3.4:995:995"
      - "1.2.3.4:143:143"
      - "1.2.3.4:993:993"
    volumes:
    - "/var/lib/mailu/certs:/certs"
    - "/path/to/my/mailu/overrides/nginx:/overrides" # don't forget to adjust /path/to/my/mailu

After that, and because Mailu is taking care of all the certs, we need to add the mta-sts.example.com domain in the HOSTNAMES variable from the mailu.env file:

...
# Subnet of the docker network. This should not conflict with any networks to which your system is connected. (Internal and external!)
SUBNET=192.168.203.0/24

# Main mail domain
DOMAIN=example.com

# Hostnames for this server, separated with comas
HOSTNAMES=email.example.com,mta-sts.example.com
...

In case you are using Traefik, you will need to adjust the labels to consider this mta-sts.example.com domain.

Then you would need to docker-compose stop front && docker-compose rm front && docker-compose up -d to make sure the new volume is considered.

Once all Mailu services are deployed and healthy, you can use curl to check that https://mta-sts.example.com/.well-known/mta-sts.txt is returning the MTA-STS configuration:

curl 'https://mta-sts.example.com/.well-known/mta-sts.txt'
version: STSv1
mode: enforce
max_age: 604800
mx: email.example.com

Finally, this free tool can be used to test your MTA-STS configuration is correct.

Bonus 3: migrate previous mailboxes using imapsync

In case you want to migrate some previous mailboxes to your brand new mailboxes, you can use the imapsync project.

I used the Docker version of the tool to perform multiple migrations that can be easily done using the following commande line:

docker run --rm gilleslamiral/imapsync imapsync  --host1 old.example.com  --user1 test1  --password1 'secret1' --host2 email.example.com --user2 test1@example.com --password2 'secret2'

You would need to 1st create the new mailboxes in your email.example.com admin panel before being able to use it as a destination mailbox (host2, user2, password2 parameters).

© Copyright 2023 by Sorriaux Software.
Built with ♥ using Astro & SolidJS.