Use Docker-MailServer to Build Self-hosted Mail Server

Advanced but detailed instruction for self-hosted mail server

Use Docker-MailServer to Build Self-hosted Mail Server

1. Background

It’s kinda cool to have your mailbox with your own domain, such as [email protected]. Meanwhile, as long as you are the owner of your domain, the easiest approach is to have a third party mail server with a custom domain configuration. Most of the services charge a little bit, like Google Workspace, Amazon Workmail, etc. It doesn’t matter but more importantly, you don’t have the absolute privilege of the managed services.
Actually I tried to build and deploy a self-hosted mail server in 2019. Due to my very limited knowledge, it failed. With the timing of ChatGPT suddenly becoming popular, I rediscovered my enthusiasm for dev recently. So I wanna retry it and this time it was a one-shot success.

2. Introduction

From the name itself you can tell that this docker-mailserver is deployed with Docker. Docker-mailserver has no built-in webmail, which means it is a pure mail server and does not occupy port 80/443. This has a great advantage that we have both various other services and mail service on the same server at the same time.
There are three reasons why I recommend this mail server that can be deployed directly with Docker:

    1. Simple / Convenient. You have to know that manually configuring such a fully functional mail server may not be done in one night, and even a slight mistake may cause you tons of trouble.
    1. Secure. As a mail server, self-use is not a big deal. But if it is a public service, with all kinds of spam & virus mail, you will find that the original mail server is really difficult to maintain.
    1. This is the top reason for me personally. I think it is a waste to have a dedicated mail server. So using Docker like this, after the deployment is complete, I can also deploy other applications. Although I’m using Nginx as a web server and SNI function to re-use the port(s), Nginx still have to take charge of port 80/443. I will have no worries if I use docker-mailserver.
      In this article, I will show you how to use Docker-MailServer to build your own self-hosted mail server.

3. System Basics

  • Linux server, whether it’s AMD64 or ARM64
  • Docker and docker-compose
curl -sSL https://get.docker.com/ | sh
systemctl enable docker
curl -L https://github.com/docker/compose/releases/download/1.29.2/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose

Note: I’m still using docker-compose v1, 1.29.2 is the last version of v1. If you prefer to use docker-compose v2, please refer to docker-compose release page and carefully read the difference.

3. Firewall

In order to use mail service, you have to open the firewall of certain ports of your server.

Port Type Purpose
25 TCP explicit TLS => STARTTLS
143 TCP explicit TLS => STARTTLS
465 TCP implicit TLS
587 TCP explicit TLS => STARTTLS
993 TCP implicit TLS

4. DNS

DNS resolve configuration, take CloudFlare as an example:

Type Name Content
A mail <mail_server_ip>
A
(optional)
@
(optional)
<app_server_ip>
(optional, can be different
from mail server ip)
MX @ mail.example.com
(random priority is fine.
10/20/50 is common)
TXT _dmarc v=DMARC1; p=reject;
rua=mailto:[email protected]
TXT mail._domainkey v=DKIM1; h=sha256; k=rsa;
p=?????
(to be filled out later)
TXT @ v=spf1 mx ~all

Note1: If you are using CloudFlare, DO NOT turn on the proxied for the first row, which is the line of Type = A / Name = mail / Content = <mail_server_ip>. The icon should be GREYED OUT.
Note2: From above, [email protected] is to be changed to an email address that you can receive emails normally.
Note3: Content of mail._domainkey will be filled out in later steps.

5. Prerequisites

5.1 Pull the image

docker pull docker.io/mailserver/docker-mailserver:latest

5.2 Get the script & env

mkdir mailserver && cd mailserver
wget https://raw.githubusercontent.com/docker-mailserver/docker-mailserver/master/setup.sh
chmod a+x ./setup.sh
wget https://raw.githubusercontent.com/docker-mailserver/docker-mailserver/master/docker-compose.yml
wget https://raw.githubusercontent.com/docker-mailserver/docker-mailserver/master/mailserver.env

5.3 Check ports

Find out which applications currently occupy port 25/143/465/587/993 on the server

netstat -nltp

If Postfix is enabled by default, we should disable it:

systemctl stop postfix
systemctl disable postfix

6. Certificate with Certbot

