Private server setup

This is documentation of this system's ( setup. It is a private multi-user system with commonly used network services: email, XMPP, web publishing, and a few others.

My motivation for setting such a system is use of quality services with control over them, as well as fun and learning. The purpose of this documentation is to share some tips and experience with others, and to simplify potential future migration.

The guiding principles are use of libre, relatively well-maintained, reliable, secure, and lightweight software; open, standardised, federated, commonly supported network protocols. Generally that which works well, is useful, and will likely stay that way for a while.



Additional maintainers, backup servers, and domain names may be useful, but their availability is not assumed.

Operating system and software choices

Debian GNU/Linux is a good, well-maintained system; its stable branch is appropriate for low-maintenance systems.

The software used here is fairly common and should be easily available from system repositories on other common POSIX systems, though the versions, default configuration, paths, and other minor details may differ. Which is one of the reasons why this is a human-readable document, and not just a collection of ansible roles: it should be relatively easy to adapt for other systems and requirements.

The software is often listed along with alternatives. Security track records are available for some of it: knot DNS CVEs, nginx CVEs, cgit CVEs, Dovecot CVEs, Postfix CVEs, mlmmj CVEs, MHOnArc CVEs, Prosody CVEs, znc CVEs, fail2ban CVEs. Additionally, there are vulnerabilities in other common daemons (e.g., OpenSSH CVEs, Rsyslog CVEs), libraries (OpenSSL CVEs, plenty of others), kernel itself (Linux kernel CVEs).

On migration

If migrating (and not just setting a new server), services can be moved and tested one by one, by changing corresponding DNS records. Some migration tips are included here, the described system changed hosters a couple of times.

Ideally there shouldn't be any sensitive data stored unencrypted on a server, and passwords should be changed after migration. Though it's not always practical, and data wiping on the old server may be needed. shred(1) might work, though not on file systems with journaling, snapshots, etc. One can try to overwrite disks with random data using either rand(1ssl) (openssl rand -out random-file $(( 2**30 ))) or dd(1) (dd if=/dev/urandom of=random-file bs=1M count=1024), though it won't overwrite bad sectors. And usually it's not practical to destroy the disks physically either. But a combination of all the approaches (and especially not storing or even having any sensitive data) reduces chances of a data breach.

Initial setup

It is a good idea to follow RFC 1178 (Choosing a Name for Your Computer). The theme here is pastries: "muffin", "cupcake", and now "tart".

Usually root access is given initially, so the first thing to do is to add a user or few, and SSH keys for them:

root@tart:~# adduser defanor
root@tart:~# usermod -G sudo defanor
root@tart:~# su defanor
defanor@tart:/root$ mkdir ~/.ssh
defanor@tart:/root$ vi ~/.ssh/authorized_keys

