Wether you want to have control over your own mails, not share data with an unknown 3rd party, not pay for several accounts when you could just pay for one server or you just wanna waste some time with another project: there are a lot of reasons to run your own mail server. But actually setting up a mail server that works can be tricky.

In this article we explore how to set up a self hosted mail server that can serve multiple mail domains on the internet. We run all applications like the required mail transfer agent (MTA) and IMAP server on one machine. The users we want to receive mails for and send mails from don’t have an account on that machine. Rather, we store their user data in a database that the other applications can access. We run the mail server processes in docker containers to make the setup more modular and get rid of the dependency to the underlying operating system and its library versions.
I already show how to create a docker image for the mail transfer agent (MTA) Postfix and IMAP Server Dovecot in other posts. We will not go through that information here as it would make this article too long and cluttered. The config settings in this article are geared towards these docker images but you can of course adapt them to fit other installations if you want to. For the sake of brevity there is no section on spam filtering or mail frontends. We will pick up these topics in future posts.
Also: This posts mostly focuses on the configuration aspect and less on the technical details of a mail server and the protocols it uses.

A word of advice: for your mail server to accept mail from other domains you have to expose it TO THE INTERNET. If you now think: well, that sounds like a dumb idea - it probably is. So take running a mail server seriously, do your updates and learn how to secure your server and data. Or to put it another way:

With that said: let’s create our mail server!

Preparations

In addition to setting up the necessary docker containers on our server we have to point our domain(s) to the mail server.

We assume that you have a working server with docker installed and can access it with Ansible. If you need help setting up Ansible, check out the first paragraphs of how to deploy and configure ApacheDS. You will later need the LDAP server anyway.

Domain Setup

For our mail server to be able to receive mails from other mail servers on the internet we need to make it known to anyone looking for it. Other mail servers will infer where mails for us should be sent to by looking at the domain part of the To mail address (the part behind the @) in the mail to be sent.
Let’s say our domain is mydomain.com. The other mail servers will look for a MX DNS record in your domain DNS entry. That’s where one normally expects a reference to the mail server for your domain. If they don’t find a MX record, they will try the A record of your domain.
So, if you want to run your mail server on the machine your domain A record points to you don’t need to do anything. But if you want to use another machine as mail server or have a spare mail server then you need to set a MX record.

Example:

# Domain
mydomain.com
# A record
mydomain.com. 600 IN A 11.222.333.44
# MX record
mydomain.com. 600 IN MX 10 mx.mydomain.com.

So for your domain mydomain.com mails should go to mx.mydomain.com which in turn is the domain name of your mail server.

You have to set the records in the UI of your domain name registrar. There are countless offerings available on the internet, so it is hard to recommend a specific service. Maybe the one where you bought your domain?

Ansible and Docker Setup

In addition to the general Ansible setup we use an internal network bridge to connect the user database, MTA and IMAP server. Here is an Ansible task to create said network:

- name: Ensure docker internal network
docker_network:
name: internal
appends: yes
driver_options:
com.docker.network.bridge.name: docker-internal
ipam_config:
- subnet: 172.200.0.0/16

Also, we assume that you have your certificates at:

/etc/ssl/certs/mx.mydomain.com.crt
/etc/ssl/private/mx.mydomain.com.key

Now you can use the snakeoil certificate that most systems have as default but you should really get a proper one either through Let’s Encrypt or another certification authority.

User database

Our mail server needs to know for which addresses it shall send and receive mails. There are a number of ways to configure this – most importantly: a list of users which we store in the LDAP server ApacheDS. There is already a tutorial on how to deploy and configure ApacheDS including a docker repository for ApacheDS. But here is a quick ansible task that starts ApacheDS and also connects it to our internal bridge:

---
- name: Ensure apacheds container
docker_container:
name: apacheds
image: gevattergaul/apacheds:latest
published_ports:
- "127.0.0.1:389:389"
networks:
- name: internal
volumes:
- /srv/data/apacheds/data:/opt/apacheds/instances
...

We use a new partition in ApacheDS for the mail server data. You can add a new partition via the server configuration (right click on your connection -> open configuration) in the partitions tab. After adding the partition you have to restart ApacheDS or the partition won’t show up.
The child entry people of type organizationalUnit in the new partition then contains the mail server users. The mail server user entries have type inetOrgPerson.

