Maddy is an all-in-one open source email server that combines a powerful SMTP and IMAP capabilities as well as SPF, DKIM and DMARC. It's significantly easier to set up compared to Postfix and Davcot.
Maddy is recommended as a small-size email server. It is extremely light weight and can be run also by less capable Edge devices, such as Raspberry Pi.
This guide walks you step by step until you get a fully working Maddy email server on The Edge.
Make sure that all of the mandatory prerequisites have been met before progressing further.
Verify that port 25 of the Internet Gateway is open and your VPS provider does not block it.
People on the Internet even maintain lists of VPS providers that allow for communication through port
25.
It is often the case that the whole range of IP addresses of a given VPS hosting provider could be blacklisted. There are also many blacklists. This said it hurts email deliverability a lot, if the IP addresses of the Internet Gateway are blacklisted in any way on one or more blacklists. There are services on the Internet, which help one figure it out whether this is the case and how bad the situation is. There are also kind self hosters sharing insights about their experience with some VPS hosting providers that can be helpful. Choose a VPS provider for Internet Gateways serving email with extreme care.
| Name | Source | Description | 
|---|---|---|
| <CONTAINER_NAME> | User input | The name of the Linux container where the app will be run. | 
| <DB_PASSWORD> | User input | The password for logging in with the maddy database user in the database. | 
| <IP_ADDRESS__EDGE_SERVER_VPN> | IP address in VPN | The IPv4 address of the the Server on The Edge in the VPN. | 
| <ROOT_PWD__CONTAINER> | User input | The password of the root user in the container. | 
| <IP_ADDRESS__CONTAINER> | Generated | The IPv4 address of the contaier run by the server on the Edge. | 
| <IPV4_ADDRESS_INET_GW> | ip a on the Internet Gateway | 
The public and static IPv4 address of the Internet Gateway. | 
| <IPV6_ADDRESS_INET_GW> | ip a on the Internet Gateway | 
The public and static IPv6 address of the Internet Gateway. | 
| <PRIMARY_DOMAIN> | User input | The host for the A record created with your domain registrar for @, e.g. myowndomain.net. | 
| <PRIMARY_DOMAIN_DKIM_KEY> | In the Maddy container running on the server on The Edge execute cat /var/lib/maddy/dkim_keys/<PRIMARY_DOMAIN>_default.dns after the maddy.service has been started | 
The DKIM key for <PRIMARY_DOMAIN>. | 
| <MAIL_DOMAIN_DKIM_KEY> | In the Maddy container running on the server on The Edge execute cat /var/lib/maddy/dkim_keys/mail.<PRIMARY_DOMAIN>_default.dns after the maddy.service has been started | 
The DKIM key for mail.<PRIMARY_DOMAIN>. | 
| <SSH_PUB_KEY__CONTAINER> | In the Maddy container running on the server on The Edge execute cat ~/.ssh/id_rsa.pub after the ssh-keygen has finished execution. | 
The SSH public key for the root user in the Maddy container. | 
| <CERTBOT_TLS_DIR_PATH__INET_GW> | certbot will print the paths to the fullchain.pem and privekey.pem files. One can easily deduce the path. | 
The path to the directory on the file system of the Internet Gateway where certbot stores the certificates for mail.<PRIMARY_DOMAIN>. | 
Run the following commands on your server on the Edge.
incus launch images:debian/bookworm/cloud <CONTAINER_NAME>
incus exec <CONTAINER_NAME> bash
You may need to change the default MTU for HTTPS connections to function. Run
ip link set dev eth0 mtu 1000if you findapt updateorapt upgradestuck for long time. This setting is non persistent and it is lost when the container reboots.
apt update && apt upgrade && apt install fish curl wget
If you are not already within the container, run the following command on your server on the Edge to log in as
root.
incus exec <CONTAINER_NAME> fish
passwd
Set a password for the root user and make a (mental) note of it. It will be denoted with a variable <ROOT_PWD__CONTAINER>.
From here on the root user will use fish as default shell.
chsh -s /usr/bin/fish
Let's create the maddy user and set a system password for it.
useradd -mrU -s /sbin/nologin -d /var/lib/maddy -c "maddy mail server" maddy
The ability to establish SSH connections from the server on the Edge to the container is key for the upcoming Ansible automation. There is an Ansible connection driver for Linux containers with limited capabilities. The default Ansible connection driver through SSH is much more robust and powerful.
You can perform this step now or in future, when you make up your mind to give the automated solutions for system and application maintanence a try.
In order to complete this stage you need to perform the following commands in the container run by the server on The Edge.
apt update && apt install openssh-server
vi /etc/ssh/sshd_config
Add the following line to the file.
PermitRootLogin yes
systemctl enable ssh
systemctl start ssh
systemctl restart ssh
Generate SSH key and certificate. You will need them at a later stage since this container will need to connect directly to the Internet Gateway as a power user that has access to certain TLS certificates.
ssh-keygen
Run the following commands on your server on the Edge, i.e. outside of the container.
Run ssh-keygen only if you haven't generated SSH private and public keys on the server on the Edge. Otherwise use the already existing pair.
ssh-keygen
incus ls | grep <CONTAINER_NAME>
Make a note of the IPV4 value <IP_ADDRESS__CONTAINER>.
ssh-copy-id root@<IP_ADDRESS__CONTAINER>
Pass the password for the root user in the container as prompted.
From here on SSH connection as the
rootuser will be possible from the server on the Edge to the container. Containers are not visible and accessible to any other network devices.
Run the following commands on your server on the Edge.
incus exec <CONTAINER_NAME> fish
Let's harden the setup of the ssh server running in the container by disabling the password for logging in with root.
vi /etc/ssh/sshd_config
Delete the line PermitRootLogin yes.
Append the line PermitRootLogin prohibit-password.
systemctl restart ssh
You should have a fully functional Linux container named
<CONTAINER_NAME>that has also a non-interactive usermaddy. In addition, the server on the Edge listens for SSH connections with therootuser. You know all login credentials.
In order to complete this stage you need to perform the following commands in the container run by the server on the Edge.
If you are not already within the container, run the following command on your server on the Edge to log in as
root.
incus exec <CONTAINER_NAME> fish
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc|sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg
apt update && apt install postgresql-15
In order to enable data checksums we need to delete the existing cluster and initialize it again.
rm -rf /var/lib/postgresql/15/main
mkdir /var/lib/postgresql/15/main
chown -R postgres:postgres /var/lib/postgresql/15/main
chmod 700 /var/lib/postgresql/15/main
sudo -u postgres /usr/lib/postgresql/15/bin/initdb \
    -D /var/lib/postgresql/15/main/ \
    --locale=C.UTF-8 --encoding=UTF8 --data-checksums