In this article we will use Let’s Encrypt with certbot. Feel free to use other certificate with other authorities. However you cannot use CloudFlare Origin Certificates with the proxied status ON.

  • Install certbot
apt install certbot
  • Run certbot in test mode (to avoid temporary ban due to misconfiguration)
certbot certonly --dry-run

You will probably see below. Remember to select standalone and enter the correct domain (with mail.)

Saving debug log to /var/log/letsencrypt/letsencrypt.log

How would you like to authenticate with the ACME CA?
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1: Nginx Web Server plugin (nginx)
2: Spin up a temporary webserver (standalone)
3: Place files in webroot directory (webroot)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Select the appropriate number [1-3] then [enter] (press 'c' to cancel): 2
Please enter the domain name(s) you would like on your certificate (comma and/or
space separated) (Enter 'c' to cancel): mail.example.com
  • If the result above is successful, run the command again without dry-run parameter
certbot certonly
  • Location of you certificate
    The certificate will be saved at /etc/letsencrypt/archive/mail.example.com/
    Important: Here is the tricky part. Since the docker container will read the files in the local volume, and the Let’s Encrypt certs under “live” folder is a symbolic link, which means if you point the container to /etc/letsencrypt/live/mail.example.com/, it cannot reach to the original certs themself. Even if you finish all the setting, some error message will pop up, like:
mailserver    | [  ERROR  ]  TLS Setup [SSL_TYPE=manual] | File /tmp/ssl/privkey.pem or /tmp/ssl/fullchain.pem does not exist!
mailserver    | [  ERROR  ]  Shutting down

To solve this, we can point the container to upper level, which includes the “archive” folder and create two new symbolic links:

cd /etc/letsencrypt
ln -s ./live/mail.moogpt.com/privkey.pem privkey.pem
ln -s ./live/mail.moogpt.com/fullchain.pem fullchain.pem

This step is to ensure the auto-renew of Let’s Encrypt.

7. Configuration

7.1 docker-compose.yml

nano docker-compose.yml

It’s recommended to add CloudFlare DNS into the yml dns: 1.1.1.1
The complete yml is like (example):

services:
  mailserver:
    image: docker.io/mailserver/docker-mailserver:latest
    container_name: mailserver
    # If the FQDN for your mail-server is only two labels (eg: example.com),
    # you can assign this entirely to `hostname` and remove `domainname`.
    hostname: mail.example.com # replace with your domain
    domainname: example.com # replace with your domain
    dns: 1.1.1.1 # This line is newly added and recommended
    env_file: mailserver.env
    # More information about the mail-server ports:
    # https://docker-mailserver.github.io/docker-mailserver/latest/config/security/understanding-the-ports/
    # To avoid conflicts with yaml base-60 float, DO NOT remove the quotation marks.
    ports:
      - "25:25"    # SMTP  (explicit TLS => STARTTLS)
      - "143:143"  # IMAP4 (explicit TLS => STARTTLS)
      - "465:465"  # ESMTP (implicit TLS)
      - "587:587"  # ESMTP (explicit TLS => STARTTLS)
      - "993:993"  # IMAP4 (implicit TLS)
    volumes:
      - ./docker-data/dms/mail-data/:/var/mail/
      - ./docker-data/dms/mail-state/:/var/mail-state/
      - ./docker-data/dms/mail-logs/:/var/log/mail/
      - ./docker-data/dms/config/:/tmp/docker-mailserver/
      - /etc/localtime:/etc/localtime:ro
      - /etc/letsencrypt/:/tmp/ssl:ro # Add this line. It will be used later.
    restart: always
    stop_grace_period: 1m
    cap_add:
      - NET_ADMIN
    healthcheck:
      test: "ss --listening --tcp | grep -P 'LISTEN.+:smtp' || exit 1"
      timeout: 3s
      retries: 0

7.2 mailserver.env

Within this env file, you must modify the following:

  • PERMIT_DOCKER
# Set different options for mynetworks option (can be overwrite in postfix-main.cf)
# **WARNING**: Adding the docker network's gateway to the list of trusted hosts, e.g. using the `network` or
# `connected-networks` option, can create an open relay
# https://github.com/docker-mailserver/docker-mailserver/issues/1405#issuecomment-590106498
# The same can happen for rootless podman. To prevent this, set the value to "none" or configure slirp4netns
# https://github.com/docker-mailserver/docker-mailserver/issues/2377
#
# none => Explicitly force authentication
# container => Container IP address only
# host => Add docker container network (ipv4 only)
# network => Add all docker container networks (ipv4 only)
# connected-networks => Add all connected docker networks (ipv4 only)
PERMIT_DOCKER=network
  • SSL_TYPE
