Build Your Own Mail Server From Scratch

Here’s how you can build your own mail server from scratch.

This document is generated automatically from Mail-in-a-Box’s setup script source code.

view the bash source for the following section at setup/

Basic System Configuration

Set hostname of the box

If the hostname is not correctly resolvable sudo can't be used. This will result in errors during the install

First set the hostname in the configuration file, then activate the setting

echo > /etc/hostname

Fix permissions

The default Ubuntu Bionic image on Scaleway throws warnings during setup about incorrect permissions (group writeable) set on the following directories.

chmod g-w /etc /etc/default /usr

Add swap space to the system

If the physical memory of the system is below 2GB it is wise to create a swap file. This will make the system more resiliant to memory spikes and prevent for instance spam filtering from crashing

We will create a 1G file, this should be a good balance between disk usage and buffers for the system. We will only allocate this file if there is more than 5GB of disk space available

The following checks are performed: - Check if swap is currently mountend by looking at /proc/swaps - Check if the user intents to activate swap on next boot by checking fstab entries. - Check if a swapfile already exists - Check if the root file system is not btrfs, might be an incompatible version with swapfiles. User should hanle it them selves. - Check the memory requirements - Check available diskspace

See for reference

SWAP_MOUNTED=$(cat /proc/swaps | tail -n+2)
SWAP_IN_FSTAB=$(grep swap /etc/fstab || /bin/true)
ROOT_IS_BTRFS=$(grep "/ .*btrfs" /proc/mounts || /bin/true)
TOTAL_PHYSICAL_MEM=$(head -n 1 /proc/meminfo | awk "{print $2}" || /bin/true)
AVAILABLE_DISK_SPACE=$(df / --output=avail | tail -n 1)
[ -z $SWAP_MOUNTED ] &&
[ -z $SWAP_IN_FSTAB ] &&
[ ! -e /swapfile ] &&
[ -z $ROOT_IS_BTRFS ] &&
[ $TOTAL_PHYSICAL_MEM -lt 1900000 ] &&
[ $AVAILABLE_DISK_SPACE -gt 5242880 ]

Allocate and activate the swap file. Allocate in 1KB chuncks doing it in one go, could fail on low memory systems

dd if=/dev/zero of=/swapfile bs=1024 count=$[1024*1024] status=none
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile

Check if swap is mounted then activate on boot

echo "/swapfile none swap sw 0 0" >> /etc/fstab

Add PPAs.

We install some non-standard Ubuntu packages maintained by other third-party providers. First ensure add-apt-repository is installed.

apt-get update
apt-get install -y software-properties-common

Install the certbot PPA.

add-apt-repository -y ppa:certbot/certbot

Update Packages

Update system packages to make sure we have the latest upstream versions of things from Ubuntu, as well as the directory of packages provide by the PPAs so we can install those packages later.

apt-get update
apt_get_quiet upgrade

Old kernels pile up over time and take up a lot of disk space, and because of Mail-in-a-Box changes there may be other packages that are no longer needed. Clear out anything apt knows is safe to delete.

apt_get_quiet autoremove

Install System Packages

Install basic utilities.

  • haveged: Provides extra entropy to /dev/random so it doesn't stall when generating random numbers for private keys (e.g. during ldns-keygen).
  • unattended-upgrades: Apt tool to install security updates automatically.
  • cron: Runs background processes periodically.
  • ntp: keeps the system time correct
  • fail2ban: scans log files for repeated failed login attempts and blocks the remote IP at the firewall
  • netcat-openbsd: nc command line networking tool
  • git: we install some things directly from github
  • sudo: allows privileged users to execute commands as root without being root
  • coreutils: includes nproc tool to report number of processors, mktemp
  • bc: allows us to do math to compute sane defaults
apt-get install -y python3 python3-dev python3-pip netcat-openbsd wget curl git sudo coreutils bc haveged pollinate unzip unattended-upgrades cron ntp fail2ban rsyslog

Suppress Upgrade Prompts

When Ubuntu 20 comes out, we don't want users to be prompted to upgrade, because we don't yet support it.

tools/ /etc/update-manager/release-upgrades Prompt=never
rm -f /var/lib/ubuntu-release-upgrader/release-upgrade-available

Set the system timezone

Some systems are missing /etc/timezone, which we cat into the configs for Z-Push and ownCloud, so we need to set it to something. Daily cron tasks like the system backup are run at a time tied to the system timezone, so letting the user choose will help us identify the right time to do those things (i.e. late at night in whatever timezone the user actually lives in).