Our schema:

# Partition
o=bored
# Users
ou=people,o=bored
# Example User
cn=john+sn=doe,ou=people,o=bored

The user entries should have a SSHA256 userPassword attribute. You can pick a different schema but for the remainder of this article we assume you use the same schema as shown above.

IMAP Server

Now we know where to find the users we want to send and receive mails for. Let’s think about where to store their mails. Our software of choice is the IMAP server Dovecot. The following config is written for the dovecot docker image recipe but you can adapt it to your docker image of choice or even a bare metal installation.

There are a number of ways to organize the dovecot config file(s). You can map the following config snippets as a single dovecot config file /etc/dovecot/dovecot.conf to the config diretory.

Dovecot Protocol and Service Configuration

First we have to tell Dovecot which protocols to support. Obviously we want IMAP so that the user can access their mails from a mail client. We will also enable LMTP. This is the protocol we use to locally deliver mails to Dovecot.

protocols = imap lmtp

We want Dovecot to listen for LMTP connections on port 24:

service lmtp {
inet_listener lmtp {
port = 24
}
}

But LMTP can do more: we also can ask Dovecot through LMTP wether a user exists in Dovecot’s user database. We will use this feature later in Postfix. For now, we want Dovecot to listen for auth requests on port 10010:

service auth {
inet_listener {
port = 10010
}
}

Another basic setting: when Dovecot has to send mail use this address:

postmaster_address = postmaster@mydomain.com

Dovecot User Config

Dovecot has to know which users shall be allowed to access the mail storage. Dovecot splits user data from password data but we will just refer to LDAP in both cases:

passdb {
driver = ldap
args = /etc/dovecot/dovecot-ldap.conf
}

userdb {
driver = ldap
args = /etc/dovecot/dovecot-ldap.conf
}

We put the LDAP connection information into a separate file which we discuss in the next section.

Dovecot LDAP Config

The LDAP config has to tell Dovecot where to find the LDAP server and how to access it as well as where to find the user and password data in the LDAP tree.

The following config is tailored to the LDAP schema we create above.

/etc/dovecot/dovecot-ldap.conf
# Use your LDAP server address
hosts = apacheds

auth_bind = yes

# The node with people we create earlier
base = ou=people,o=bored

scope = subtree

# So we can find our users based on the given name and ignore the surname
user_filter = (&(objectClass=inetOrgPerson)(cn=%n))

# Same as for the user filter
pass_filter = (&(objectClass=inetOrgPerson)(cn=%n))

# Attributes and filter to get a list of all users
iterate_attrs = cn=user
iterate_filter = (objectClass=inetOrgPerson)

# Must match the kinds of password hashes we use on the users in LDAP
default_pass_scheme = SSHA256

Dovecot Auth Config

Ok, but users still have to log in. We allow the plain and login auth mechanisms. We also set the default realm to our domain so the user can just use their username instead of their full mail address when connecting to the Dovecot. You might not want to allow this when you serve multiple domains but it’s handy when you only serve one domain.

auth_mechanisms = plain login

auth_default_realm = mydomain.com

Dovecot Logging Config

If you have problems you can always turn on logging. Set the following switches to yes to get more verbose output.
We run Dovecot in a docker container and therefore direct the log output to /dev/stderr so that it will appear in the docker logs instead in a file in the container.

# What to log verbose
auth_verbose = no
auth_debug = no
mail_debug = no

# If you run a docker container
log_path = /dev/stderr

Configure Mail Storage in Dovecot

Dovecot can store mails in various formats. We choose Maildir because of its simplicity. With Maildir every mail is a separate file on the harddrive. Hopefully that will make recovery easier in the unavoidable event of total desaster. But depending on your workload you might choose another of their mailbox formats.

We define a home directory path for each user that comprises the mail domain of the user and the username. The home directory will hold all mail files for that user plus potential script and other files. Then we set the mail location to a Maildir folder in the users home directory. The mail_location string also encodes the mailbox format. In this case: Maildir.

mail_home = /var/spool/mail/vhosts/%d/%n
mail_location = maildir:~/Maildir

We also tell Dovecot to use the mail folder inbox as inbox.

namespace inbox {
inbox = yes
}