# Please read [the SSL page in the documentation](https://docker-mailserver.github.io/docker-mailserver/latest/config/security/ssl) for more information.
#
# empty => SSL disabled
# letsencrypt => Enables Let's Encrypt certificates
# custom => Enables custom certificates
# manual => Let's you manually specify locations of your SSL certificates for non-standard cases
# self-signed => Enables self-signed certificates
SSL_TYPE=manual
  • SSL_CERT_PATH and SSL_KEY_PATH
# These are only supported with `SSL_TYPE=manual`.
# Provide the path to your cert and key files that you've mounted access to within the container.
SSL_CERT_PATH=/tmp/ssl/fullchain.pem
SSL_KEY_PATH=/tmp/ssl/privkey.pem
# Optional: A 2nd certificate can be supported as fallback (dual cert support), eg ECDSA with an RSA fallback.
# Useful for additional compatibility with older MTA and MUA (eg pre-2015).
SSL_ALT_CERT_PATH=
SSL_ALT_KEY_PATH=

Note: SSL_CERT_PATH and SSL_KEY_PATH are the paths within the container, not local volume.

8. Final Settings

  • Create an email account. You can replace admin with whatever you like. Don’t forget to change the password.
cd /your/path/mailserver
./setup.sh email add [email protected] password
  • Generate a DKIM key
./setup.sh config dkim

The resolve record of DKIM is saved in:

/your/path/mailserver/docker-data/dms/config/opendkim/keys/moogpt.com/mail.txt
  • Fill out mail._domainkey in DNS (in Step 4)
    The content of mail.txt is something like below (example, link the parts together):
mail._domainkey	IN	TXT	v=DKIM1; h=sha256; k=rsa; 
	 p=MIICIjANBgkfdsjanfjkds34rfrsnfjl.gKCAgEAme22c10H25DwMw90ob7RYozozQ2ONchrLGFWb8gulxxMcOpWo+tmAjFob6Hn/7u49nEW28W3EGfnisdfuvndafijlnvfCHuejKzEeE0LMOTpzRTDeCQkLlSKhzfsdanfj4irjfgvierfdnviofdfakRwtObe+L8HZeUZnBDXsCAh/lmgI3bRQQZkqcfRVquwh132hkgvjkhyBAJiKrevbuqTPHOKioWMFsg0EAj7UfenarufoidsvndfijonvjifYeRgZHC1s6jsZjBQig4gvyOCL0eP4fwi4eriognveourafisdfjvECRajAXgwSOaEjvV++CYImiF9T9tnc7FppcFyJ6KhYRaoF+NbTz8GYQ3gzTKh2hxmoeFSsa+YP7G/DD0Ru2yLkqKy8QCMyqTgJHpITD0JjmYMBxwjFrZRGKnp5Qo8Ssd/U5x0AGV698ePker08ghdsh80Mvmnfjonuvh9rjfg03oPxBwtz0/3ZGEQ94qRMN7bx93oynRD/XKBqgrK9R0LP1AGQxVkT+1aWVDhhCLQ9PL6Z9oqkeuXvcGcdUvGg4Ww5f8DowGcfadosufhBgQ9/WthcIwWCtVPgtIrXHuKTgWfjnfwX6vkert0igfsaeijrsdnfuionerU+M64Jst0oe5+FdTVNaOVPkCAwEAAQ==

9. Bring Up the Container

cd /your/path/mailserver
docker-compose up -d

10. Client Setting

For incoming mail server:

For outgoing mail server (SMTP, iPhone for example):

  • Host Name: mail.example.com
  • User Name: [email protected]
  • Password: password
  • Use SSL: on
  • Authentication: Password
  • Server Port: 465

Both incoming and outgoing function have been tested with the settings in this article.


Copyright statement: Unless otherwise stated, all articles on this blog adopt the CC BY-NC-SA 4.0 license agreement. For non-commercial reprints and citations, please indicate the author: Henry, and original article URL. For commercial reprints, please contact the author for authorization.