Then log out, log in as the user (to ensure that it's set properly), and edit /etc/ssh/sshd_config to prohibit password authentication and root login:

--- sshd_config 2020-12-15 15:38:05.088873498 +0100
+++ /etc/ssh/sshd_config        2020-12-16 01:50:40.567136824 +0100
@@ -29,7 +29,7 @@
 # Authentication:
 #LoginGraceTime 2m
-PermitRootLogin yes
+PermitRootLogin no
 #StrictModes yes
 #MaxAuthTries 6
 #MaxSessions 10
@@ -53,7 +53,7 @@
 #IgnoreRhosts yes
 # To disable tunneled clear text passwords, change to no here!
-#PasswordAuthentication yes
+PasswordAuthentication no
 #PermitEmptyPasswords no
 # Change to yes to enable challenge-response passwords (beware issues with

Update package lists and the system at once: sudo apt update && sudo apt upgrade.

Whether for resolv.conf(5) or for a local caching DNS server, the choice of name servers is challenging: it's good if a hoster (or an ISP) provides a decent DNS server, but it's useful to set a backup server anyway, especially if there will be outgoing connections or use of email blacklists. One should be careful and check that those are fast, don't cut out less common record types, support DNSSEC. Many are rather poor, but it isn't nice to use (and load) root servers directly. It isn't great for everyone to depend on a few major companies' servers either, but fortunately there's quite a few options, and they can be combined easily: e.g., hoster's DNS, followed by OpenDNS, Level3, Hurricane Electric, and/or Verisign. Generally larger networking-related companies tend to be relatively reliable, and I'd skip a couple of largest ones -- both to reduce centralisation, and because those are rather unpleasant companies (Google and Cloudflare) at the time of writing. /etc/resolv.conf may be updated by different means, but when relying on DHCP for network configuration, it's updated by dhclient(1). Either static or additional DNS servers can be set in /etc/dhcp/dhclient.conf:

--- dhclient.conf       2020-12-20 23:15:32.997683197 +0100
+++ /etc/dhcp/dhclient.conf     2020-12-20 23:33:11.071832933 +0100
@@ -19,6 +19,9 @@
        netbios-name-servers, netbios-scope, interface-mtu,
        rfc3442-classless-static-routes, ntp-servers;
+# Use OpenDNS as a backup.
+append domain-name-servers;
 #send dhcp-client-identifier 1:0:a0:24:ab:fb:9c;
 #send dhcp-lease-time 3600;
 #supersede domain-name "";

Authoritative name server

DNSSEC support is both useful by itself and needed for DANE (RFC 6693). RFC 2136 (dynamic DNS updates) support is preferable for automation of X.509 certificate acquisition with ACME (as covered in the next section). Some of the suitable options are BIND and knot DNS. Install knot DNS and generate a key we'll use for dynamic updates:

$ sudo apt install knot knot-dnsutils
$ sudo keymgr -t certbot hmac-sha512

Edit /etc/knot/knot.conf (here and further in the text, replace SECRET_TSIG_KEY with the generated key), here we're also setting as a secondary (slave) nameserver:

--- knot.conf   2020-12-16 00:13:08.259552803 +0100
+++ /etc/knot/knot.conf 2020-12-23 10:28:07.177978868 +0100
@@ -4,7 +4,7 @@
     rundir: "/run/knot"
     user: knot:knot
-    listen: [, ::1@53 ]
+    listen: [, ::@53 ]
   - target: syslog
@@ -17,7 +17,17 @@
 #  - id: master
 #    address:
+  - id: certbot
+    algorithm: hmac-sha512
+    secret: SECRET_TSIG_KEY
+  - id: update_acl
+    address: [, ::1]
+    action: update
+    key: certbot
 #  - id: acl_slave
 #    address:
 #    action: transfer
@@ -31,7 +41,25 @@
     storage: "/var/lib/knot"
     file: ""
+  - id: gandi
+    address: [, 2001:4b98:d:1::40]
+  - id: gandi_slave_acl
+    address: [, 2001:4b98:d:1::40]
+    action: transfer
+  - domain:
+    file:
+    notify: gandi
+    acl: [update_acl, gandi_slave_acl]
+    zonefile-load: difference
+    dnssec-signing: on
+    semantic-checks: on
 #    # Master zone
 #  - domain:
 #    notify: slave

Add records into the zone file, /var/lib/knot/ Fingerprints for SSHFP records can be obtained with ssh-keygen -r, and a DKIM key will be in /etc/dkimkeys/tart2020.txt once we'll set OpenDKIM (see below; the key itself is not included here for being too long); is referenced in many CNAME records, but will be set in the next section, once we'll have an X.509 certificate.      	10800	SOA 123 43200 7200 2419200 86400      	10800	NS      	10800	NS      	10800	A      	10800	AAAA	2a01:4f8:1c0c:73c7::1      	10800	MX	10      	10800	TXT	"v=spf1 a mx ~all"      	10800	CAA	0 issue ""	10800	TXT	"v=DMARC1; p=quarantine"	10800	TXT	"dkim=all" 10800	TXT	"v=DKIM1; h=sha256; k=rsa; p=LONG_KEY_HERE"	10800	CNAME	10800	CNAME	10800	CNAME	10800	CNAME	10800	CNAME	10800	CNAME	10800	CNAME	10800	SRV	10 5 143	10800	SRV	10 5 993	10800	SRV	10 5 587 10800	SRV	10 5 5222 10800	SRV	10 5 5269	10800	TXT	"_xmpp-client-xbosh="	10800	TXT	"_xmpp-client-websocket=wss://" 10800	CNAME 10800	SRV	10 5 5269	10800	CNAME 10800	CNAME  	10800	CNAME	10800	CNAME	10800	A	10800	AAAA	2a01:4f8:1c0c:73c7::1	10800	MX	10	10800	TXT	"v=spf1"	10800	CNAME	10800	CNAME 10800	CNAME	10800	CNAME	10800	CNAME 	10800	SSHFP	1 2 210A803DE18A5652E77AE4871A31BD07D272725B145A709F031F867402BA49EB 	10800	SSHFP	2 2 15431F1E626734EB9B8490EDA624E557493920F1FAF22FC7845E3971F2973DEB 	10800	SSHFP	3 2 04D5812158B0B61F2808845370DE5C137F75224B833B85A0AE76127CBF642433 	10800	SSHFP	4 2 A05343607D0886DF55A8E85BA4ADD9D82C17488C5700FD6B35C96DB41B34B276 	10800	A 	10800	AAAA	2a01:4f8:1c0c:73c7::1	10800	CNAME	10800	CNAME	10800	CNAME	10800	CNAME	10800	CNAME	10800	CNAME	10800	CNAME  	10800	CNAME	10800	CNAME  	10800	CNAME	10800	CNAME

Now sudo knotc reload should load the zone file and update it with signatures. sudo keymgr dnskey would show a DNSKEY, which should be set for the domain (usually in domain registrar's control panel), along with glue records for the name server, and name server itself.

X.509 certificates with an ACME client

Most of the target protocols can work over TLS; Let's Encrypt and a few other CAs provide X.509 certificates for it using the ACME protocol (RFC 8555). Both the protocol and the software are awkward, but it's still an improvement over older CAs. The DNS challenge allows to issue wildcard certificates, and helps to avoid ugly hacks needed with the HTTP challenge.

Certbot is used here, being Let's Encrypt's recommended client, and the alternatives (such as dehydrated) not being much better. Certbot is intended to run as a superuser, but that's undesirable and unnecessary in this setup, so we'll set a new user and a group for it; the services using its keys and certificates would then be in its group, and the keys will be group-readable. And we'll set it to use the ACME DNS challenge (using certbot-dns-rfc2136). Actually it's still quite awkward (certbot doesn't need access to the keys, for one), but better than the defaults, and unfortunately even this involves a relatively complex setup.

Hopefully in the future it will be viable to use opportunistic IPsec (likely relying on DNS: IPSECKEY or IPSECA), getting rid of PKIX together with TLS and all the awkwardness around it. TLSA RR updates are configured below, as a small step away from PKIX and closer to how it may work with IPsec.

$ sudo apt install certbot python3-certbot-dns-rfc2136
$ sudo addgroup --system letsencrypt
$ sudo adduser --system --no-create-home --home /var/lib/letsencrypt/ --ingroup letsencrypt letsencrypt
$ sudo touch /etc/letsencrypt/rfc2136
$ sudo chmod 600 /etc/letsencrypt/rfc2136
$ sudo chown -R letsencrypt:letsencrypt /etc/letsencrypt/ /var/log/letsencrypt/ /var/lib/letsencrypt/

Edit /etc/letsencrypt/rfc2136:

dns_rfc2136_server =
dns_rfc2136_port = 53
dns_rfc2136_name = certbot
dns_rfc2136_secret = SECRET_TSIG_KEY
dns_rfc2136_algorithm = HMAC-SHA512

Request a certificate (setting a rather long propagation time, since our secondary DNS server updates with a delay; another option is to delegate the _acme-challenge subdomain to a different zone, served only by the primary nameserver, but the delay is tolerable in this case), change file permissions:

$ sudo -u letsencrypt certbot certonly --agree-tos --email '' \
> -n --dns-rfc2136 --dns-rfc2136-credentials=/etc/letsencrypt/rfc2136 \
> --dns-rfc2136-propagation-seconds 600 -d '' -d '*'
$ sudo chmod 0640 /etc/letsencrypt/live/

Add a script to update TLSA records, /usr/local/bin/

set -e

fp=$(openssl x509 -pubkey -noout -in \
             "/etc/letsencrypt/live/" |
         openssl rsa -pubin -outform der 2>/dev/null |
         openssl dgst -sha256 |
         cut -d ' ' -f 2)

knsupdate <<EOF
server localhost
key hmac-sha512:certbot SECRET_TSIG_KEY
ttl 10800
update del tlsa311._dane TLSA
update add tlsa311._dane TLSA 3 1 1 $fp
exit 0

Don't forget to adjust permissions for it, since it contains a secret key:

$ sudo chown letsencrypt:letsencrypt /usr/local/bin/
$ sudo chmod 0500 /usr/local/bin/

Daemons in general won't reload expired certificates, so we'll have to poke them occasionally. One option is to add /etc/cron.weekly/certificate-reload:

set -e
chmod 0640 /etc/letsencrypt/live/
systemctl reload postfix dovecot nginx prosody
sudo -u letsencrypt /usr/local/bin/
exit 0

And make it executable with sudo chmod +x /etc/cron.weekly/certificate-reload.

Finally, the certbot renewal task should be adjusted to run as the letsencrypt user. Overriding can be done with sudo systemctl edit certbot:


Now adding a user into the letsencrypt group would grant them access to the keys, e.g.: sudo usermod -G letsencrypt prosody.

When we'll be configuring servers, there often will be a choice between direct TLS and STARTTLS, required and optional use of TLS, restriction of used ciphers. Being overly strict leads to failures, sometimes for no good reason. I tend to require TLS for SMTP, IMAP, and XMPP clients, but not for public documents served over HTTP. Clients should also be encouraged to not rely on it too much, and use OpenPGP or other end-to-end encryption methods when it matters.


I'm usually installing nginx (though there's a few other httpd alternatives) with cgit at once, sudo apt install nginx cgit fcgiwrap. /etc/nginx/nginx.conf edits:

--- nginx.conf  2020-12-15 21:17:38.367101592 +0100
+++ /etc/nginx/nginx.conf       2020-12-15 21:21:44.052736859 +0100
@@ -33,6 +33,8 @@
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
        ssl_prefer_server_ciphers on;
+        ssl_certificate     /etc/letsencrypt/live/;
+        ssl_certificate_key /etc/letsencrypt/live/;
        # Logging Settings

/etc/cgitrc (with just a couple of repositories):

# cgit config
# see cgitrc(5) for details


virtual-root=/ git repositories

repo.desc=XSLT-based PostgreSQL web interface

repo.desc=A reusable XMPP library

Attempt to reduce crawler log spam with /usr/share/cgit/robots.txt:

--- robots.txt  2020-12-19 17:25:29.863643316 +0100
+++ /usr/share/cgit/robots.txt  2020-12-19 17:26:29.062565176 +0100
@@ -1,3 +1,4 @@
 User-agent: *
 Disallow: /*/snapshot/*
+Disallow: /*?*
 Allow: /


server {
    listen [::]:80;
    listen 80;
    listen [::]:443 ssl;
    listen 443 ssl;

    root /usr/share/cgit;
    try_files $uri @cgit;

    location @cgit {
        include             fastcgi_params;
        fastcgi_param       SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi;
        fastcgi_param       PATH_INFO       $uri;
        fastcgi_param       QUERY_STRING    $args;
        fastcgi_param       HTTP_HOST       $server_name;
        fastcgi_pass        unix:/run/fcgiwrap.socket;

A sample static website (paste service, serving files that were simply uploaded with rsync), /etc/nginx/conf.d/paste.conf:

server {
    listen 80;
    listen [::]:80;
    listen 443 ssl;
    listen [::]:443 ssl;


    location / {
        charset utf-8;
        root /srv/paste/;

Setting reverse proxies for XMPP file upload and XMPP-over-HTTPS (needed because the available client software on some systems--iOS, most notably--is highly unreliable; using converse.js, which goes into /var/www/xmpp/) in /etc/nginx/conf.d/xmpp.conf:

server {
    listen 80;
    listen [::]:80;
    return 301 https://$server_name$request_uri;

server {
    listen 443 ssl;
    listen [::]:443 ssl;


    location /upload/ {
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_pass http://localhost:5280;

    location /http-bind {
        proxy_pass http://localhost:5280/http-bind;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_buffering off;
        tcp_nodelay on;

    location /xmpp-websocket {
        proxy_pass http://localhost:5280/xmpp-websocket;
        proxy_http_version 1.1;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Upgrade $http_upgrade;

        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_read_timeout 900s;

    location / {
        charset utf-8;
        root /var/www/xmpp;

HTTPS is enabled for the default website, and the directive preventing "not found" errors from being logged is removed, so that fail2ban would notice and block spammy vulnerability scanners:

--- default     2020-12-15 22:58:40.632894873 +0100
+++ /etc/nginx/sites-available/default  2020-12-24 00:38:26.996563289 +0100
@@ -24,8 +24,8 @@
        # SSL configuration
-       # listen 443 ssl default_server;
-       # listen [::]:443 ssl default_server;
+       listen 443 ssl default_server;
+       listen [::]:443 ssl default_server;
        # Note: You should disable gzip for SSL traffic.
        # See:
@@ -45,12 +45,6 @@
        server_name _;
-       location / {
-               # First attempt to serve request as file, then
-               # as directory, then fall back to displaying a 404.
-               try_files $uri $uri/ =404;
-       }
        # pass PHP scripts to FastCGI server
        #location ~ \.php$ {

At this point we can also set the .well-known directory (RFC 5785) with a XEP-0156/RFC 7395 host-meta file for XMPP-over-HTTPS, and a Web Key Directory for OpenPGP key discovery (generate with gpg --list-options show-only-fpr-mbox -k '' | /usr/lib/gnupg/gpg-wks-client -v --install-key).


We'll use Dovecot and Postfix with OpenDKIM and SPF Engine. Additionally, mlmmj will be set for mailing list management, and MHonArc for mailing list archieval. Some of the viable alternatives are Courier Mail Server (providing SMTP, IMAP, and mailing lists at once), Exim for SMTP, Mailman for mailing list management (though Mailman 3 uses a few times more memory than this whole system together, and there's a bunch of other bloat-related issues with it).


This one is easy, and handy to setup first out of the email programs: then we can move all the user maildirs (maildir is a generally nice format, which is handy for migration by simply moving it with a home directory), check that they work (that is, messages are available via IMAP), and use Dovecot's SASL for Postfix too. Install it with sudo apt install dovecot-imapd, edit /etc/dovecot/conf.d/10-mail.conf:

--- 10-mail.conf        2020-12-15 16:40:21.880949141 +0100
+++ /etc/dovecot/conf.d/10-mail.conf    2020-12-15 16:41:31.704881933 +0100
@@ -27,7 +27,7 @@
 # <doc/wiki/MailLocation.txt>
-mail_location = mbox:~/mail:INBOX=/var/mail/%u
+mail_location = maildir:~/Maildir
 # If you need to set multiple mailbox locations or want to change default
 # namespace settings, you can do it by defining namespace sections.

Edit /etc/dovecot/conf.d/10-ssl.conf too:

--- 10-ssl.conf 2020-12-15 16:31:54.059688146 +0100
+++ /etc/dovecot/conf.d/10-ssl.conf     2020-12-15 16:34:39.057359523 +0100
@@ -3,14 +3,14 @@
 # SSL/TLS support: yes, no, required. <doc/wiki/SSL.txt>
-ssl = yes
+ssl = required
 # PEM encoded X.509 SSL/TLS certificate and private key. They're opened before
 # dropping root privileges, so keep the key file unreadable by anyone but
 # root. Included doc/ can be used to easily generate self-signed
 # certificate, just make sure to update the domains in dovecot-openssl.cnf
-ssl_cert = </etc/dovecot/private/dovecot.pem
-ssl_key = </etc/dovecot/private/dovecot.key
+ssl_cert = </etc/letsencrypt/live/
+ssl_key = </etc/letsencrypt/live/
 # If key file is password protected, give the password here. Alternatively
 # give it when starting dovecot with -p parameter. Since this file is often

Then just reload (or restart) it, and it should be ready to serve messages. Though once we'll install Postfix, to use Dovecot SASL, we'll also need the following /etc/dovecot/conf.d/10-master.conf edits:

--- 10-master.conf      2020-12-15 19:12:44.948427098 +0100
+++ /etc/dovecot/conf.d/10-master.conf  2020-12-15 19:13:42.139594734 +0100
@@ -104,9 +104,11 @@
   # Postfix smtp-auth
-  #unix_listener /var/spool/postfix/private/auth {
-  #  mode = 0666
-  #}
+  unix_listener /var/spool/postfix/private/auth {
+    mode = 0666
+    user = postfix
+    group = postfix
+  }
   # Auth process is run as this user.
   #user = $default_internal_user


Install and generate a key:

$ sudo apt install opendkim opendkim-tools
$ sudo -u opendkim opendkim-genkey -D /etc/dkimkeys -d -s tart2020

Then the key from /etc/dkimkeys/tart2020.txt should be added into the zone file, and its parameters should be set in /etc/opendkim.conf:

--- opendkim.conf       2020-12-15 17:41:13.715731125 +0100
+++ /etc/opendkim.conf  2020-12-15 17:52:34.965843653 +0100
@@ -10,9 +10,9 @@
 # Sign for with key in /etc/dkimkeys/dkim.key using
 # selector '2007' (e.g.
-#KeyFile               /etc/dkimkeys/dkim.key
-#Selector              2007
+KeyFile                        /etc/dkimkeys/tart2020.private
+Selector               tart2020
 # Commonly-used options; the commented-out versions show the defaults.
 #Canonicalization      simple
@@ -30,8 +30,8 @@
 # ##  inet:port                   to listen on all interfaces
 # ##  local:/path/to/socket       to listen on a UNIX domain socket
-#Socket                  inet:8892@localhost
-Socket                 local:/var/run/opendkim/opendkim.sock
+Socket                  inet:8892@localhost
+#Socket                        local:/var/run/opendkim/opendkim.sock
 ##  PidFile filename
 ###      default (none)

The records can then be checked with sudo -u opendkim opendkim-testkey -d -s tart2020 -vvv.


Install Postfix and SPF Engine with sudo apt install postfix postfix-policyd-spf-python, edit /etc/postfix/ to enable a dedicated submission port, postscreen, and SPF:

---   2020-12-15 18:06:15.011289517 +0100
+++ /etc/postfix/      2021-01-02 16:53:24.430963345 +0100
@@ -9,23 +9,28 @@
 # service type  private unpriv  chroot  wakeup  maxproc command + args
 #               (yes)   (yes)   (no)    (never) (100)
 # ==========================================================================
-smtp      inet  n       -       y       -       -       smtpd
-#smtp      inet  n       -       y       -       1       postscreen
-#smtpd     pass  -       -       y       -       -       smtpd
-#dnsblog   unix  -       -       y       -       0       dnsblog
-#tlsproxy  unix  -       -       y       -       0       tlsproxy
-#submission inet n       -       y       -       -       smtpd
-#  -o syslog_name=postfix/submission
-#  -o smtpd_tls_security_level=encrypt
-#  -o smtpd_sasl_auth_enable=yes
-#  -o smtpd_tls_auth_only=yes
-#  -o smtpd_reject_unlisted_recipient=no
-#  -o smtpd_client_restrictions=$mua_client_restrictions
-#  -o smtpd_helo_restrictions=$mua_helo_restrictions
-#  -o smtpd_sender_restrictions=$mua_sender_restrictions
-#  -o smtpd_recipient_restrictions=
-#  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-#  -o milter_macro_daemon_name=ORIGINATING
+#smtp      inet  n       -       y       -       -       smtpd
+smtp      inet  n       -       y       -       1       postscreen
+smtpd     pass  -       -       y       -       -       smtpd
+dnsblog   unix  -       -       y       -       0       dnsblog
+tlsproxy  unix  -       -       y       -       0       tlsproxy
+submission inet n       -       y       -       -       smtpd
+  -o syslog_name=postfix/submission
+  -o smtpd_tls_security_level=encrypt
+  -o smtpd_sasl_auth_enable=yes
+  -o smtpd_sasl_type=dovecot
+  -o smtpd_sasl_path=private/auth
+  -o smtpd_sasl_security_options=noanonymous
+  -o smtpd_sasl_local_domain=$myhostname
+  -o smtpd_tls_auth_only=yes
+  -o smtpd_reject_unlisted_recipient=no
+  -o smtpd_client_restrictions=permit_sasl_authenticated,reject
+  -o { smtpd_recipient_restrictions = reject_non_fqdn_recipient,
+                                      reject_unknown_recipient_domain,
+                                      permit_sasl_authenticated,
+                                      reject }
+  -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
+  -o milter_macro_daemon_name=ORIGINATING
 #smtps     inet  n       -       y       -       -       smtpd
 #  -o syslog_name=postfix/smtps
 #  -o smtpd_tls_wrappermode=yes
@@ -124,4 +129,9 @@
 mailman   unix  -       n       n       -       -       pipe
   flags=FR user=list argv=/usr/lib/mailman/bin/
   ${nexthop} ${user}
+# SPF with postfix-policyd-spf-python
+policyd-spf  unix  -       n       n       -       0       spawn
+  user=policyd-spf argv=/usr/bin/policyd-spf
+# mlmmj mailing lists
+mlmmj   unix  -       n       n       -       -       pipe
+  flags=ORhu user=mlmmj argv=/usr/bin/mlmmj-receive -F -L /var/spool/mlmmj/$nexthop

Also edit /etc/postfix/ (partially based on Postfix Anti-UCE Cheat Sheet and rob0's postscreen(8) configuration; for reference, see postconf(5); Postfix Documentation is nice and extensive):

---     2020-12-15 18:03:13.489245160 +0100
+++ /etc/postfix/        2021-01-06 07:42:45.445520853 +0100
@@ -24,8 +24,8 @@
 # TLS parameters
 smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
 smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
@@ -34,14 +34,80 @@
 # information on enabling SSL in the smtp client.
 smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
-myhostname = tart
+myhostname =
 alias_maps = hash:/etc/aliases
 alias_database = hash:/etc/aliases
 myorigin = /etc/mailname
 mydestination = $myhostname,, tart, localhost.localdomain, localhost
 relayhost = 
 mynetworks = [::ffff:]/104 [::1]/128
-mailbox_size_limit = 0
+# 20 MiB for a message
+message_size_limit = 20971520
+# 1 GiB for a mailbox
+mailbox_size_limit = 1073741824
 recipient_delimiter = +
 inet_interfaces = all
 inet_protocols = all
+# Store messages in ~/Maildir/
+home_mailbox = Maildir/
+# OpenDKIM
+smtpd_milters = inet:localhost:8892
+non_smtpd_milters = $smtpd_milters
+milter_default_action = accept
+internal_mail_filter_classes = bounce
+# Postscreen
+postscreen_access_list = permit_mynetworks,
+                         cidr:/etc/postfix/postscreen_access.cidr
+postscreen_blacklist_action = drop
+postscreen_greet_action = drop
+postscreen_pipelining_enable = yes
+postscreen_non_smtp_command_enable = yes
+postscreen_bare_newline_enable = yes
+postscreen_bare_newline_action = enforce
+postscreen_dnsbl_action = enforce
+postscreen_dnsbl_sites =*3
+postscreen_dnsbl_threshold = 3
+postscreen_dnsbl_whitelist_threshold = -1
+# Other anti-UCE
+smtpd_helo_required = yes
+disable_vrfy_command = yes
+smtpd_recipient_restrictions =
+        reject_invalid_hostname,
+        reject_non_fqdn_hostname,
+        reject_non_fqdn_sender,
+        reject_non_fqdn_recipient,
+        reject_unknown_sender_domain,
+        reject_unknown_recipient_domain,
+        permit_mynetworks,
+        reject_unknown_client_hostname,
+        reject_unauth_destination,
+        check_sender_access hash:/etc/postfix/sender_checks,
+        check_client_access hash:/etc/postfix/client_checks,
+        # reject_rbl_client,
+        # reject_rbl_client
+        reject_rbl_client,
+        reject_rbl_client,
+        #reject_rbl_client,
+        check_policy_service unix:private/policyd-spf,
+        permit
+smtpd_data_restrictions =
+                        reject_unauth_pipelining,
+                        permit
+# Mailing lists with mlmmj
+mlmmj_destination_recipient_limit = 1
+transport_maps = hash:/etc/postfix/transport
+relay_domains =

Create a few files for access control:

$ sudo -e /etc/postfix/postscreen_access.cidr
$ sudo -e /etc/postfix/client_checks
$ sudo postmap /etc/postfix/client_checks
$ sudo -e /etc/postfix/sender_checks
$ sudo postmap /etc/postfix/sender_checks 
$ sudo -e /etc/aliases
$ sudo postalias /etc/aliases

A few aliases should be set, as per RFC 2142 and XEP-0157:

postmaster:    root
hostmaster:    root
webmaster:     root
xmpp:          root
abuse:         root
info:          root
support:       root
security:      root
root:          defanor

A /etc/postfix/postscreen_access.cidr example:

# GitHub SPF, 2020-12-19 permit

#, 2020-12-19 permit
2001:41b8:202:deb:216:36ff:fe40:4002 permit

# Spammy networks reject

DNS records should be checked, reverse DNS records should be set too, and the addresses can be added into DNSWL. Additional spam filtering can be added too, but I'm hardly getting any spam with just these settings (rare spam that gets through I'm reporting and adding into access checks).

Mailing lists

Postfix is already configured above to hook up mlmmj, here is an example /etc/postfix/transport file for a single list (called "general"): mlmmj:general

Installation, configuration, and usage of mlmmj and mhonarc:

$ # Initial setup
$ sudo apt install mlmmj mhonarc
$ sudo adduser --system --no-create-home --home /var/spool/mlmmj/ mlmmj
$ sudo mkdir /var/www/lists/
$ sudo chown mlmmj /var/spool/mlmmj/ /var/www/lists/

$ # Create a list
$ sudo -u mlmmj mlmmj-make-ml -L general
$ echo | sudo -u mlmmj tee /var/spool/mlmmj/general/control/smtphelo
$ sudo -u mlmmj mkdir /var/www/lists/general

And the following in /etc/cron.hourly/mlmmj-maintenance:

set -e
/usr/bin/mlmmj-maintd -F -L /var/spool/mlmmj/general/
sudo -u mlmmj mhonarc -quiet -spammode -add -umask 022 \
     -outdir /var/www/lists/general /var/spool/mlmmj/general/archive/
exit 0

There's a room for tinkering and improvement, but that's a basic and lightweight setup that is quite usable.


Prosody is a popular XMPP server option; it's fairly feature-rich and easy to set. Among other usable XMPP server alternatives is ejabberd, though IIRC it required more resources, its documentation is rather awkward, and generally looks rather enterprise-y. Install Prosody and its modules with sudo apt install prosody prosody-modules, edit /etc/prosody/prosody.cfg.lua (among other things, enable smacks for Prosody to at least attempt to deliver messages, set certificate paths, and enable HTTP file upload with the reverse proxy we've configured above):

--- prosody.cfg.lua     2020-12-16 00:44:39.844425794 +0100
+++ /etc/prosody/prosody.cfg.lua        2021-01-08 09:14:14.185873840 +0100
@@ -21,7 +21,7 @@
 -- for the server. Note that you must create the accounts separately
 -- (see for info)
 -- Example: admins = { "", "" }
-admins = { }
+admins = { "" }
 -- Enable use of libevent for better performance under high load
 -- For more information see:
@@ -60,7 +60,7 @@
                "time"; -- Let others know the time here on this server
                "ping"; -- Replies to XMPP pings with pongs
                "register"; -- Allow users to register on this server using a client and change passwords
-               --"mam"; -- Store messages in an archive and allow users to access it
+               "mam"; -- Store messages in an archive and allow users to access it
                --"csi_simple"; -- Simple Mobile optimizations
        -- Admin interfaces
@@ -68,8 +68,8 @@
                --"admin_telnet"; -- Opens telnet console interface on localhost port 5582
        -- HTTP modules
-               --"bosh"; -- Enable BOSH clients, aka "Jabber over HTTP"
-               --"websocket"; -- XMPP over WebSockets
+               "bosh"; -- Enable BOSH clients, aka "Jabber over HTTP"
+               "websocket"; -- XMPP over WebSockets
                --"http_files"; -- Serve static files from a directory over HTTP
        -- Other specific functionality
@@ -82,7 +82,10 @@
                --"watchregistrations"; -- Alert admins of registrations
                --"motd"; -- Send a message to users when they log in
                --"legacyauth"; -- Legacy authentication. Only used by some old clients and bots.
-               --"proxy65"; -- Enables a file transfer proxy service which clients behind NAT can use
+               "proxy65"; -- Enables a file transfer proxy service which clients behind NAT can use
+        -- Custom modules
+                "smacks";
+                "cloud_notify";
 -- These modules are auto-loaded, but should you want
@@ -93,6 +96,22 @@
        -- "s2s"; -- Handle server-to-server connections
+smacks_enabled_s2s = true
+consider_bosh_secure = true
+consider_websocket_secure = true
+cross_domain_bosh = {""}
+cross_domain_websocket = {""}
+http_external_url = ""
+trusted_proxies = { "", "::1" }
+http_ports = { 5280 }
+http_interfaces = { "", "::1" }
+https_ports = { 5281 }
+https_interfaces = { "", "::1" }
+http_upload_file_size_limit = 20*1024*1024
+http_max_content_size = 100*1024*1024
 -- Disable account creation by default, for security
 -- For more information see
 allow_registration = false
@@ -172,7 +191,7 @@
 --  Logs errors to syslog also
 log = {
        -- Log files (change 'info' to 'debug' for debug logs):
-       info = "/var/log/prosody/prosody.log";
+       debug = "/var/log/prosody/prosody.log";
        error = "/var/log/prosody/prosody.err";
        -- Syslog:
        { levels = { "error" }; to = "syslog";  };
@@ -190,7 +209,12 @@
 -- (from e.g. Let's Encrypt) see
 -- Location of directory to find certificates in (relative to main config file):
-certificates = "certs"
+-- certificates = "certs"
+ssl = {
+   certificate = "/etc/letsencrypt/live/";
+   key = "/etc/letsencrypt/live/";
 -- HTTPS currently only supports a single certificate, specify it here:
 --https_certificate = "/etc/prosody/certs/localhost.crt"
@@ -207,7 +231,7 @@
 -- Component definitions in their own config files. This line includes
 -- all config files in /etc/prosody/conf.d/
-VirtualHost "localhost"
+VirtualHost ""
 --VirtualHost ""
 --     certificate = "/path/to/example.crt"
@@ -218,9 +242,12 @@
 -- For more information on components, see
 ---Set up a MUC (multi-user chat) room server on
---Component "" "muc"
+Component "" "muc"
 --- Store MUC messages in an archive and allow users to access it
---modules_enabled = { "muc_mam" }
+modules_enabled = { "muc_mam" }
+Component "" "http_upload"
 ---Set up an external component (default component port is 5347)
@@ -230,4 +257,4 @@
 --Component ""
 --     component_secret = "password"
-Include "conf.d/*.cfg.lua"
+-- Include "conf.d/*.cfg.lua"

When migrating, /var/lib/prosody/ can be simply moved to a new server with this setup (while it would be a bit more complicated with non-file-based storage).

Other tweaks and services


znc can be installed with sudo apt install znc, though it won't add a user or a service. To do that, sudo adduser --system --home /var/lib/znc/ znc, and add /etc/systemd/system/znc.service:

Description=ZNC, an advanced IRC bouncer

ExecStart=/usr/bin/znc -f


Then configure it or move its existing configuration (which is also just about moving its home directory, /var/lib/znc/, to a new server), let it access the X.509 key and certificate (sudo usermod -G letsencrypt znc) start and enable (sudo systemctl start znc, sudo systemctl enable znc).


Gophernicus isn't available from Debian repositories, but comes with upstream packaging, so we'll build it:

defanor@tart:~/src$ sudo apt install git build-essential fakeroot libwrap0-dev debhelper
defanor@tart:~/src$ git clone ''
defanor@tart:~/src$ cd gophernicus
defanor@tart:~/src/gophernicus$ git checkout 3.0.1
defanor@tart:~/src/gophernicus$ make deb
defanor@tart:~/src/gophernicus$ sudo dpkg -i ../gophernicus_3.0.1-1_amd64.deb


A firewall is nice to set, at least to reduce garbage in the logs. nftables replaces iptables as a netfilter interface on Linux, so we'll use that: sudo apt install nftables, sudo systemctl enable nftables, and edit /etc/nftables.conf. For instance, based on the simple ruleset for a server:

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
  # Large networks from which registered users don't connect: APNIC,
  # AFRNIC, LACNIC. Also subnets of ISPs that seem to ignore abuse
  # reports: Centurylink,,
  set not-users {
    type ipv4_addr
    flags interval
    elements = {,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,

  # Trusted hosts.
  set trusted {
    type ipv4_addr
    flags interval
    # elements = {  }

  set spammers {
    type ipv4_addr
    flags interval

  chain input {
    type filter hook input priority 0; policy drop;

    # Allow traffic from established and related packets.
    ct state established,related accept

    # Drop invalid packets.
    ct state invalid drop

    # Allow loopback traffic.
    iifname lo accept

    # Allow traffic from trusted hosts.
    ip saddr @trusted accept

    # Allow all ICMP and IGMP traffic, but enforce a rate limit to
    # help prevent some types of flood attacks.
    ip protocol icmp limit rate 4/second accept
    ip6 nexthdr ipv6-icmp limit rate 4/second accept
    ip protocol igmp limit rate 4/second accept

    # Restrict connections from major spammers.
    ip saddr @spammers limit rate over 4/hour drop

    # Allow non-users to access public services, just limit the rate.
    tcp dport {25, 53, 70, 443, 5269} ip saddr @not-users limit rate 1/minute accept
    udp dport {53} ip saddr @not-users limit rate 1/minute accept

    # That's it for non-users.
    ip saddr @not-users drop

    # Allow SSH, SMTP, DNS, Gopher, HTTP, IMAP, HTTPS, SMTP
    # submission, IMAPS, ZNC, and XMPP-related ports: file transfer
    # proxy, client connections, server connections.
    tcp dport {22, 25, 53, 70, 80, 143, 443, 587, 993, 1500, 5000, 5222, 5269} accept

    # Also allow UDP ports: 53 for DNS, 68 for DHCP.
    udp dport {53, 68} accept
  chain forward {
    type filter hook forward priority 0; policy drop;
  chain output {
    type filter hook output priority 0; policy accept;

And sudo nft -f /etc/nftables.conf to reload it. One should be careful with blocking large subnets, as it is rather frustrating to be on the other side of blocking. Restricting access to private services (the ones requiring authentication) is fine if it's known that there's no legitimate users connecting from those subnets, and access to public services can be rate limited. It may also be nice to only allow SSH connections from your addresses, if all the possible addresses (or subnets) are static and known.

One may also consider using dynamically generated blocklists, based on spam/bruteforce spotted by others. Some of the seemingly nice lists are available at, Charles B. Haley,, IPsum,, Threat Sourcing, Anti-Attacks,, An example /etc/cron.daily/blacklist-refresh:

set -e
nft flush set inet filter spammers
curl --silent --compressed --max-filesize 1M \
     '' |
     cut -f 1 |
     grep -P '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$' |
     grep -E '^[0-9\.]{7,15}$' |   # a sanity check
     head -n 80000 |               # and another one
     xargs -L 4096 echo |
     tr ' ' ',' |
     xargs -i nft add element inet filter spammers '{{}}'
exit 0


To further reduce garbage in the logs, install fail2ban with sudo apt install fail2ban, and enable relevant jails in /etc/fail2ban/jail.local:

bantime = 24h
findtime = 1h
banaction = nftables-multiport
mode = aggressive

enabled = true

enabled = true

enabled = true

enabled = true

Fail2ban should be restarted after reloading nftables, to reapply its rules. There are some minor warts (failing to resolve "imap" service on Debian 10, not handling some of the common postscreen messages, likely more), which can be fixed in jail.local. Not including workarounds here, since they are temporary, but that's one more thing that happens commonly with software: one should be prepared to run into, report, and/or fix issues that arise.

Continued maintenance

It's a good idea to follow developments in the used software, protocols, and infrastructures. There are relatively low-traffic mailing lists for announcements (e.g., debian-security-announce and debian-announce, among Debian mailing lists), helping to track when urgent updates are needed, and which are also handy for periodically confirming that one's mail server works.

Occasionally I'm making backups with tar and gpg, and downloading them with rsync, though the server isn't supposed to have anything stored that is valuable, hard to restore, and only available on it; perhaps its configuration was the closest thing to it, until this document was composed. An example: sudo tar cz /etc/ /var/lib/{prosody,znc,knot} /var/www/html/ /srv/ /home/*/Maildir/ /usr/local/ | gpg -e -r -o "$HOSTNAME-$(date --rfc-3339=date).tgz.gpg".

Sometimes I report particularly spammy addresses (bruteforce and spam attempts) to their hosters or ISPs. Not sure how well that works, but more people monitoring and reporting network abuse should lead to its reduction.

Things may go wrong and require fixing sometimes. There are single points of failure in this setup, so such a system shouldn't be a single point of failure for one's well-being. In my experience, issues with such a system rarely arise and can be fixed quickly, with it being simpler and more reliable than desktop systems, but this may vary.