If you run Dovecot on a different user or group from the default you have to tell Dovecot about it so it can access the mail directories properly. The following values work for the dovecot docker image.

mail_uid = dovecot
mail_gid = dovecot
first_valid_uid = 90
last_valid_uid = 90

If you want Dovecot to compress the mail files before saving them to disk, enable the zlib plugin:

# Use the $mail_plugins variable to add to the existing mail_plugins value
mail_plugins = $mail_plugins zlib

plugin {
# The most modern algorithm currently
zlib_save = zlib
}

Dovecot SSL Options

Users should not connect to Dovecot using insecure connections as that exposes their mails and passwords. To secure the connection we configure Dovecot to use SSL.
The SSL configuration is straight forward. You just turn it on and tell Dovecot which certificate to use.

ssl = yes
ssl_cert = /etc/dovecot/my_cert.pem
ssl_key = /etc/dovecot/my_key.pem

Dovecot Default Directories

Ok, let’s finally mark some more folders as special so that Dovecot and mail clients know where to put drafts, junk, trash and so on:

### Mailboxes
namespace inbox {
mailbox Drafts {
special_use = \Drafts
}
mailbox Junk {
special_use = \Junk
}
mailbox Trash {
special_use = \Trash
}

# For \Sent mailboxes there are two widely used names. We'll mark both of
# them as \Sent. User typically deletes one of them if duplicates are created.
mailbox Sent {
special_use = \Sent
}
mailbox "Sent Messages" {
special_use = \Sent
}
}

Dovecot Setup With Ansible

This is an Ansible role to get Dovecot up and running using the Dovecot docker image. We mount the directory containing the mail and config files to directories on the host system. You might wanna opt for Docker volumes if necessary.

---
- name: Ensure directories
file:
name: "{{ item }}"
state: directory
loop:
- /srv/data/dovecot/data
- /srv/data/dovecot/config

- name: Render dovecot config file
template:
src: "files/dovecot/{{ item }}"
dest: "/srv/data/dovecot/config/{{ item }}"
loop:
- dovecot.conf
register: dovecot_config

- name: Copy dovecot ldap config file
copy:
src: files/dovecot/dovecot-ldap.conf
dest: "/srv/data/dovecot/config/dovecot-ldap.conf"
register: dovecot_ldap_config

- name: Ensure dovecot container
docker_container:
name: dovecot
image: gevattergaul/dovecot:2.3.19.1-r0-3
recreate: "{{ dovecot_config.changed or dovecot_ldap_config.changed}}"
networks_cli_compatible: yes
published_ports:
- "0.0.0.0:993:993"
networks:
- name: internal
volumes:
- "/srv/data/dovecot/data:/var/spool/mail"
- "/srv/data/dovecot/config:/etc/dovecot"
- /etc/ssl/certs/mx.mydomain.com.crt:/etc/dovecot/my_cert.crt
- /etc/ssl/private/mx.mydomain.com.key:/etc/dovecot/my_cert.key
...

Postfix

A mail transfer agent (MTA) receives mails from the internet or sends mails from our users to their destination. The MTA is exposed to the internet and should be configured very carefully to not become an accidental spam relay or be abused otherwise. Users that want to send mails through our MTA must authenticate. Incoming mail must be checked for various criterias before being accepted. There are a lot of MTA implementations. We will use Postfix for this post.

The following config is written for the Postfix docker image recipe.

Enable Postfix services

For most capabilities Postfix has it will run a service. We can control what services Postfix will run through its /etc/postfix/master.cf config file.
Let’s check that our most important services are configured. For incoming mails from other MTAs we need to enable the smtp service:

smtp      inet  n       -       n       -       -       smtpd

And for mails that our users want to send we use the submission service. Here we force SSL through an extra option to make sure the user authenticates securely.

submission inet n       -       n       -       -       smtpd
-o smtpd_tls_security_level=encrypt
-o milter_macro_daemon_name=ORIGINATING

If you are using the Postfix docker image the rest of the master.cf is fine.

Domain Setup

The rest of the config settings go into /etc/postfix/main.cf. First we tell Postfix what Domain it is running from and what its surrounding network looks like.

mydomain = mydomain.com
myhostname = mx.$mydomain