systemctl enable postgresql@15-main.service
systemctl start postgresql@15-main.service
Verify that the stage has been completed successfully by running the following command in the container. Its output should indicate that the service is running.
systemctl status postgresql@15-main.service
In order to complete this stage you need to perform the following commands in the container run by the server on the Edge.
If you are not already within the container, run the following command on your server on the Edge to log in as
root.
incus exec <CONTAINER_NAME> fish
Take a not of the password for the database user. It will be denoted with a variable <DB_PASSWORD>.
sudo -u postgres createuser maddy --interactive --pwprompt
Take a note of the password <DB_PASSWORD>.
sudo -u postgres createdb maddy -O maddy --encoding='utf-8'
If you are not already within the container, run the following command on your server on the Edge to log in as
root.
incus exec <CONTAINER_NAME> fish
apt update && apt install zstd
mkdir ~/tmp
cd ~/tmp
Do not forget to substitute the latest version of Maddy as a value of the environment envirable
M_VER. To find it out refer to the latest releases.
export M_VER=0.7.1
wget https://maddy.email/builds/$M_VER/maddy-$M_VER-x86_64-linux-musl.tar.zst
zstd -d maddy-$M_VER-x86_64-linux-musl.tar.zst
tar -xvf maddy-$M_VER-x86_64-linux-musl.tar 
cp maddy-$M_VER-x86_64-linux-musl/maddy /usr/local/bin/
chown maddy:maddy /usr/local/bin/maddy
mkdir /etc/maddy
cp maddy-$M_VER-x86_64-linux-musl/maddy.conf /etc/maddy
cp maddy-$M_VER-x86_64-linux-musl/systemd/*.service /etc/systemd/system
If you are not already within the container, run the following command on your server on the Edge to log in as
root.
incus exec <CONTAINER_NAME> fish
Edit the /etc/maddy/maddy.conf file.
vi /etc/maddy/maddy.conf
Replace the content of file with the configuration below.
Make sure to substitute the variables enclosed in <...> with the real values matching your setup.
## Maddy Mail Server - default configuration file (2022-06-18)
# Suitable for small-scale deployments. Uses its own format for local users DB,
# should be managed via maddy subcommands.
#
# See tutorials at https://maddy.email for guidance on typical
# configuration changes.
# ----------------------------------------------------------------------------
# Base variables
$(hostname) = mail.<PRIMARY_DOMAIN>
$(primary_domain) = <PRIMARY_DOMAIN>
$(local_domains) = $(primary_domain) mail.<PRIMARY_DOMAIN>
tls file /opt/maddy/tls/cert.pem /opt/maddy/tls/key.pem
# ----------------------------------------------------------------------------
# Local storage & authentication
# pass_table provides local hashed passwords storage for authentication of
# users. It can be configured to use any "table" module, in default
# configuration a table in SQLite DB is used.
# Table can be replaced to use e.g. a file for passwords. Or pass_table module
# can be replaced altogether to use some external source of credentials (e.g.
# PAM, /etc/shadow file).
#
# If table module supports it (sql_table does) - credentials can be managed
# using 'maddy creds' command.
auth.pass_table local_authdb {
    table sql_table {
        driver postgres
        dsn "user=maddy dbname=maddy password=<DB_PASSWORD> sslmode=disable"
        table_name passwords
    }
}
# imapsql module stores all indexes and metadata necessary for IMAP using a
# relational database. It is used by IMAP endpoint for mailbox access and
# also by SMTP & Submission endpoints for delivery of local messages.
#
# IMAP accounts, mailboxes and all message metadata can be inspected using
# imap-* subcommands of maddy.
storage.imapsql local_mailboxes {
     driver postgres
     dsn "user=maddy dbname=maddy password=<DB_PASSWORD> sslmode=disable"
}
# ----------------------------------------------------------------------------
# SMTP endpoints + message routing
hostname $(hostname)
table.chain local_rewrites {
    optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
    optional_step static {
        entry postmaster postmaster@$(primary_domain)
    }
    optional_step file /etc/maddy/aliases
}
msgpipeline local_routing {
    # Insert handling for special-purpose local domains here.
    destination $(local_domains) {
        deliver_to &local_mailboxes
    }
    destination postmaster $(local_domains) {
        modify {
            replace_rcpt &local_rewrites
        }
        deliver_to &local_mailboxes
    }
    default_destination {
        reject 550 5.1.1 "User doesn't exist"
    }
}
smtp tcp://0.0.0.0:25 {
    limits {
        # Up to 20 msgs/sec across max. 10 SMTP connections.
        all rate 20 1s
        all concurrency 10
    }
    dmarc yes
    check {
        require_mx_record
        dkim
        spf
    }
    source $(local_domains) {
        reject 501 5.1.8 "Use Submission for outgoing SMTP"
    }
    default_source {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.1.1 "User doesn't exist"
        }
    }
}
submission tls://0.0.0.0:465 tcp://0.0.0.0:587 {
    limits {
        # Up to 50 msgs/sec across any amount of SMTP connections.
        all rate 50 1s
    }
    auth &local_authdb
    source $(local_domains) {
        check {
            authorize_sender {
                prepare_email &local_rewrites
                user_to_email identity
            }
        }
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            modify {
                dkim $(primary_domain) $(local_domains) default
            }
            deliver_to &remote_queue
        }
    }
    default_source {
        reject 501 5.1.8 "Non-local sender domain"
    }
}
target.remote outbound_delivery {
    limits {
        # Up to 20 msgs/sec across max. 10 SMTP connections
        # for each recipient domain.
        destination rate 20 1s
        destination concurrency 10
    }
    mx_auth {
        dane
        mtasts {
            cache fs
            fs_dir mtasts_cache/
        }
        local_policy {
            min_tls_level encrypted
            min_mx_level none
        }
    }
}
target.queue remote_queue {
    target &outbound_delivery
    autogenerated_msg_domain $(primary_domain)
    bounce {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
        }
    }
}
# ----------------------------------------------------------------------------
# IMAP endpoints
imap tls://0.0.0.0:993 tcp://0.0.0.0:143 {
    auth &local_authdb
    storage &local_mailboxes
}
Save the file and exit the text editor.
If you are not already within the container, run the following command on your server on the Edge to log in as
root.
incus exec <CONTAINER_NAME> fish
Maddy has its own systemd service file that we copied earlier to /etc/systemd/system. Now the service needs to be loaded, enabled and started.
systemctl daemon-reload
systemctl enable maddy.service
systemctl start maddy.service
systemctl status maddy.service
The output should indicate that Maddy has been listening on the following ports within the container:
tls://0.0.0.0:465,tcp://0.0.0.0:587,tls://0.0.0.0:993,tcp://0.0.0.0:143.
In a terminal on on the server on The Edge run the following commands to forward the respective host ports to the container.
incus config device add <CONTAINER_NAME> \
    smtp-25 proxy listen=tcp:0.0.0.0:25 \
    connect=tcp:127.0.0.1:25
incus config device add <CONTAINER_NAME> \
    smtp-465 proxy listen=tcp:0.0.0.0:465 \
    connect=tcp:127.0.0.1:465
incus config device add <CONTAINER_NAME> \
    smtp-587 proxy listen=tcp:0.0.0.0:587 \
    connect=tcp:127.0.0.1:587
incus config device add <CONTAINER_NAME> \
    imap-993 proxy listen=tcp:0.0.0.0:993 \
    connect=tcp:127.0.0.1:993
In order to complete this stage you need to perform the following commands in the container run by the server on the Edge.
If you are not already within the container, run the following command on your server on the Edge to log in as
root.
incus exec <CONTAINER_NAME> fish
cat ~/.ssh/id_rsa.pub
Copy the content (<SSH_PUB_KEY__CONTAINER>) as you will need to add it to authorized_keys for the root user on the Internet Gateway.
From a host that is allowed to establish ssh connection to the Internet Gateway run the following commands.
ssh mycelium@<IP_ADDRESS__INET_GW>
sudo su -
vi ~/.ssh/authorized_keys
Add a new line and paste the public key value that you copied in the container running on the server on The Edge (<SSH_PUB_KEY__CONTAINER>). Save the file and exit.
You should be able to start a SSH session from within the running container to the Internet Gateway as a
rootuser, i.e.ssh root@<IP_ADDRESS__INET_GW>.
We need to generate TLS certificates to secure the communication of the SMTP and IMAP servers. We will use Let's Encrypt to provide us with officially signed certificates.
You will need to run this step roughly once every three months. The Let's Encrypt certificates will need to be renewed. Better set up a reminder to prevent downtime of your email service on The Edge. In future these steps will be automated.
ssh mycelium@<IP_ADDRESS__INET_GW>
sudo su -
apt update && apt upgrade -y && apt install certbot -y
If you have a reverse proxy running on the Internet Gateway, it will have to be stopped while certificate generation is ongoing as
certbotneeds to be able to listen to port80. This means short downtime for any app self hosted on The Edge that is accessed through HTTP and HTTPS.
systemctl stop caddy
certbot certonly --standalone \
    -d <PRIMARY_DOMAIN> \
    -d mail.<PRIMARY_DOMAIN>
certbotwill print the paths to the certificate (fullchain.pem) and the respective private key (privkey.pem) on the command line.
Copy the path of the directory where they are stored <CERTBOT_TLS_DIR_PATH__INET_GW>
systemctl start caddy
You will need to run this step roughly once every three months. The Let's Encrypt certificates will need to be renewed. Better set up a reminder to prevent issues related to the email deliverability that will be a bit of a nuisance to troubleshoot and resolve. In future these steps will be automated.
The TLS certificates that were generated for mail.<PRIMARY_DOMAIN> on the Internet Gateway will be needed also in the container and the Maddy email server will need them in order to ensure reliable email deliverability.
There might be much better ways to ensure that the Maddy daemon running in the container has access to the TLS certificate and private key pair generated on the Internet Gateway. We are documenting just one of the possible routes, not necessarily the most optimal one.
In order to complete this stage you need to perform the following commands in the container run by the server on the Edge.
If you are not already within the container, run the following command on your server on the Edge to log in as
root.
incus exec <CONTAINER_NAME> fish
mkdir -p /opt/maddy/tls
Do not forget to subsistute the variables below with the respective values matching your Edge setup.
scp root@<IPV4_ADDRESS_INET_GW>:<CERTBOT_TLS_DIR_PATH__INET_GW>/fullchain.pem \
    /opt/maddy/tls/cert.pem
scp root@<IPV4_ADDRESS_INET_GW>:<CERTBOT_TLS_DIR_PATH__INET_GW>/privkey.pem \
    /opt/maddy/tls/key.pem
chown -R maddy:maddy /opt/maddy
systemctl restart maddy.service
The
maddy.serviceshould have been restarted successfully. Check it out via runningsystemctl status maddy.service.
In order to complete this stage you will need to be using a super user on the Internet Gateway
ssh mycelium@<IP_ADDRESS__INET_GW>
sudo su -
Create the following Systemd service files.
ufw allow 25
vi /etc/systemd/system/smtp-25-bridge.service
Make sure the file contains the service configuration below.
Sustitute the variables enclosed within
<>in the service file with the values matching your setup.
# /etc/systemd/system/smtp-25-bridge.service
[Unit]
Description=A service managing the tcp port forwarding to a SMTP server listening on port 25
[Service]
Type=simple
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=smtp-25-bridge
ExecStart=socat -d -d TCP-LISTEN:25,forever,reuseaddr,fork TCP4:<IP_ADDRESS__EDGE_SERVER_VPN>:25
Restart=always
RuntimeMaxSec=900 # the service will be restarted every 15 min.
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable smtp-25-bridge.service
systemctl start smtp-25-bridge.service
ufw allow 465
vi /etc/systemd/system/smtp-465-bridge.service
Make sure the file contains the service configuration below.
Sustitute the variables enclosed within
<>in the service file with the values matching your setup.
# /etc/systemd/system/smtp-465-bridge.service
[Unit]
Description=A service managing the tcp port forwarding to a SMTP server - port 465 (submission)
[Service]
Type=simple
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=smtp-465-bridge
ExecStart=socat -d -d OPENSSL-LISTEN:465,cert=/etc/letsencrypt/live/<PRIMARY_DOMAIN>/fullchain.pem,key=/etc/letsencrypt/live/<PRIMARY_DOMAIN>/privkey.pem,verify=0,forever,reuseaddr,fork OPENSSL:<IP_ADDRESS__EDGE_SERVER_VPN>:465,verify=0
Restart=always
RuntimeMaxSec=900 # the service will be restarted every 15 min.
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable smtp-465-bridge.service
systemctl start smtp-465-bridge.service
ufw allow 587
vi /etc/systemd/system/smtp-587-bridge.service
Make sure the file contains the service configuration below.
Sustitute the variables enclosed within
<>in the service file with the values matching your setup.
# /etc/systemd/system/smtp-587-bridge.service
[Unit]
Description=A service managing the tcp port forwarding to a SMTP server - port 587
[Service]
Type=simple
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=smtp-587-bridge
ExecStart=socat -d -d OPENSSL-LISTEN:587,cert=/etc/letsencrypt/live/<PRIMARY_DOMAIN>/fullchain.pem,key=/etc/letsencrypt/live/<PRIMARY_DOMAIN>/privkey.pem,verify=0,forever,reuseaddr,fork OPENSSL:<IP_ADDRESS__EDGE_SERVER_VPN>:587,verify=0
Restart=always
RuntimeMaxSec=900 # the service will be restarted every 15 min.
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable smtp-587-bridge.service
systemctl start smtp-587-bridge.service
ufw allow 993
vi /etc/systemd/system/imap-993-bridge.service
Make sure the file contains the service configuration below.
Sustitute the variables enclosed within
<>in the service file with the values matching your setup.
# /etc/systemd/system/imap-993-bridge.service
[Unit]
Description=A service managing the tcp port forwarding to an IMAP server - port 993
[Service]
Type=simple
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=imap-993-bridge
ExecStart=socat -d -d OPENSSL-LISTEN:993,cert=/etc/letsencrypt/live/<PRIMARY_DOMAIN>/fullchain.pem,key=/etc/letsencrypt/live/<PRIMARY_DOMAIN>/privkey.pem,verify=0,forever,reuseaddr,fork OPENSSL:<IP_ADDRESS__EDGE_SERVER_VPN>:993,verify=0
Restart=always
RuntimeMaxSec=900 # the service will be restarted every 15 min.
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable imap-993-bridge.service
systemctl start imap-993-bridge.service
You will need to create the following domain name records in your DNS server.
Sustitute the variables enclosed within
<>in the service file with the values matching your setup.
| Record type | Host | Value | 
|---|---|---|
| A | @ | <IPV4_ADDRESS_INET_GW> | 
| AAAA | @ | <IPV6_ADDRESS_INET_GW> | 
| A | <IPV4_ADDRESS_INET_GW> | |
| AAAA | <IPV6_ADDRESS_INET_GW> | |
| TXT | @ | "v=spf1 a:mail.<PRIMARY_DOMAIN> -all" | 
| TXT | "v=spf1 a:mail.<PRIMARY_DOMAIN> -all" | |
| TXT | _dmarc | "v=DMARC1; p=quarantine; ruf=mailto:postmaster@<PRIMARY_DOMAIN>" | 
| TXT | _mta-sts | "v=STSv1; id=1" | 
| TXT | _smtp._tls | "v=TLSRPTv1;rua=mailto:postmaster@<PRIMARY_DOMAIN>" | 
| TXT | default._domainkey | "<PRIMARY_DOMAIN_DKIM_KEY>" | 
| TXT | default._domainkey.mail.<PRIMARY_DOMAIN> | "<MAIL_DOMAIN_DKIM_KEY>" | 
Configure the Reverse DNS using the administrative consoles that VPS providers have.
Sustitute the variables enclosed within
<>in the service file with the values matching your setup.
You will need to point the IPv4 and IPv6 public IP addresses of the VPS to point to mail.<PRIMARY_DOMAIN>
ssh mycelium@<IP_ADDRESS__INET_GW>
sudo su -
nvim /etc/caddy/Caddyfile
Add the following configuration and save the file.
Sustitute the variables enclosed within
<>in the service file with the values matching your setup.
mta-sts.<PRIMARY_DOMAIN> {
    respond "version: STSv1
                mode: enforce
                max_age: 604800
                mx: mail.<PRIMARY_DOMAIN>"
    header Content-Type "text/plain; charset=utf-8"
}
systemctl reload caddy
You will need to create virtual email users using the maddy executable file.
Further information related to user management in Maddy could be found in this guide.
If you are not already within the container, run the following command on your server on the Edge to log in as
root.
incus exec <CONTAINER_NAME> fish
Don't forget to substitute the variables enclosed within <...> with values matching your setup.
Make sure you choose a really strong password. You could generate a password up to 72 bytes with any password manager.
maddy creds create postmaster@<PRIMARY_DOMAIN>
maddy imap-acct create postmaster@<PRIMARY_DOMAIN>
In this way you can create also other virtual email users.
Use a virtual user to access the server through an email client, such as Mozilla Thunderbird, on desktop and mobile devices.