However, changing the timezone once it is set seems to confuse fail2ban and requires restarting fail2ban (done below in the fail2ban section) and syslog (see #328). There might be other issues, and it's not likely the user will want to change this, so we only ask on first setup. If the file is missing or this is the user's first time running Mail-in-a-Box setup, run the interactive timezone configuration tool.

dpkg-reconfigure tzdata
service rsyslog restart

This is a non-interactive setup so we can't ask the user. If /etc/timezone is missing, set it to UTC.

echo Etc/UTC > /etc/timezone
service rsyslog restart

Seed /dev/urandom

/dev/urandom is used by various components for generating random bytes for encryption keys and passwords:

  • TLS private key (see, which calls openssl genrsa)
  • DNSSEC signing keys (see
  • our management server's API key (via Python's os.urandom method)
  • Roundcube's SECRET_KEY (

Why /dev/urandom? It's the same as /dev/random, except that it doesn't wait for a constant new stream of entropy. In practice, we only need a little entropy at the start to get going. After that, we can safely pull a random stream from /dev/urandom and not worry about how much entropy has been added to the stream. ( So we need to worry about /dev/urandom being seeded properly (which is also an issue for /dev/random), but after that /dev/urandom is superior to /dev/random because it's faster and doesn't block indefinitely to wait for hardware entropy. Note that openssl genrsa even uses /dev/urandom, and if it's good enough for generating an RSA private key, it's good enough for anything else we may need.

Now about that seeding issue....

/dev/urandom is seeded from "the uninitialized contents of the pool buffers when the kernel starts, the startup clock time in nanosecond resolution,...and entropy saved across boots to a local file" as well as the order of execution of concurrent accesses to /dev/urandom. (Heninger et al 2012, But when memory is zeroed, the system clock is reset on boot, /etc/init.d/urandom has not yet run, or the machine is single CPU or has no concurrent accesses to /dev/urandom prior to this point, /dev/urandom may not be seeded well. After this, /dev/urandom draws from the same entropy sources as /dev/random, but it doesn't block or issue any warnings if no entropy is actually available. ( Entropy might not be readily available because this machine has no user input devices (common on servers!) and either no hard disk or not enough IO has ocurred yet --- although haveged tries to mitigate this. So there's a good chance that accessing /dev/urandom will not be drawing from any hardware entropy and under a perfect-storm circumstance where the other seeds are meaningless, /dev/urandom may not be seeded at all.

The first thing we'll do is block until we can seed /dev/urandom with enough hardware entropy to get going, by drawing from /dev/random. haveged makes this less likely to stall for very long.

dd if=/dev/random of=/dev/urandom bs=1 count=32 2> /dev/null

This is supposedly sufficient. But because we're not sure if hardware entropy is really any good on virtualized systems, we'll also seed from Ubuntu's pollinate servers:

pollinate -q -r

Between these two, we really ought to be all set.

We need an ssh key to store backups via rsync, if it doesn't exist create one

ssh-keygen -t rsa -b 2048 -a 100 -f /root/.ssh/id_rsa_miab -N -q

Package maintenance

Allow apt to install system updates automatically every day.

/etc/apt/apt.conf.d/02periodic (overwrite)
APT::Periodic::MaxAge "7";
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Verbose "0";


Install ufw which provides a simple firewall configuration.

apt-get install -y ufw

Allow incoming connections to SSH.

ufw allow ssh
ufw --force enable

Local DNS Service

Install a local recursive DNS server --- i.e. for DNS queries made by local services running on this machine.

(This is unrelated to the box's public, non-recursive DNS server that answers remote queries about domain names hosted on this box. For that see

The default systemd-resolved service provides local DNS name resolution. By default it is a recursive stub nameserver, which means it simply relays requests to an external nameserver, usually provided by your ISP or configured in /etc/systemd/resolved.conf.

This won't work for us for three reasons.

1) We have higher security goals --- we want DNSSEC to be enforced on all DNS queries (some upstream DNS servers do, some don't). 2) We will configure postfix to use DANE, which uses DNSSEC to find TLS certificates for remote servers. DNSSEC validation must be performed locally because we can't trust an unencrypted connection to an external DNS server. 3) DNS-based mail server blacklists (RBLs) typically block large ISP DNS servers because they only provide free data to small users. Since we use RBLs to block incoming mail from blacklisted IP addresses, we have to run our own DNS server. See #1424.

systemd-resolved has a setting to perform local DNSSEC validation on all requests (in /etc/systemd/resolved.conf, set DNSSEC=yes), but because it's a stub server the main part of a request still goes through an upstream DNS server, which won't work for RBLs. So we really need a local recursive nameserver.

We'll install bind9, which as packaged for Ubuntu, has DNSSEC enabled by default via "dnssec-validation auto". We'll have it be bound to so that it does not interfere with the public, recursive nameserver nsd bound to the public ethernet interfaces.

About the settings:

  • Adding -4 to OPTIONS will have bind9 not listen on IPv6 addresses so that we're sure there's no conflict with nsd, our public domain name server, on IPV6.
  • The listen-on directive in named.conf.options restricts bind9 to binding to the loopback interface instead of all interfaces.
apt-get install -y bind9
/etc/default/bind9 (change settings)
OPTIONS="-u bind -4"

Add a listen-on directive if it doesn't exist inside the options block.

sed -i "s/^}/\n\tlisten-on {; };\n}/" /etc/bind/named.conf.options

First we'll disable systemd-resolved's management of resolv.conf and its stub server. Breaking the symlink to /run/systemd/resolve/stub-resolv.conf means systemd-resolved will read it for DNS servers to use. Put in, which is where bind9 will be running. Obviously don't do this before installing bind9 or else apt won't be able to resolve a server to download bind9 from.

rm -f /etc/resolv.conf
/etc/systemd/resolved.conf (change settings)
echo "nameserver" > /etc/resolv.conf

Restart the DNS services.

service bind9 restart
systemctl restart systemd-resolved

Fail2Ban Service

Configure the Fail2Ban installation to prevent dumb bruce-force attacks against dovecot, postfix, ssh, etc.

rm -f /etc/fail2ban/jail.local # we used to use this file but don\'t anymore
rm -f /etc/fail2ban/jail.d/defaults-debian.conf # removes default config so we can manage all of fail2ban rules in one config
cat conf/fail2ban/jails.conf | sed s/PUBLIC_IP/$PUBLIC_IP/g | sed s#STORAGE_ROOT#$STORE# > /etc/fail2ban/jail.d/mailinabox.conf
cp -f conf/fail2ban/filter.d/* /etc/fail2ban/filter.d/

On first installation, the log files that the jails look at don't all exist. e.g., The roundcube error log isn't normally created until someone logs into Roundcube for the first time. This causes fail2ban to fail to start. Later scripts will ensure the files exist and then fail2ban is given another restart at the very end of setup.

service fail2ban restart
view the bash source for the following section at setup/

RSA private key, SSL certificate, Diffie-Hellman bits files

Create an RSA private key, a self-signed SSL certificate, and some Diffie-Hellman cipher bits, if they have not yet been created.

The RSA private key and certificate are used for:

  • DNSSEC DANE TLSA records
  • IMAP
  • SMTP (opportunistic TLS for port 25 and submission on port 587)

The certificate is created with its CN set to the It is also used for other domains served over HTTPS until the user installs a better certificate for those domains.

The Diffie-Hellman cipher bits are used for SMTP and HTTPS, when a Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman provides Perfect Forward Secrecy.

Show a status line if we are going to take any action in this file.

if [ ! -f /usr/bin/openssl ] || [ ! -f $STORE/ssl/ssl_private_key.pem ] || [ ! -f $STORE/ssl/ssl_certificate.pem ] || [ ! -f $STORE/ssl/dh2048.pem ]

Install openssl.

apt-get install -y openssl

Create a directory to store TLS-related things like "SSL" certificates.

mkdir -p $STORE/ssl

Generate a new private key.

The key is only as good as the entropy available to openssl so that it can generate a random key. "OpenSSL’s built-in RSA key generator .... is seeded on first use with (on Linux) 32 bytes read from /dev/urandom, the process ID, user ID, and the current time in seconds. [During key generation OpenSSL] mixes into the entropy pool the current time in seconds, the process ID, and the possibly uninitialized contents of a ... buffer ... dozens to hundreds of times."

A perfect storm of issues can cause the generated key to be not very random:

  • improperly seeded /dev/urandom, but see for how we mitigate this
  • the user ID of this process is always the same (we're root), so that seed is useless
  • zero'd memory (plausible on embedded systems, cloud VMs?)
  • a predictable process ID (likely on an embedded/virtualized system)
  • a system clock reset to a fixed time on boot

Since we properly seed /dev/urandom in we should be fine, but I leave in the rest of the notes in case that ever changes. Set the umask so the key file is never world-readable.

(umask 077; openssl genrsa -out $STORE/ssl/ssl_private_key.pem 2048)

Generate a self-signed SSL certificate because things like nginx, dovecot, etc. won't even start without some certificate in place, and we need nginx so we can offer the user a control panel to install a better certificate. Generate a certificate signing request.

openssl req -new -key $STORE/ssl/ssl_private_key.pem -out $CSR -sha256 -subj /

Generate the self-signed certificate.

CERT=$STORE/ssl/$(date --rfc-3339=date | sed s/-//g).pem
openssl x509 -req -days 365 -in $CSR -signkey $STORE/ssl/ssl_private_key.pem -out $CERT

Delete the certificate signing request because it has no other purpose.

rm -f $CSR

Symlink the certificate into the system certificate path, so system services can find it.

ln -s $CERT $STORE/ssl/ssl_certificate.pem

Generate some Diffie-Hellman cipher bits. openssl's default bit length for this is 1024 bits, but we'll create 2048 bits of bits per the latest recommendations.

openssl dhparam -out $STORE/ssl/dh2048.pem 2048
view the bash source for the following section at setup/


This script installs packages, but the DNS zone files are only created by the /dns/update API in the management server because the set of zones (domains) hosted by the server depends on the mail users & aliases created by the user later.

Install the packages.

  • nsd: The non-recursive nameserver that publishes our DNS records.
  • ldnsutils: Helper utilities for signing DNSSEC zones.
  • openssh-client: Provides ssh-keyscan which we use to create SSHFP records.
apt-get install -y nsd ldnsutils openssh-client

Prepare nsd's configuration.

mkdir -p /var/run/nsd
/etc/nsd/nsd.conf (overwrite)
# Do not edit. Overwritten by Mail-in-a-Box setup.
  hide-version: yes
  logfile: "/var/log/nsd.log"

  # identify the server (CH TXT ID.SERVER entry).
  identity: ""

  # The directory for zonefile: files.
  zonesdir: "/etc/nsd/zones"

  # Allows NSD to bind to IP addresses that are not (yet) added to the
  # network interface. This allows nsd to start even if the network stack
  # isn't fully ready, which apparently happens in some cases.
  # See
  ip-transparent: yes

Add log rotation

/etc/logrotate.d/nsd (overwrite)
/var/log/nsd.log {
  rotate 12

Since we have bind9 listening on localhost for locally-generated DNS queries that require a recursive nameserver, and the system might have other network interfaces for e.g. tunnelling, we have to be specific about the network interfaces that nsd binds to.

echo " ip-address: $ip" >> /etc/nsd/nsd.conf
echo "include: /etc/nsd/zones.conf" >> /etc/nsd/nsd.conf

Create DNSSEC signing keys.

mkdir -p $STORE/dns/dnssec

TLDs don't all support the same algorithms, so we'll generate keys using a few different algorithms. RSASHA1-NSEC3-SHA1 was possibly the first widely used algorithm that supported NSEC3, which is a security best practice. However TLDs will probably be moving away from it to a a SHA256-based algorithm.

Supports RSASHA1-NSEC3-SHA1 (didn't test with RSASHA256):

  • .info
  • .me

Requires RSASHA256

  • .email
  • .guide

Supports RSASHA256 (and defaulting to this)

  • .fund
for algo in RSASHA1-NSEC3-SHA1 RSASHA256

Create the Key-Signing Key (KSK) (with -k) which is the so-called Secure Entry Point. The domain name we provide ("domain") doesn't matter -- we'll use the same keys for all our domains.

ldns-keygen outputs the new key's filename to stdout, which we're capturing into the KSK variable.

ldns-keygen uses /dev/random for generating random numbers by default. This is slow and unecessary if we ensure /dev/urandom is seeded properly, so we use /dev/urandom. See for an explanation. See #596, #115.

KSK=$(umask 077; cd $STORE/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo -b 2048 -k _domain_)

Now create a Zone-Signing Key (ZSK) which is expected to be rotated more often than a KSK, although we have no plans to rotate it (and doing so would be difficult to do without disturbing DNS availability.) Omit -k and use a shorter key length.

ZSK=$(umask 077; cd $STORE/dns/dnssec; ldns-keygen -r /dev/urandom -a $algo -b 1024 _domain_)

These generate two sets of files like:

  • K_domain_.+007+08882.ds: DS record normally provided to domain name registrar (but it's actually invalid with _domain_)
  • K_domain_.+007+08882.key: public key
  • K_domain_.+007+08882.private: private key (secret!)

The filenames are unpredictable and encode the key generation options. So we'll store the names of the files we just generated. We might have multiple keys down the road. This will identify what keys are the current keys.

cat > $STORE/dns/dnssec/$algo.conf << EOF

And loop to do the next algorithm...


Force the dns_update script to be run every day to re-sign zones for DNSSEC before they expire. When we sign zones (in we specify a 30-day validation window, so we had better re-sign before then.

/etc/cron.daily/mailinabox-dnssec (overwrite)
# Mail-in-a-Box
# Re-sign any DNS zones with DNSSEC because the signatures expire periodically.
chmod +x /etc/cron.daily/mailinabox-dnssec

Permit DNS queries on TCP/UDP in the firewall.

ufw allow domain
view the bash source for the following section at setup/

Postfix (SMTP)

Postfix handles the transmission of email between servers using the SMTP protocol. It is a Mail Transfer Agent (MTA).

Postfix listens on port 25 (SMTP) for incoming mail from other servers on the Internet. It is responsible for very basic email filtering such as by IP address and greylisting, it checks that the destination address is valid, rewrites destinations according to aliases, and passses email on to another service for local mail delivery.

The first hop in local mail delivery is to Spamassassin via LMTP. Spamassassin then passes mail over to Dovecot for storage in the user's mailbox.

Postfix also listens on port 587 (SMTP+STARTLS) for connections from users who can authenticate and then sends their email out to the outside world. Postfix queries Dovecot to authenticate users.

Address validation, alias rewriting, and user authentication is configured in a separate setup script because of the overlap of this part with the Dovecot configuration.

Install packages.

Install postfix's packages.

  • postfix: The SMTP server.
  • postfix-pcre: Enables header filtering.
  • postgrey: A mail policy service that soft-rejects mail the first time it is received. Spammers don't usually try agian. Legitimate mail always will.
  • ca-certificates: A trust store used to squelch postfix warnings about untrusted opportunistically-encrypted connections.
apt-get install -y postfix postfix-sqlite postfix-pcre postgrey ca-certificates

Basic Settings

Set some basic settings...

  • Have postfix listen on all network interfaces.
  • Make outgoing connections on a particular interface (if multihomed) so that SPF passes on the receiving side.
  • Set our name (the Debian default seems to be "localhost" but make it our hostname).
  • Set the name of the local machine to localhost, which means xxx@localhost is delivered locally, although we don't use it.
  • Set the SMTP banner (which must have the hostname first, then anything).
/etc/postfix/ (change settings)
smtpd_banner=$myhostname ESMTP Hi, I'm a Mail-in-a-Box (Ubuntu/Postfix; see

Tweak some queue settings: * Inform users when their e-mail delivery is delayed more than 3 hours (default is not to warn). * Stop trying to send an undeliverable e-mail after 2 days (instead of 5), and for bounce messages just try for 1 day.

/etc/postfix/ (change settings)

Outgoing Mail

Enable the 'submission' port 587 smtpd server and tweak its settings.

  • Enable authentication. It's disabled globally so that it is disabled on port 25, so we need to explicitly enable it here.
  • Do not add the OpenDMAC Authentication-Results header. That should only be added on incoming mail. Omit the OpenDMARC milter by re-setting smtpd_milters to the OpenDKIM milter only. See
  • Even though we dont allow auth over non-TLS connections (smtpd_tls_auth_only below, and without auth the client cant send outbound mail), don't allow non-TLS mail submission on this port anyway to prevent accidental misconfiguration.
  • Require the best ciphers for incoming connections per By putting this setting here we leave opportunistic TLS on incoming mail at default cipher settings (any cipher is better than none).
  • Give it a different name in syslog to distinguish it from the port 25 smtpd server.
  • Add a new cleanup service specific to the submission service ('authclean') that filters out privacy-sensitive headers on mail being sent out by authenticated users. By default Postfix also applies this to attached emails but we turn this off by setting nested_header_checks empty.
/etc/postfix/ (change settings)
submission inet n       -       -       -       -       smtpd
  -o smtpd_sasl_auth_enable yes
  -o syslog_name postfix/submission
  -o smtpd_milters inet:
  -o smtpd_tls_security_level encrypt
  -o smtpd_tls_ciphers high -o smtpd_tls_exclude_ciphers=aNULL,DES,3DES,MD5,DES+MD5,RC4 -o smtpd_tls_mandatory_protocols=!SSLv2,!SSLv3
  -o cleanup_service_name authclean
authclean unix  n       -       -       -       0       cleanup
  -o header_checks pcre:/etc/postfix/outgoing_mail_header_filters
  -o nested_header_checks 

Install the outgoing_mail_header_filters file required by the new 'authclean' service.

cp conf/postfix_outgoing_mail_header_filters /etc/postfix/outgoing_mail_header_filters

Modify the outgoing_mail_header_filters file to use the local machine name and ip on the first received header line. This may help reduce the spam score of email by removing the reference.

sed -i s/ /etc/postfix/outgoing_mail_header_filters
sed -i s/PUBLIC_IP/$PUBLIC_IP/ /etc/postfix/outgoing_mail_header_filters

Enable TLS on these and all other connections (i.e. ports 25 and 587) and require TLS before a user is allowed to authenticate. This also makes opportunistic TLS available on incoming mail. Set stronger DH parameters, which via openssl tend to default to 1024 bits (see

/etc/postfix/ (change settings)

Prevent non-authenticated users from sending mail that requires being relayed elsewhere. We don't want to be an "open relay". On outbound mail, require one of:

  • permit_sasl_authenticated: Authenticated users (i.e. on port 587).
  • permit_mynetworks: Mail that originates locally.
  • reject_unauth_destination: No one else. (Permits mail whose destination is local and rejects other mail.)
/etc/postfix/ (change settings)


When connecting to remote SMTP servers, prefer TLS and use DANE if available.

Prefering ("opportunistic") TLS means Postfix will use TLS if the remote end offers it, otherwise it will transmit the message in the clear. Postfix will accept whatever SSL certificate the remote end provides. Opportunistic TLS protects against passive easvesdropping (but not man-in-the-middle attacks). DANE takes this a step further:

Postfix queries DNS for the TLSA record on the destination MX host. If no TLSA records are found, then opportunistic TLS is used. Otherwise the server certificate must match the TLSA records or else the mail bounces. TLSA also requires DNSSEC on the MX host. Postfix doesn't do DNSSEC itself but assumes the system's nameserver does and reports DNSSEC status. Thus this also relies on our local DNS server (see and smtp_dns_support_level=dnssec.

The smtp_tls_CAfile is superflous, but it eliminates warnings in the logs about untrusted certs, which we don't care about seeing because Postfix is doing opportunistic TLS anyway. Better to encrypt, even if we don't know if it's to the right party, than to not encrypt at all. Instead we'll now see notices about trusted certs. The CA file is provided by the package ca-certificates.

/etc/postfix/ (change settings)

Incoming Mail

Pass any incoming mail over to a local delivery agent. Spamassassin will act as the LDA agent at first. It is listening on port 10025 with LMTP. Spamassassin will pass the mail over to Dovecot after.

In a basic setup we would pass mail directly to Dovecot by setting virtual_transport to lmtp:unix:private/dovecot-lmtp.

/etc/postfix/ (change settings)

Because of a spampd bug, limit the number of recipients in each connection. See

/etc/postfix/ (change settings)

Who can send mail to us? Some basic filters.

  • reject_non_fqdn_sender: Reject not-nice-looking return paths.
  • reject_unknown_sender_domain: Reject return paths with invalid domains.
  • reject_authenticated_sender_login_mismatch: Reject if mail FROM address does not match the client SASL login
  • reject_rhsbl_sender: Reject return paths that use blacklisted domains.
  • permit_sasl_authenticated: Authenticated users (i.e. on port 587) can skip further checks.
  • permit_mynetworks: Mail that originates locally can skip further checks.
  • reject_rbl_client: Reject connections from IP addresses blacklisted in
  • reject_unlisted_recipient: Although Postfix will reject mail to unknown recipients, it's nicer to reject such mail ahead of greylisting rather than after.
  • check_policy_service: Apply greylisting using postgrey.
/etc/postfix/ (change settings)
smtpd_recipient_restrictions=permit_sasl_authenticated,permit_mynetworks,reject_rbl_client,reject_unlisted_recipient,check_policy_service inet:

Postfix connects to Postgrey on the interface specifically. Ensure that Postgrey listens on the same interface (and not IPv6, for instance). A lot of legit mail servers try to resend before 300 seconds. As a matter of fact RFC is not strict about retry timer so postfix and other MTA have their own intervals. To fix the problem of receiving e-mails really latter, delay of greylisting has been set to 180 seconds (default is 300 seconds).

/etc/default/postgrey (change settings)
POSTGREY_OPTS="--inet= --delay=180 --whitelist-recipients=/etc/postgrey/whitelist_clients"

We are going to setup a newer whitelist for postgrey, the version included in the distribution is old

/etc/cron.daily/mailinabox-postgrey-whitelist (overwrite)

# Mail-in-a-Box

# check we have a postgrey_whitelist_clients file and that it is not older than 28 days
    # ok we need to update the file, so lets try to fetch it
        # if fetching hasn't failed yet then check it is a plain text file
        # curl manual states that --fail sometimes still produces output
        # this final check will at least check the output is not html
        # before moving it into place
            mv /tmp/postgrey_whitelist_clients /etc/postgrey/whitelist_clients
            service postgrey restart
            rm /tmp/postgrey_whitelist_clients
chmod +x /etc/cron.daily/mailinabox-postgrey-whitelist

Increase the message size limit from 10MB to 128MB. The same limit is specified in nginx.conf for mail submitted via webmail and Z-Push.

/etc/postfix/ (change settings)

Allow the two SMTP ports in the firewall.

ufw allow smtp
ufw allow submission

Restart services

service postfix restart
service postgrey restart
view the bash source for the following section at setup/

Dovecot (IMAP/POP and LDA)

Dovecot is both the IMAP/POP server (the protocol that email applications use to query a mailbox) as well as the local delivery agent (LDA), meaning it is responsible for writing emails to mailbox storage on disk. You could imagine why these things would be bundled together.

As part of local mail delivery, Dovecot executes actions on incoming mail as defined in a "sieve" script.

Dovecot's LDA role comes after spam filtering. Postfix hands mail off to Spamassassin which in turn hands it off to Dovecot. This all happens using the LMTP protocol.

Install packages for dovecot. These are all core dovecot plugins, but dovecot-lucene is packaged by us in the Mail-in-a-Box PPA, not by Ubuntu.

apt-get install -y dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite sqlite3 dovecot-sieve dovecot-managesieved

The dovecot-imapd, dovecot-pop3d, and dovecot-lmtpd packages automatically enable IMAP, POP and LMTP protocols.

Set basic daemon options.

The default_process_limit is 100, which constrains the total number of active IMAP connections (at, say, 5 open connections per user that would be 20 users). Set it to 250 times the number of cores this machine has, so on a two-core machine that's 500 processes/100 users). The default_vsz_limit is the maximum amount of virtual memory that can be allocated. It should be set reasonably high to avoid allocation issues with larger mailboxes. We're setting it to 1/3 of the total available memory (physical mem + swap) to be sure. See here for discussion: - -

/etc/dovecot/conf.d/10-master.conf (change settings)

The inotify max_user_instances default is 128, which constrains the total number of watched (IMAP IDLE push) folders by open connections. See A reboot is required for this to take effect (which we don't do as as a part of setup). Test with cat /proc/sys/fs/inotify/max_user_instances.

/etc/sysctl.conf (change settings)

Set the location where we'll store user mailboxes. '%d' is the domain name and '%n' is the username part of the user's email address. We'll ensure that no bad domains or email addresses are created within the management daemon.

/etc/dovecot/conf.d/10-mail.conf (change settings)

Create, subscribe, and mark as special folders: INBOX, Drafts, Sent, Trash, Spam and Archive.

cp conf/dovecot-mailboxes.conf /etc/dovecot/conf.d/15-mailboxes.conf


Require that passwords are sent over SSL only, and allow the usual IMAP authentication mechanisms. The LOGIN mechanism is supposedly for Microsoft products like Outlook to do SMTP login (I guess since we're using Dovecot to handle SMTP authentication?).

/etc/dovecot/conf.d/10-auth.conf (change settings)
auth_mechanisms=plain login

Enable SSL, specify the location of the SSL certificate and private key files. Disable obsolete SSL protocols and allow only good ciphers per Enable strong ssl dh parameters

/etc/dovecot/conf.d/10-ssl.conf (change settings)
ssl_prefer_server_ciphers = yes
ssl_dh_parameters_length = 2048

Disable in-the-clear IMAP/POP because there is no reason for a user to transmit login credentials outside of an encrypted connection. Only the over-TLS versions are made available (IMAPS on port 993; POP3S on port 995).

sed -i "s/#port = 143/port = 0/" /etc/dovecot/conf.d/10-master.conf
sed -i "s/#port = 110/port = 0/" /etc/dovecot/conf.d/10-master.conf

Make IMAP IDLE slightly more efficient. By default, Dovecot says "still here" every two minutes. With K-9 mail, the bandwidth and battery usage due to this are minimal. But for good measure, let's go to 4 minutes to halve the bandwidth and number of times the device's networking might be woken up. The risk is that if the connection is silent for too long it might be reset by a peer. See #129 and How bad is IMAP IDLE.

/etc/dovecot/conf.d/20-imap.conf (change settings)
imap_idle_notify_interval=4 mins

Set POP3 UIDL. UIDLs are used by POP3 clients to keep track of what messages they've downloaded. For new POP3 servers, the easiest way to set up UIDLs is to use IMAP's UIDVALIDITY and UID values, the default in Dovecot.

/etc/dovecot/conf.d/20-pop3.conf (change settings)


Enable Dovecot's LDA service with the LMTP protocol. It will listen on port 10026, and Spamassassin will be configured to pass mail there.

The disabled unix socket listener is normally how Postfix and Dovecot would communicate (see the Postfix setup script for the corresponding setting also commented out).

Also increase the number of allowed IMAP connections per mailbox because we all have so many devices lately.

/etc/dovecot/conf.d/99-local.conf (overwrite)
service lmtp {
  #unix_listener /var/spool/postfix/private/dovecot-lmtp {
  #  user = postfix
  #  group = postfix
  inet_listener lmtp {
    address =
    port = 10026

# Enable imap-login on localhost to allow the user_external plugin
# for Nextcloud to do imap authentication. (See #1577)
service imap-login {
  inet_listener imap {
    address =
    port = 143
protocol imap {
  mail_max_userip_connections = 20

Setting a postmaster_address is required or LMTP won't start. An alias will be created automatically by our management daemon.

/etc/dovecot/conf.d/15-lda.conf (change settings)


Enable the Dovecot sieve plugin which let's users run scripts that process mail as it comes in.

sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins sieve/" /etc/dovecot/conf.d/20-lmtp.conf

Configure sieve. We'll create a global script that moves mail marked as spam by Spamassassin into the user's Spam folder.

  • sieve_before: The path to our global sieve which handles moving spam to the Spam folder.

  • sieve_before2: The path to our global sieve directory for sieve which can contain .sieve files to run globally for every user before their own sieve files run.

  • sieve_after: The path to our global sieve directory which can contain .sieve files to run globally for every user after their own sieve files run.

  • sieve: The path to the user's main active script. ManageSieve will create a symbolic link here to the actual sieve script. It should not be in the mailbox directory (because then it might appear as a folder) and it should not be in the sieve_dir (because then I suppose it might appear to the user as one of their scripts).

  • sieve_dir: Directory for :personal include scripts for the include extension. This is also where the ManageSieve service stores the user's scripts.
/etc/dovecot/conf.d/99-local-sieve.conf (overwrite)
plugin {
  sieve_before = /etc/dovecot/sieve-spam.sieve
  sieve_before2 = $STORE/mail/sieve/global_before
  sieve_after = $STORE/mail/sieve/global_after
  sieve = $STORE/mail/sieve/%d/%n.sieve
  sieve_dir = $STORE/mail/sieve/%d/%n

Copy the global sieve script into where we've told Dovecot to look for it. Then compile it. Global scripts must be compiled now because Dovecot won't have permission later.

cp conf/sieve-spam.txt /etc/dovecot/sieve-spam.sieve
sievec /etc/dovecot/sieve-spam.sieve


Ensure configuration files are owned by dovecot and not world readable.

chown -R mail:dovecot /etc/dovecot
chmod -R o-rwx /etc/dovecot

Ensure mailbox files have a directory that exists and are owned by the mail user.

mkdir -p $STORE/mail/mailboxes
chown -R mail.mail $STORE/mail/mailboxes

Same for the sieve scripts.

mkdir -p $STORE/mail/sieve
mkdir -p $STORE/mail/sieve/global_before
mkdir -p $STORE/mail/sieve/global_after
chown -R mail.mail $STORE/mail/sieve

Allow the IMAP/POP ports in the firewall.

ufw allow imaps
ufw allow pop3s

Allow the Sieve port in the firewall.

ufw allow sieve

Restart services.

service dovecot restart
view the bash source for the following section at setup/

User Authentication and Destination Validation

This script configures user authentication for Dovecot and Postfix (which relies on Dovecot) and destination validation by quering an Sqlite3 database of mail users.

User and Alias Database

The database of mail users (i.e. authenticated users, who have mailboxes) and aliases (forwarders).


Create an empty database if it doesn't yet exist.

| sqlite3 $db_path
echo "\"CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL UNIQUE, destination TEXT NOT NULL, permitted_senders TEXT);\"" \
| sqlite3 $db_path

User Authentication

Have Dovecot query our database, and not system users, for authentication.

sed -i "s/#*(!include auth-system.conf.ext)/#1/" /etc/dovecot/conf.d/10-auth.conf
sed -i "s/#(!include auth-sql.conf.ext)/1/" /etc/dovecot/conf.d/10-auth.conf

Specify how the database is to be queried for user authentication (passdb) and where user mailboxes are stored (userdb).

/etc/dovecot/conf.d/auth-sql.conf.ext (overwrite)
passdb {
  driver = sql
  args = /etc/dovecot/dovecot-sql.conf.ext
userdb {
  driver = sql
  args = /etc/dovecot/dovecot-sql.conf.ext

Configure the SQL to query for a user's metadata and password.

/etc/dovecot/dovecot-sql.conf.ext (overwrite)
driver = sqlite
connect = $db_path
default_pass_scheme = SHA512-CRYPT
password_query = SELECT email as user, password FROM users WHERE email='%u';
user_query = SELECT email AS user, "mail" as uid, "mail" as gid, "$STORE/mail/mailboxes/%d/%n" as home FROM users WHERE email='%u';
iterate_query = SELECT email AS user FROM users;
chmod 0600 /etc/dovecot/dovecot-sql.conf.ext # per Dovecot instructions

Have Dovecot provide an authorization service that Postfix can access & use.

/etc/dovecot/conf.d/99-local-auth.conf (overwrite)
service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0666
    user = postfix
    group = postfix

And have Postfix use that service. We disable it here so that authentication is not permitted on port 25 (which does not run DKIM on relayed mail, so outbound mail isn't correct, see #830), but we enable it specifically for the submission port.

/etc/postfix/ (change settings)

Sender Validation

We use Postfix's reject_authenticated_sender_login_mismatch filter to prevent intra-domain spoofing by logged in but untrusted users in outbound email. In all outbound mail (the sender has authenticated), the MAIL FROM address (aka envelope or return path address) must be "owned" by the user who authenticated. An SQL query will find who are the owners of any given address.

/etc/postfix/ (change settings)

Postfix will query the exact address first, where the priority will be alias records first, then user records. If there are no matches for the exact address, then Postfix will query just the domain part, which we call catch-alls and domain aliases. A NULL permitted_senders column means to take the value from the destination column.

/etc/postfix/ (overwrite)
query = SELECT permitted_senders FROM (SELECT permitted_senders, 0 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NOT NULL UNION SELECT destination AS permitted_senders, 1 AS priority FROM aliases WHERE source='%s' AND permitted_senders IS NULL UNION SELECT email as permitted_senders, 2 AS priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;

Destination Validation

Use a Sqlite3 database to check whether a destination email address exists, and to perform any email alias rewrites in Postfix.

/etc/postfix/ (change settings)

SQL statement to check if we handle incoming mail for a domain, either for users or aliases.

/etc/postfix/ (overwrite)
query = SELECT 1 FROM users WHERE email LIKE '%%@%s' UNION SELECT 1 FROM aliases WHERE source LIKE '%%@%s'

SQL statement to check if we handle incoming mail for a user.

/etc/postfix/ (overwrite)
query = SELECT 1 FROM users WHERE email='%s'

SQL statement to rewrite an email address if an alias is present.

Postfix makes multiple queries for each incoming mail. It first queries the whole email address, then just the user part in certain locally-directed cases (but we don't use this), then just @+the domain part. The first query that returns something wins. See

virtual-alias-maps has precedence over virtual-mailbox-maps, but we don't want catch-alls and domain aliases to catch mail for users that have been defined on those domains. To fix this, we not only query the aliases table but also the users table when resolving aliases, i.e. we turn users into aliases from themselves to themselves. That means users will match in postfix's first query before postfix gets to the third query for catch-alls/domain alises.

If there is both an alias and a user for the same address either might be returned by the UNION, so the whole query is wrapped in another select that prioritizes the alias definition to preserve postfix's preference for aliases for whole email addresses.

Since we might have alias records with an empty destination because it might have just permitted_senders, skip any records with an empty destination here so that other lower priority rules might match.

/etc/postfix/ (overwrite)
query = SELECT destination from (SELECT destination, 0 as priority FROM aliases WHERE source='%s' AND destination<>'' UNION SELECT email as destination, 1 as priority FROM users WHERE email='%s') ORDER BY priority LIMIT 1;

Restart Services

service postfix restart
service dovecot restart
view the bash source for the following section at setup/


OpenDKIM provides a service that puts a DKIM signature on outbound mail.

The DNS configuration for DKIM is done in the management daemon.

Install DKIM...

apt-get install -y opendkim opendkim-tools opendmarc

Make sure configuration directories exist.

mkdir -p /etc/opendkim
mkdir -p $STORE/mail/dkim

Used in InternalHosts and ExternalIgnoreList configuration directives. Not quite sure why.

echo > /etc/opendkim/TrustedHosts

We need to at least create these files, since we reference them later. Otherwise, opendkim startup will fail

touch /etc/opendkim/KeyTable
touch /etc/opendkim/SigningTable

Add various configuration options to the end of opendkim.conf.

/etc/opendkim.conf (append to)
MinimumKeyBits          1024
ExternalIgnoreList      refile:/etc/opendkim/TrustedHosts
InternalHosts           refile:/etc/opendkim/TrustedHosts
KeyTable                refile:/etc/opendkim/KeyTable
SigningTable            refile:/etc/opendkim/SigningTable
Socket                  inet:8891@
RequireSafeKeys         false

Create a new DKIM key. This creates mail.private and mail.txt in $STORE/mail/dkim. The former is the private key and the latter is the suggested DNS TXT entry which we'll include in our DNS setup. Note that the files are named after the 'selector' of the key, which we can change later on to support key rotation.

A 1024-bit key is seen as a minimum standard by several providers such as Google. But they and others use a 2048 bit key, so we'll do the same. Keys beyond 2048 bits may exceed DNS record limits.

opendkim-genkey -b 2048 -r -s mail -D $STORE/mail/dkim

Ensure files are owned by the opendkim user and are private otherwise.

chown -R opendkim:opendkim $STORE/mail/dkim
chmod go-rwx $STORE/mail/dkim
/etc/opendmarc.conf (change settings)
Syslog true
Socket inet:8893@[]

Add OpenDKIM and OpenDMARC as milters to postfix, which is how OpenDKIM intercepts outgoing mail to perform the signing (by adding a mail header) and how they both intercept incoming mail to add Authentication-Results headers. The order possibly/probably matters: OpenDMARC relies on the OpenDKIM Authentication-Results header already being present.

Be careful. If we add other milters later, this needs to be concatenated on the smtpd_milters line.

The OpenDMARC milter is skipped in the SMTP submission listener by configuring smtpd_milters there to only list the OpenDKIM milter (see

/etc/postfix/ (change settings)
smtpd_milters=inet: inet:

We need to explicitly enable the opendmarc service, or it will not start

systemctl enable opendmarc

Restart services.

service opendkim restart
service opendmarc restart
service postfix restart
view the bash source for the following section at setup/

Spam filtering with spamassassin via spampd

spampd sits between postfix and dovecot. It takes mail from postfix over the LMTP protocol, runs spamassassin on it, and then passes the message over LMTP to dovecot for local delivery.

In order to move spam automatically into the Spam folder we use the dovecot sieve plugin.

Install packages and basic configuration

Install packages. libmail-dkim-perl is needed to make the spamassassin DKIM module work. For more information see Debian Bug #689414:

apt-get install -y spampd razor pyzor dovecot-antispam libmail-dkim-perl

Allow spamassassin to download new rules.

/etc/default/spamassassin (change settings)

Configure pyzor, which is a client to a live database of hashes of spam emails. Set the pyzor configuration directory to something sane. The default is ~/.pyzor. We used to use that, so we'll kill that old directory. Then write the public pyzor server to its servers file. That will prevent an automatic download on first use, and also means we can skip 'pyzor discover', both of which are currently broken by something happening on Sourceforge (#496).

rm -rf ~/.pyzor
/etc/spamassassin/ (change settings)
pyzor_options --homedir /etc/spamassassin/pyzor
mkdir -p /etc/spamassassin/pyzor
echo > /etc/spamassassin/pyzor/servers

check with: pyzor --homedir /etc/mail/spamassassin/pyzor ping

Configure spampd: * Pass messages on to docevot on port 10026. This is actually the default setting but we don't want to lose track of it. (We've configured Dovecot to listen on this port elsewhere.) * Increase the maximum message size of scanned messages from the default of 64KB to 500KB, which is Spamassassin (spamc)'s own default. Specified in KBytes. * Disable localmode so Pyzor, DKIM and DNS checks can be used.

/etc/default/spampd (change settings)

Spamassassin normally wraps spam as an attachment inside a fresh email with a report about the message. This also protects the user from accidentally openening a message with embedded malware.

It's nice to see what rules caused the message to be marked as spam, but it's also annoying to get to the original message when it is an attachment, modern mail clients are safer now and don't load remote content or execute scripts, and it is probably confusing to most users.

Tell Spamassassin not to modify the original message except for adding the X-Spam-Status & X-Spam-Score mail headers and related headers.

/etc/spamassassin/ (change settings)
report_safe 0
add_header all Report _REPORT_
add_header all Score _SCORE_

Bayesean learning

Spamassassin can learn from mail marked as spam or ham, but it needs to be configured. We'll store the learning data in our storage area.

These files must be:

  • Writable by sa-learn-pipe script below, which run as the 'mail' user, for manual tagging of mail as spam/ham.
  • Readable by the spampd process ('spampd' user) during mail filtering.
  • Writable by the debian-spamd user, which runs /etc/cron.daily/spamassassin.

We'll have these files owned by spampd and grant access to the other two processes.

Spamassassin will change the access rights back to the defaults, so we must also configure the filemode in the config file.

/etc/spamassassin/ (change settings)
bayes_path $STORE/mail/spamassassin/bayes
bayes_file_mode 0666
mkdir -p $STORE/mail/spamassassin
chown -R spampd:spampd $STORE/mail/spamassassin

To mark mail as spam or ham, just drag it in or out of the Spam folder. We'll use the Dovecot antispam plugin to detect the message move operation and execute a shell script that invokes learning.

Enable the Dovecot antispam plugin.

sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-imap.conf
sed -i "s/#mail_plugins = .*/mail_plugins = \$mail_plugins antispam/" /etc/dovecot/conf.d/20-pop3.conf

Configure the antispam plugin to call

/etc/dovecot/conf.d/99-local-spampd.conf (overwrite)
plugin {
    antispam_backend = pipe
    antispam_spam_pattern_ignorecase = SPAM
    antispam_trash_pattern_ignorecase = trash;Deleted *
    antispam_allow_append_to_spam = yes
    antispam_pipe_program_spam_args = /usr/local/bin/;--spam
    antispam_pipe_program_notspam_args = /usr/local/bin/;--ham
    antispam_pipe_program = /bin/bash

Have Dovecot run its mail process with a supplementary group (the spampd group) so that it can access the learning files.

/etc/dovecot/conf.d/10-mail.conf (change settings)

Here's the script that the antispam plugin executes. It spools the message into a temporary file and then runs sa-learn on it. from

/usr/local/bin/ (overwrite)
cat<&0 >> /tmp/sendmail-msg-$$.txt
/usr/bin/sa-learn $* /tmp/sendmail-msg-$$.txt > /dev/null
rm -f /tmp/sendmail-msg-$$.txt
exit 0
chmod a+x /usr/local/bin/

Create empty bayes training data (if it doesn't exist). Once the files exist, ensure they are group-writable so that the Dovecot process has access.

sudo -u spampd /usr/bin/sa-learn --sync 2>/dev/null
chmod -R 660 $STORE/mail/spamassassin
chmod 770 $STORE/mail/spamassassin

Initial training? sa-learn --ham storage/mail/mailboxes///cur/ sa-learn --spam storage/mail/mailboxes///.Spam/cur/

Kick services.

service spampd restart
service dovecot restart
view the bash source for the following section at setup/

HTTP: Turn on a web server serving static files

Some Ubuntu images start off with Apache. Remove it since we will use nginx. Use autoremove to remove any Apache depenencies.

apt-get -y purge apache2 apache2-*
apt-get -y --purge autoremove

Install nginx and a PHP FastCGI daemon.

Turn off nginx's default website.

apt-get install -y nginx php-cli php-fpm
rm -f /etc/nginx/sites-enabled/default

Copy in a nginx configuration file for common and best-practices SSL settings from @konklone. Replace STORAGE_ROOT so it can find the DH params.

rm -f /etc/nginx/nginx-ssl.conf # we used to put it here
sed s#STORAGE_ROOT#$STORE# conf/nginx-ssl.conf > /etc/nginx/conf.d/ssl.conf

Fix some nginx defaults. The server_names_hash_bucket_size seems to prevent long domain names! The default, according to nginx's docs, depends on "the size of the processor’s cache line." It could be as low as 32. We fixed it at 64 in 2014 to accommodate a long domain name (20 characters?). But even at 64, a 58-character domain name won't work (#93), so now we're going up to 128.

/etc/nginx/nginx.conf (change settings)
server_names_hash_bucket_size 128;

Tell PHP not to expose its version number in the X-Powered-By header.

/etc/php/7.2/fpm/php.ini (change settings)

Set PHPs default charset to UTF-8, since we use it. See #367.

/etc/php/7.2/fpm/php.ini (change settings)

Switch from the dynamic process manager to the ondemand manager see #1216

/etc/php/7.2/fpm/pool.d/www.conf (change settings)

Bump up PHP's max_children to support more concurrent connections

/etc/php/7.2/fpm/pool.d/www.conf (change settings)

Other nginx settings will be configured by the management service since it depends on what domains we're serving, which we don't know until mail accounts have been created.

Create the iOS/OS X Mobile Configuration file which is exposed via the nginx configuration at /mailinabox-mobileconfig.

mkdir -p /var/lib/mailinabox
chmod a+rx /var/lib/mailinabox
cat conf/ios-profile.xml | sed s/ | sed "s/UUID1/$(cat /proc/sys/kernel/random/uuid)/" | sed "s/UUID2/$(cat /proc/sys/kernel/random/uuid)/" | sed "s/UUID3/$(cat /proc/sys/kernel/random/uuid)/" | sed "s/UUID4/$(cat /proc/sys/kernel/random/uuid)/" > /var/lib/mailinabox/mobileconfig.xml
chmod a+r /var/lib/mailinabox/mobileconfig.xml

Create the Mozilla Auto-configuration file which is exposed via the nginx configuration at /.well-known/autoconfig/mail/config-v1.1.xml. The format of the file is documented at: and

cat conf/mozilla-autoconfig.xml | sed s/ > /var/lib/mailinabox/mozilla-autoconfig.xml
chmod a+r /var/lib/mailinabox/mozilla-autoconfig.xml

make a default homepage

mkdir -p $STORE/www/default
cp conf/www_default.html $STORE/www/default/index.html

Start services.

service nginx restart
service php7.2-fpm restart

Open ports.

ufw allow http
ufw allow https
view the bash source for the following section at setup/

Webmail with Roundcube

Installing Roundcube

We install Roundcube from sources, rather than from Ubuntu, because:

  1. Ubuntu's roundcube-core package has dependencies on Apache & MySQL, which we don't want.

  2. The Roundcube shipped with Ubuntu is consistently out of date.

  3. It's packaged incorrectly --- it seems to be missing a directory of files.

So we'll use apt-get to manually install the dependencies of roundcube that we know we need, and then we'll manually install roundcube from source.

These dependencies are from apt-cache showpkg roundcube-core.

apt-get install -y dbconfig-common php-cli php-sqlite3 php-intl php-json php-common php-curl php-gd php-pspell tinymce libjs-jquery libjs-jquery-mousewheel libmagic1 php-mbstring

Install Roundcube from source if it is not already present or if it is out of date. Combine the Roundcube version number with the commit hash of plugins to track whether we have the latest version of everything.


paths that are often reused.


checks if the version is what we want install roundcube

wget_verify$VERSION/roundcubemail-$VERSION-complete.tar.gz $HASH /tmp/roundcube.tgz
tar -C /usr/local/lib --no-same-owner -zxf /tmp/roundcube.tgz
rm -rf /usr/local/lib/roundcubemail
mv /usr/local/lib/roundcubemail-$VERSION/ $RCM_DIR
rm -f /tmp/roundcube.tgz

install roundcube persistent_login plugin

git_clone $PERSISTENT_LOGIN_VERSION ${RCM_PLUGIN_DIR}/persistent_login

install roundcube html5_notifier plugin

git_clone $HTML5_NOTIFIER_VERSION ${RCM_PLUGIN_DIR}/html5_notifier

download and verify the full release of the carddav plugin

wget_verify${CARDDAV_VERSION}/carddav-${CARDDAV_VERSION}.zip $CARDDAV_HASH /tmp/

unzip and cleanup

unzip -q /tmp/ -d ${RCM_PLUGIN_DIR}
rm -f /tmp/

record the version we've installed

echo $UPDATE_KEY > ${RCM_DIR}/version

Configuring Roundcube

Generate a safe 24-character secret key of safe characters.

SECRET_KEY=$(dd if=/dev/urandom bs=1 count=18 2>/dev/null | base64 | fold -w 24 | head -n 1)

Create a configuration file.

For security, temp and log files are not stored in the default locations which are inside the roundcube sources directory. We put them instead in normal places.

* Do not edit. Written by Mail-in-a-Box. Regenerated on updates.
\$config = array();
\$config[\'log_dir\'] = \'/var/log/roundcubemail/\';
\$config[\'temp_dir\'] = \'/var/tmp/roundcubemail/\';
\$config[\'db_dsnw\'] = \'sqlite:///$STORE/mail/roundcube/roundcube.sqlite?mode=0640\';
\$config[\'default_host\'] = \'ssl://localhost\';
\$config[\'default_port\'] = 993;
\$config[\'imap_conn_options\'] = array(
\'ssl\' => array(
\'verify_peer\' => false,
\'verify_peer_name\' => false,
\$config[\'imap_timeout\'] = 15;
\$config[\'smtp_server\'] = \'tls://\';
\$config[\'smtp_port\'] = 587;
\$config[\'smtp_user\'] = \'%u\';
\$config[\'smtp_pass\'] = \'%p\';
\$config[\'smtp_conn_options\'] = array(
\'ssl\' => array(
\'verify_peer\' => false,
\'verify_peer_name\' => false,
\$config[\'support_url\'] = \'\';
\$config[\'product_name\'] = \' Webmail\';
\$config[\'des_key\'] = \'$SECRET_KEY\';
\$config[\'plugins\'] = array(\'html5_notifier\', \'archive\', \'zipdownload\', \'password\', \'managesieve\', \'jqueryui\', \'persistent_login\', \'carddav\');
\$config[\'skin\'] = \'larry\';
\$config[\'login_autocomplete\'] = 2;
\$config[\'password_charset\'] = \'UTF-8\';
\$config[\'junk_mbox\'] = \'Spam\';

Configure CardDav

cat > ${RCM_PLUGIN_DIR}/carddav/ <<EOF
/* Do not edit. Written by Mail-in-a-Box. Regenerated on updates. */
\$prefs[\'_GLOBAL\'][\'hide_preferences\'] = true;
\$prefs[\'_GLOBAL\'][\'suppress_version_warning\'] = true;
\$prefs[\'ownCloud\'] = array(
\'name\' => \'ownCloud\',
\'username\' => \'%u\', // login username
\'password\' => \'%p\', // login password
\'url\' => \'https://${}/cloud/remote.php/carddav/addressbooks/%u/contacts\',
\'active\' => true,
\'readonly\' => false,
\'refresh_time\' => \'02:00:00\',
\'fixed\' => array(\'username\',\'password\'),
\'preemptive_auth\' => \'1\',
\'hide\' => false,

Create writable directories.

mkdir -p /var/log/roundcubemail /var/tmp/roundcubemail $STORE/mail/roundcube
chown -R www-data.www-data /var/log/roundcubemail /var/tmp/roundcubemail $STORE/mail/roundcube

Ensure the log file monitored by fail2ban exists, or else fail2ban can't start.

sudo -u www-data touch /var/log/roundcubemail/errors

Password changing plugin settings The config comes empty by default, so we need the settings we're not planning to change in

cp ${RCM_PLUGIN_DIR}/password/ ${RCM_PLUGIN_DIR}/password/
tools/ ${RCM_PLUGIN_DIR}/password/ \$config[\'password_minimum_length\']=8; \$config[\'password_db_dsn\']=\'sqlite:///$STORE/mail/users.sqlite\'; "\$config['password_query']='UPDATE users SET password=%D WHERE email=%u';" "\$config['password_dovecotpw']='/usr/bin/doveadm pw';" \$config[\'password_dovecotpw_method\']=\'SHA512-CRYPT\'; \$config[\'password_dovecotpw_with_method\']=true;

so PHP can use doveadm, for the password changing plugin

usermod -a -G dovecot www-data

set permissions so that PHP can use users.sqlite could use dovecot instead of www-data, but not sure it matters

chown root.www-data $STORE/mail
chmod 775 $STORE/mail
chown root.www-data $STORE/mail/users.sqlite
chmod 664 $STORE/mail/users.sqlite

Fix Carddav permissions:

chown -f -R root.www-data ${RCM_PLUGIN_DIR}/carddav

root.www-data need all permissions, others only read

chmod -R 774 ${RCM_PLUGIN_DIR}/carddav

Run Roundcube database migration script (database is created if it does not exist)

${RCM_DIR}/bin/ --dir ${RCM_DIR}/SQL --package roundcube
chown www-data:www-data $STORE/mail/roundcube/roundcube.sqlite
chmod 664 $STORE/mail/roundcube/roundcube.sqlite

Enable PHP modules.

phpenmod -v php mcrypt imap
service php7.2-fpm restart
view the bash source for the following section at setup/


Installing Nextcloud

apt-get purge -qq -y owncloud* # we used to use the package manager
apt-get install -y php php-fpm php-cli php-sqlite3 php-gd php-imap php-curl php-pear curl php-dev php-gd php-xml php-mbstring php-zip php-apcu php-json php-intl php-imagick
InstallNextcloud() {

Download and verify

wget_verify$ $hash /tmp/

Remove the current owncloud/Nextcloud

rm -rf /usr/local/lib/owncloud

Extract ownCloud/Nextcloud

unzip -q /tmp/ -d /usr/local/lib
mv /usr/local/lib/nextcloud /usr/local/lib/owncloud
rm -f /tmp/

The two apps we actually want are not in Nextcloud core. Download the releases from their github repositories.

mkdir -p /usr/local/lib/owncloud/apps
wget_verify a06bd967197dcb03c94ec1dbd698c037018669e5 /tmp/contacts.tgz
tar xf /tmp/contacts.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/contacts.tgz
wget_verify 79941255521a5172f7e4ce42dc7773838b5ede2f /tmp/calendar.tgz
tar xf /tmp/calendar.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/calendar.tgz

Starting with Nextcloud 15, the app user_external is no longer included in Nextcloud core, we will install from their github repository.

wget_verify 0f756d35fef6b64a177d6a16020486b76ea5799c /tmp/user_external.tgz
tar -xf /tmp/user_external.tgz -C /usr/local/lib/owncloud/apps/
rm /tmp/user_external.tgz

Fix weird permissions.

chmod 750 /usr/local/lib/owncloud/{apps,config}

Create a symlink to the config.php in STORAGE_ROOT (for upgrades we're restoring the symlink we previously put in, and in new installs we're creating a symlink and will create the actual config later).

ln -sf $STORE/owncloud/config.php /usr/local/lib/owncloud/config/config.php

Make sure permissions are correct or the upgrade step won't run. $STORE/owncloud may not yet exist, so use -f to suppress that error.

chown -f -R www-data.www-data $STORE/owncloud /usr/local/lib/owncloud || /bin/true

If this isn't a new installation, immediately run the upgrade script. Then check for success (0=ok and 3=no upgrade needed, both are success). ownCloud 8.1.1 broke upgrades. It may fail on the first attempt, but that can be OK.

sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
sudo -u www-data php /usr/local/lib/owncloud/occ upgrade
sudo -u www-data php /usr/local/lib/owncloud/occ maintenance:mode --off

Add missing indices. NextCloud didn't include this in the normal upgrade because it might take some time.

sudo -u www-data php /usr/local/lib/owncloud/occ db:add-missing-indices

Run conversion to BigInt identifiers, this process may take some time on large tables.

sudo -u www-data php /usr/local/lib/owncloud/occ db:convert-filecache-bigint --no-interaction

Nextcloud Version to install. Checks are done down below to step through intermediate versions.


Current Nextcloud Version, #1623 Checking /usr/local/lib/owncloud/version.php shows version of the Nextcloud application, not the DB $STORE/owncloud is kept together even during a backup. It is better to rely on config.php than version.php since the restore procedure can leave the system in a state where you have a newer Nextcloud application version than the database.

If config.php exists, get version number, otherwise CURRENT_NEXTCLOUD_VER is empty.

CURRENT_NEXTCLOUD_VER=$(php -r "include(\"$STORE/owncloud/config.php\"); echo(\$CONFIG['version']);)"

If the Nextcloud directory is missing (never been installed before, or the nextcloud version to be installed is different from the version currently installed, do the install/upgrade

Stop php-fpm if running. If theyre not running (which happens on a previously failed install), dont bail.

service php7.2-fpm stop &> /dev/null || /bin/true

Backup the existing ownCloud/Nextcloud. Create a backup directory to store the current installation and database to

BACKUP_DIRECTORY=$STORE/owncloud-backup/`date +%Y-%m-%d-%T`
cp -r /usr/local/lib/owncloud $BACKUP_DIRECTORY/owncloud-install
cp $STORE/owncloud/owncloud.db $BACKUP_DIRECTORY
cp $STORE/owncloud/config.php $BACKUP_DIRECTORY

If ownCloud or Nextcloud was previously installed.... Database migrations from ownCloud are no longer possible because ownCloud cannot be run under PHP 7.

exit 1
exit 1

If we are running Nextcloud 13, upgrade to Nextcloud 14

InstallNextcloud 14.0.6 4e43a57340f04c2da306c8eea98e30040399ae5a

During the upgrade from Nextcloud 14 to 15, user_external may cause the upgrade to fail. We will disable it here before the upgrade and install it again after the upgrade.

sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable user_external
InstallNextcloud $nextcloud_ver $nextcloud_hash

Configuring Nextcloud

Setup Nextcloud if the Nextcloud database does not yet exist. Running setup when the database does exist wipes the database and user data. Create user data directory

mkdir -p $STORE/owncloud

Create an initial configuration file.

instanceid=oc$(echo | sha1sum | fold -w 10 | head -n 1)
cat > $STORE/owncloud/config.php <<EOF
\$CONFIG = array (
\'datadirectory\' => \'$STORE/owncloud\',
\'instanceid\' => \'$instanceid\',
\'forcessl\' => true, # if unset/false, Nextcloud sends a HSTS=0 header, which conflicts with nginx config
\'overwritewebroot\' => \'/cloud\',
\'overwrite.cli.url\' => \'/cloud\',
\'user_backends\' => array(
\'class\' => \'OC_User_IMAP\',
\'arguments\' => array(
\'\', 143, null
\'memcache.local\' => \'OCMemcacheAPCu\',
\'mail_smtpmode\' => \'sendmail\',
\'mail_smtpsecure\' => \'\',
\'mail_smtpauthtype\' => \'LOGIN\',
\'mail_smtpauth\' => false,
\'mail_smtphost\' => \'\',
\'mail_smtpport\' => \'\',
\'mail_smtpname\' => \'\',
\'mail_smtppassword\' => \'\',
\'mail_from_address\' => \'owncloud\',

Create an auto-configuration file to fill in database settings when the install script is run. Make an administrator account here or else the install can't finish.

adminpassword=$(dd if=/dev/urandom bs=1 count=40 2>/dev/null | sha1sum | fold -w 30 | head -n 1)
/usr/local/lib/owncloud/config/autoconfig.php (overwrite)
$AUTOCONFIG = array (
  # storage/database
  'directory' => '$STORE/owncloud',
  'dbtype' => 'sqlite3',

  # create an administrator account with a random password so that
  # the user does not have to enter anything on first load of Nextcloud
  'adminlogin'    => 'root',
  'adminpass'     => '$adminpassword',

Set permissions

chown -R www-data.www-data $STORE/owncloud /usr/local/lib/owncloud

Execute Nextcloud's setup step, which creates the Nextcloud sqlite database. It also wipes it if it exists. And it updates config.php with database settings and deletes the autoconfig.php file.

(cd /usr/local/lib/owncloud; sudo -u www-data php /usr/local/lib/owncloud/index.php;)

Update config.php. * trusted_domains is reset to localhost by autoconfig starting with ownCloud 8.1.1, so set it here. It also can change if the box's changes, so this will make sure it has the right value. * Some settings weren't included in previous versions of Mail-in-a-Box. * We need to set the timezone to the system timezone to allow fail2ban to ban users within the proper timeframe * We need to set the logdateformat to something that will work correctly with fail2ban * mail_domain' needs to be set every time we run the setup. Making sure we are setting the correct domain name if the domain is being change from the previous setup. Use PHP to read the settings file, modify it, and write out the new settings array.

TIMEZONE=$(cat /etc/timezone)
php <<EOF > $CONFIG_TEMP && mv $CONFIG_TEMP $STORE/owncloud/config.php
\$CONFIG[trusted_domains] = array(
\$CONFIG[memcache.local] = OCMemcacheAPCu
\$CONFIG[overwrite.cli.url] = /cloud
\$CONFIG[mail_from_address] = administrator

just the local part, matches our master administrator address

\$CONFIG[logtimezone] = $TIMEZONE
\$CONFIG[logdateformat] = "Y-m-d H:i:s"
\$CONFIG[mail_domain] =
\$CONFIG[user_backends] = array(array(class => OC_User_IMAP,arguments => array(, 143, null),),)
chown www-data.www-data $STORE/owncloud/config.php

Enable/disable apps. Note that this must be done after the Nextcloud setup. The firstrunwizard gave Josh all sorts of problems, so disabling that. user_external is what allows Nextcloud to use IMAP for login. The contacts and calendar apps are the extensions we really care about here.

sudo -u www-data php /usr/local/lib/owncloud/console.php app:disable firstrunwizard
sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable user_external
sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable contacts
sudo -u www-data php /usr/local/lib/owncloud/console.php app:enable calendar

When upgrading, run the upgrade script again now that apps are enabled. It seems like the first upgrade at the top won't work because apps may be disabled during upgrade? Check for success (0=ok, 3=no upgrade needed).

sudo -u www-data php /usr/local/lib/owncloud/occ upgrade

Set PHP FPM values to support large file uploads (semicolon is the comment character in this file, hashes produce deprecation warnings)

/etc/php/7.2/fpm/php.ini (change settings)

Set Nextcloud recommended opcache settings

/etc/php/7.2/cli/conf.d/10-opcache.ini (change settings)

Configure the path environment for php-fpm

/etc/php/7.2/fpm/pool.d/www.conf (change settings)

If apc is explicitly disabled we need to enable it

tools/ /etc/php/7.2/mods-available/apcu.ini -c ; apc.enabled=1

Set up a cron job for Nextcloud.

/etc/cron.hourly/mailinabox-owncloud (overwrite)
# Mail-in-a-Box
sudo -u www-data php -f /usr/local/lib/owncloud/cron.php
chmod +x /etc/cron.hourly/mailinabox-owncloud

There's nothing much of interest that a user could do as an admin for Nextcloud, and there's a lot they could mess up, so we don't make any users admins of Nextcloud. But if we wanted to, we would do this: for user in $(tools/ user admins); do sqlite3 $STORE/owncloud/owncloud.db "INSERT OR IGNORE INTO oc_group_user VALUES ('admin', '$user')" done

Enable PHP modules and restart PHP.

service php7.2-fpm restart
view the bash source for the following section at setup/

Z-Push: The Microsoft Exchange protocol server

Mostly for use on iOS which doesn't support IMAP IDLE.

Although Ubuntu ships Z-Push (as d-push) it has a dependency on Apache so we won't install it that way.

Thanks to


apt-get install -y php-soap php-imap libawl-php php-xsl
phpenmod -v php imap

Copy Z-Push into place.


checks if the version Download

wget_verify$VERSION&format=zip $TARGETHASH /tmp/

Extract into place.

rm -rf /usr/local/lib/z-push /tmp/z-push
unzip -q /tmp/ -d /tmp/z-push
mv /tmp/z-push/src /usr/local/lib/z-push
rm -rf /tmp/ /tmp/z-push
rm -f /usr/sbin/z-push-{admin,top}
ln -s /usr/local/lib/z-push/z-push-admin.php /usr/sbin/z-push-admin
ln -s /usr/local/lib/z-push/z-push-top.php /usr/sbin/z-push-top
echo $VERSION > /usr/local/lib/z-push/version

Configure default config.

sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/config.php
sed -i "s/define('BACKEND_PROVIDER', .*/define('BACKEND_PROVIDER', 'BackendCombined');/" /usr/local/lib/z-push/config.php
sed -i "s/define('USE_FULLEMAIL_FOR_LOGIN', .*/define('USE_FULLEMAIL_FOR_LOGIN', true);/" /usr/local/lib/z-push/config.php
sed -i "s/define('LOG_MEMORY_PROFILER', .*/define('LOG_MEMORY_PROFILER', false);/" /usr/local/lib/z-push/config.php
sed -i "s/define('BUG68532FIXED', .*/define('BUG68532FIXED', false);/" /usr/local/lib/z-push/config.php
sed -i "s/define('LOGLEVEL', .*/define('LOGLEVEL', LOGLEVEL_ERROR);/" /usr/local/lib/z-push/config.php

Configure BACKEND

rm -f /usr/local/lib/z-push/backend/combined/config.php
cp conf/zpush/backend_combined.php /usr/local/lib/z-push/backend/combined/config.php

Configure IMAP

rm -f /usr/local/lib/z-push/backend/imap/config.php
cp conf/zpush/backend_imap.php /usr/local/lib/z-push/backend/imap/config.php
sed -i s%STORAGE_ROOT%$STORE% /usr/local/lib/z-push/backend/imap/config.php

Configure CardDav

rm -f /usr/local/lib/z-push/backend/carddav/config.php
cp conf/zpush/backend_carddav.php /usr/local/lib/z-push/backend/carddav/config.php

Configure CalDav

rm -f /usr/local/lib/z-push/backend/caldav/config.php
cp conf/zpush/backend_caldav.php /usr/local/lib/z-push/backend/caldav/config.php

Configure Autodiscover

rm -f /usr/local/lib/z-push/autodiscover/config.php
cp conf/zpush/autodiscover_config.php /usr/local/lib/z-push/autodiscover/config.php
sed -i s/ /usr/local/lib/z-push/autodiscover/config.php
sed -i "s^define('TIMEZONE', .*^define('TIMEZONE', '$(cat /etc/timezone)');^" /usr/local/lib/z-push/autodiscover/config.php

Some directories it will use.

mkdir -p /var/log/z-push
mkdir -p /var/lib/z-push
chmod 750 /var/log/z-push
chmod 750 /var/lib/z-push
chown www-data:www-data /var/log/z-push
chown www-data:www-data /var/lib/z-push

Add log rotation

/etc/logrotate.d/z-push (overwrite)
/var/log/z-push/*.log {
    rotate 52

Restart service.

service php7.2-fpm restart

Fix states after upgrade

z-push-admin -a fixstates
view the bash source for the following section at setup/

Munin: resource monitoring tool

install Munin

apt-get install -y munin munin-node libcgi-fast-perl

libcgi-fast-perl is needed by /usr/lib/munin/cgi/munin-cgi-graph

edit config

/etc/munin/munin.conf (overwrite)
dbdir /var/lib/munin
htmldir /var/cache/munin/www
logdir /var/log/munin
rundir /var/run/munin
tmpldir /etc/munin/templates

includedir /etc/munin/munin-conf.d

# path dynazoom uses for requests
cgiurl_graph /admin/munin/cgi-graph

# a simple host tree

# send alerts to the following address
contacts admin
contact.admin.command mail -s "Munin notification ${var:host}"
contact.admin.always_send warning critical

The Debian installer touches these files and chowns them to www-data:adm for use with spawn-fcgi

chown munin. /var/log/munin/munin-cgi-html.log
chown munin. /var/log/munin/munin-cgi-graph.log

ensure munin-node knows the name of this machine and reduce logging level to warning

/etc/munin/munin-node.conf (change settings)
log_level 1

Update the activated plugins through munin's autoconfiguration.

munin-node-configure --shell --remove-also 2>/dev/null | sh || /bin/true

Deactivate monitoring of NTP peers. Not sure why anyone would want to monitor a NTP peer. The addresses seem to change (which is taken care of my munin-node-configure, but only when we re-run it.)nd /etc/munin/plugins/ -lname /usr/share/munin/plugins/ntp_ -print0 | xargs -0 /bin/rm -f

Deactivate monitoring of network interfaces that are not up. Otherwise we can get a lot of empty charts.

for f in $(find /etc/munin/plugins/ ( -lname /usr/share/munin/plugins/if_ -o -lname /usr/share/munin/plugins/if_err_ -o -lname /usr/share/munin/plugins/bonding_err_ ))
IF=$(echo $f | sed s/.*_//)
rm $f

Create a 'state' directory. Not sure why we need to do this manually.

mkdir -p /var/lib/munin-node/plugin-state/

Create a systemd service for munin.

ln -sf $(pwd)/management/ /usr/local/lib/mailinabox/
chmod 0744 /usr/local/lib/mailinabox/
systemctl link -f conf/munin.service
systemctl daemon-reload
systemctl unmask munin.service
systemctl enable munin.service

Restart services.

service munin restart
service munin-node restart

generate initial statistics so the directory isn't empty (We get "Pango-WARNING **: error opening config file '/root/.config/pango/pangorc': Permission denied" if we don't explicitly set the HOME directory when sudo'ing.) We check to see if munin-cron is already running, if it is, there is no need to run it simultaneously generating an error.

sudo -H -u munin munin-cron