mynetworks = 127.0.0.0/8, localhost

And because we want to serve mails for potentially multiple domains, we restrict normal incoming mails to the localhost:

# 172.200.0.0/16 is anything from our docker bridge
mydestination = localhost.$mydomain, 172.200.0.0/16, localhost

Also we don’t want to relay mails for other MTAs. So any mail that arrived via SMTP either goes to Dovecot or is rejected.

relay_domains =

If we send mail we want it to appear to come from here:

myorigin = $mydomain

And if someone connects to us, greet them with:

smtpd_banner = $myhostname ESMTP

Virtual Domain(s)

All domains that we actually serve will be virtual domains. That means that our server will accept mails for these domains although it might not be its own domain. We pass a list of all domains we want to serve and that we want to send them to dovecot.

virtual_mailbox_domains = $mydomain
virtual_transport = lmtp:dovecot:24

If we have any virtual users that have alias addresses, we can specify a file for that:

virtual_alias_maps = lmdb:/etc/postfix/virtual

That file will look like this:

/etc/postfix/virtual
admin@mydomain.com postmaster@mydomain.com

Which means all mails to admin@mydomain.com will be delivered to postmaster@mydomain.com

User Authentication

To prevent our MTA from becoming a spam relay we require users that want to send mail through us to authenticate. We can provide Postfix with an LDAP connection config so that it takes the user information from ApacheDS like Dovecot does. But we also can direct Postfix to ask Dovecot wether a user exists or not through LMTP. That way we don’t have to replicate the LDAP connection config.
Also we force users to use TLS to connect to the submission port.

smtpd_sasl_type = dovecot
# That's the port we configure in the dovecot service auth section
smtpd_sasl_path = inet:dovecot:10010
smtpd_sasl_auth_enable = yes
smtpd_sasl_tls_security_options = noanonymous
smtpd_tls_auth_only = yes
smtpd_tls_ciphers = high

We later enable the user check through dovecot using the reject_unverified_recipient policy.

SSL

For everyone who wants to send mails to our users we enable opportunistic encryption: if they want it, they can use it. We also reuse the certificates we use for dovecot. It’s the same domain after all.

smtpd_tls_cert_file = /etc/postfix/my_cert.crt
smtpd_tls_key_file = /etc/postfix/my_key.key
smtpd_tls_security_level = may
smtp_tls_security_level = may

Logging

We are running postfix in a docker container so we want all output to go to stdout so docker logs can pick it up.

### logging and debugging: docker friendly logging and minimal error output
notify_classes = resource, software
maillog_file = /dev/stdout

Libraries

Postfix on Alpine 3.13 and later will no longer support BTree and Hash files. Instead, use lmdb files like in the Postfix docker image. But we have to enable the lmdb library in a special file first:

/etc/postfix/dynamicmaps.cf.d/lmdb
lmdb	postfix-lmdb.so	dict_lmdb_open	mkmap_lmdb_open

Security

Right now we havent’t really restricted anyone from sending mails through our mail server yet, so we will do that now. Postfix actually requires you so specify some policies that shall prevent it from beeing an open relay, which means that anyone could send mails from our server.

Any of these policies might work for you or accidentially restrict the wrong users or other MTAs from delivering mail. You should check your mail logs to see if there is any unexpected behaviour. For me the following configuration seems to work.

First, we require some basic stuff:

### Other MTAs must use a proper HELO as specified in RFC5321
smtpd_helo_required = yes
# Disables harvesting mail addresses
disable_vrfy_command = yes
# Reject mail only after RCPT TO, improves compatibility
smtpd_delay_reject = yes

Now for who is allowed to send mail. We want clients from our local network and our users to be able to send mail to the outside and to other users on our server. Other MTAs shall be able to send mails to users on our server.
Again: we use Dovecot to do the user checks for us using the reject_unverified_recipient check.

Postfix will do the following checks in order for every mail it receives. It will perform the checks after it receives the recipient in the mail text. If you know SMTP you might think: why not do them earlier and use smtpd_helo_restrictions or smtpd_sender_restrictions? Well, postfix will receive the mail up to the recipient anyway for performance reasons, so we can as well check them with more information available.

smtpd_recipient_restrictions =
# allow clients from our local network
permit_mynetworks
# allow authenticated users
permit_sasl_authenticated
# reject hosts without fqdn hostname
reject_non_fqdn_helo_hostname
# reject mails without fqdn sender
reject_non_fqdn_sender
# reject mails without fqdn recipient
reject_non_fqdn_recipient
# reject mails when we are not the final sender domain
reject_unknown_sender_domain
# reject mails to domains we don't host
reject_unknown_recipient_domain
reject_unauth_destination
# reject mails with malformed HELO
check_helo_access regexp:/etc/postfix/helo_checks
# reject mails from IPs that are not plausible
check_sender_mx_access cidr:/etc/postfix/bogus_mx
# allow mails to special addresses
check_recipient_access lmdb:/etc/postfix/roleaccount_exceptions
# reject mails to users we don't host
reject_unverified_recipient
# finally, start reading the rest of the mail
permit

We will also do some additional checks for the data part of the mail:

smtpd_data_restrictions =
# reject the mail when the client sends SMTP commands out of order
reject_unauth_pipelining
# reject null sender addresses
reject_multi_recipient_bounce
permit

Note: reject_multi_recipient_bounce might cause problems because there are legitimate use cases for null senders. But I never had these problems, so I continue to use it.

We reference some files in the smtpd_recipient_restrictions above:

If a client presents with one of these identities, it’s spam:

/etc/postfix/helo_checks
/^mx\.mydomain\.com$/                    550 Don't use my hostname.
/^mydomain\.com$/ 550 Don't use my hostname.
/^11\.222\.333\.44$/ 550 Don't use my IP address.
/^\[11\.222\.333\.44\]$/ 550 Don't use my IP address.
/^[0-9.]+$/ 550 Your client is not RFC 2821 compliant.

Here we just list some IP ranges that cannot be valid MX servers, because they would be on our network or us:

/etc/postfix/bogus_mx
# invalid sender IPs

# IPv4
0.0.0.0/8 550 Local Broadcast
1.0.0.0/8 550 IANA reserved
172.200.0.0/16 550 Local Network
127.0.0.0/8 550 localhost
224.0.0.0/4 550 Multicast Network

# IPv6
::/128 550 No Address
ff00::/8 550 Multicast
fec0::/10 550 Site Local Unicast
fc00::/7 550 Unique Local Unicast

We accept some special addresses that other mail admins would use to contact us in case anything is wrong.

/etc/postfix/roleaccount_exceptions
postmaster@     OK
abuse@ OK
webmaster@ OK

Postfix Deployment With Ansible

Now that we got our config, here is an Ansible role to deploy to Postfix as docker container to your server. Just keep the config files you didn’t touch. Reuse the SSL certificate from Dovecot.

---
- name: Ensure directories
file:
name: "{{ item }}"
state: directory
loop:
- /srv/data/postfix/config
- /srv/data/postfix/config/dynamicmaps.cf.d

- name: Render postfix main config file
template:
src: files/postfix/main.cf.j2
dest: /srv/data/postfix/config/main.cf
register: postfix_main_config

- name: Copy plain postfix config files
copy:
src: "files/postfix/{{ item }}"
dest: "/srv/data/postfix/config/{{ item }}"
loop:
- aliases
- bogus_mx
- dynamicmaps.cf
- dynamicmaps.cf.d/ldap
- dynamicmaps.cf.d/lmdb
- helo_checks
- master.cf
- postfix-files
- roleaccount_exceptions
- virtual
register: postfix_static_config

- name: Ensure postfix container
docker_container:
name: postfix
image: gevattergaul/postfix:3.7.3-r0-0
recreate: "{{ postfix_main_config.changed or postfix_static_config.changed}}"
networks_cli_compatible: yes
published_ports:
- "0.0.0.0:25:25"
- "0.0.0.0:465:465"
- "0.0.0.0:587:587"
networks:
- name: internal
volumes:
- /srv/data/postfix/config:/etc/postfix
- /etc/ssl/certs/mx.mydomain.com.crt:/etc/postfix/my_cert.crt
- /etc/ssl/private/mx.mydomain.com.key:/etc/postfix/my_cert.key
...

Final Thoughts

Ok, that was a lot of config files. I said it before: running a mail server isn’t easy. But if you want to do it I hope I was able to provide a starting point.