Setup Email Server From Scratch On FreeBSD #2 - 09 Virtual Domains

08 RoundCube WebMail PKG <- Intro -> 10 Blocking Spam

We believe in data independence, and support others who want data independence.
Debian Email From Scratch version 2 finished 2025-07-30.

We are still adding to it but it all works!


################################
# Howto Create Virtual Domains #
################################

One mail server can handle multiple domains "virtual domain" and newer Postfix
and Dovecot can use SNI, server name indication, mapping with multiple
certificates. Each virtual host can have it's own ssl certificate, just like
virtual hosts in apache.

The three solutions are 1) put multiple hostnames in a single certificate for
postfix or 2) use the same mx servers for all domains, or 3) use SNI. If you
want the virtual host users to go to the "virtual domain" website specify
different directores for the website autoconfig autodiscover each with their
own certificates or a combined certificate. Use a common roundcube directory
and modify /var/www/roundcube/config/config.inc.php to display a pretty name
based on matching the php servername value. This page covers using SNI or
using 2 mx hosts for all the virtual domains, eg. pointing the MX records for
those domains to those 2 mx hosts. The SNI method is a a few lines more to
configure so the best choice.

This page of the "Debian Email From Scratch" tutorial assumes successful setup
of the following ...

1. Debian Server
2. LAMP - Debian Apache MySQL/MariaDB PHP
3. Postfix - smtp server
4. Dovecot - imap server
5. PostfixAdmin - Postfix Web Administration
6. SPF DMARC and DKIM - email authetication and spam filters
7. Roundcube - Webmail (installed using git sources)
8. Roundcube - Webmail (installed using apt packages)

Create DKIM keys for new virtual hosts

mkdir -p /etc/dkimkeys/okbsd.com
mkdir -p /etc/dkimkeys/okbiz.net
mkdir -p /etc/dkimkeys/coragarden.com
opendkim-genkey -b 2048 -d okbsd.com -D /etc/dkimkeys/okbsd.com -s 20250723 -v
opendkim-genkey -b 2048 -d okbiz.net -D /etc/dkimkeys/okbiz.net -s 20250723 -v
opendkim-genkey -b 2048 -d coragarden.com -D /etc/dkimkeys/coragarden.com -s 20250724 -v
find /etc/dkimkeys/* -type d -exec chown -R opendkim:postfix {} \;
find /etc/dkimkeys/* -type d -exec chmod 700 {} \;
chmod 600 /etc/dkimkeys/*/*.private

Get the DKIM selector and key from the file and copy it to the DNS...

cat /etc/dkimkeys/okbsd.com/20250723.txt
cat /etc/dkimkeys/okbiz.net/20250723.txt
cat /etc/dkimkeys/coragarden.com/20250724.txt

Setup DNS for New Domain(s), point the mx records, autoconfig, and
autodiscover to the main mx server hostname. and paste the DKIM TXT record for
this domain.

A @ 15.204.113.148
AAAA @ 2604:2dc0:202:300::3645
A mail 15.204.113.148
AAAA mail 2604:2dc0:202:300::3645
A mx 15.204.113.148
AAAA mx 2604:2dc0:202:300::3645
CNAME autoconfig mx.coragarden.com
CNAME autodiscover mx.coragarden.com
CNAME www okbsd.com
TXT @ v=spf1 ip4:15.204.113.148 ip6:2604:2dc0:202:300::3645/64 mx ~all
TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:postmaster@coragarden.com; ruf=mailto:postmaster@coragarden.com; sp=quarantine
TXT 20250723._domainkey v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQE....IDAQAB
SRV _autodiscover _tcp 5 0 443 mx.coragarden.com
In Custom MX Records
MX @ mx.okdeb.com 0
MX @ mail.okdeb.com 10

Copy the keys to your name server record and remember to remove the extra
quotes and spaces in 2 sections of the DKIM line.

We use mx.okdeb.com and mail.okdeb.com as the MX host so we don't need to
create A and AAAA records and certs for additional mx servers like
mx.okbsd.com and mail.okbsd.com, but this example includes the A and AAAA
records that can be used for webmail. There is some benefit to adding A and
AAAA records if users will access IMAP or webmail at mail.okbsd.com for
example. If users will use these hostnames for IMAP and SMTP use a combined
certificate with all the hostnames (postfix can only use one cert).

If DMARC reports are sent to an okdeb.com email address also add an entry in
the okdeb.com DNS to "allow" the dmarc to be sent from another domain.

DNS TXT Entry for okbsd.com
TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:postmaster@okdeb.com; ruf=mailto:postmaster@okdeb.com; sp=quarantine

DNS TXT Entry for okdeb.com
TXT okbsd.com._report._dmarc v=DMARC1;

Append it to the keytable file to avoid mistyping the dkim selectors.

cat /etc/dkimkeys/okbsd.com/20250723.txt >> /etc/opendkim/keytable
cat /etc/dkimkeys/okbiz.net/20250723.txt >> /etc/opendkim/keytable
cat /etc/dkimkeys/coragarden.com/20250724.txt >> /etc/opendkim/keytable

Edit the appended entries.

nano /etc/opendkim/keytable
20250718._domainkey.okdeb.com okdeb.com:20250718:/etc/dkimkeys/okdeb.com/20250718.private 20250723._domainkey.okbsd.com okbsd.com:20250723:/etc/dkimkeys/okbsd.com/20250723.private 20250723._domainkey.okbiz.net okbiz.net:20250723:/etc/dkimkeys/okbiz.net/20250723.private 20250724._domainkey.coragarden.com coragarden.com:20250724:/etc/dkimkeys/coragarden.com/20250724.private

nano /etc/opendkim/signingtable
*@okdeb.com 20250718._domainkey.okdeb.com *@okbsd.com 20250723._domainkey.okbsd.com *@okbiz.net 20250723._domainkey.okbiz.net *@coragarden.com 20250724._domainkey.coragarden.com

nano /etc/opendkim/trustedhosts
127.0.0.1 ::1 localhost 15.204.113.148 2604:2dc0:202:300::3645 mx.okdeb.com mail.okdeb.com okdeb.com coragarden.com mx.coragarden.com mail.coragarden.com

systemctl restart opendkim

By the time this is created the DNS has hopefully propagated and can be
tested, test the keys.

This one works and it has been using Namecheap Basic DNS

opendkim-testkey -d okbiz.net -s 20250723 -vvv
opendkim-testkey: using default configfile /etc/opendkim.conf
opendkim-testkey: checking key '20250723._domainkey.okbiz.net'
opendkim-testkey: key secure
opendkim-testkey: key OK

opendkim-testkey -d coragarden.com -s 20250724 -vvv -x /etc/opendkim.conf
opendkim-testkey: checking key '20250724._domainkey.coragarden.com'
opendkim-testkey: key secure
opendkim-testkey: key OK

opendkim-testkey -d okbsd.com -s 20250723 -vvv -x /etc/opendkim.conf
opendkim-testkey: '20250723._domainkey.okbsd.com' query timed out

okbsd.com was previously running on another machine with itself as the
nameserver running bind wiht dnssec. By the next day google still wouldn't
provide an address for okbsd.com. This problem did not resolve until I setup a
new FreeBSD server (probably not relevant) and set the new A Record in
Namecheap, disabled then reenabled dnssec (probably relevant). I also setup
bind again with a master zone but didn't enable my own dnssec or point the DNS
to the machine. Save that for later another howto.

Setup Domain in PostfixAdmin

Domain List -> New Domain
Domain: coragarden.com
Description: Cora Garden
Pass expires : 3650
Add Domain

Virtual List - Add Mailbox
vuser jack@coragarden.com
Add User

Virtual List - redirect the default email addresses to a valid account. RFC
standard is that every mail domain should have a postmaster@domain.tld
account. So either keep all the aliases, or keep postermaster, or delete them
all and add a catchall alias like *.coragarden.com -> postmaster@okdeb.com. In the last
case make sure and create an postmaster@okdeb.com account or alias to another
email account used for mail administration.

While you could add another domain to your webserver it is easier to just
login with roundcube at the mx.okdeb.com address. If you want to use roundcube
with new domain name setup apache and letsencrypt certs for the new domain.
You could also put multiple domains in one cert and use ServerAlias in the
same virtual host configuration.

It is better to keep the mx mail web hosts separate from the general website
directory (manage mail separately from managing website content) and use a
separate cert for the postfix and www hosts.

Create new website and mail website directories

mkdir -p /var/www/coragarden/mx/
mkdir -p /var/www/coragarden/mail
mkdir -p /var/www/coragarden/html

Configure new website in apache

nano /etc/apache2/sites-available/coragarden.conf
<VirtualHost *:80> ServerName coragarden.com ServerAlias www.coragarden.com ServerAdmin postmaster@coragarden.com CustomLog ${APACHE_LOG_DIR}/access.log vhost_combined ErrorLog ${APACHE_LOG_DIR}/error.log DirectoryIndex index.php index.html DocumentRoot /var/www/coragarden/html <Directory /var/www/coragarden/html/> Options FollowSymLinks AllowOverride All Require all granted </Directory> # If you want to run cgi's uncomment these and the handler ScriptAlias /cgi-bin/ "/var/www/coragarden/cgi-bin/" <Directory "/var/www/coragarden/cgi-bin"> AllowOverride None Options +ExecCGI -Indexes -MultiViews +SymLinksIfOwnerMatch Require all granted </Directory> </VirtualHost>

Configure mx and mail host in apache, certbot -a apache needs these virtual
hosts to create the certs for postfix.

nano /etc/apache2/sites-available/mx.coragarden.conf
<VirtualHost *:80> ServerName mx.coragarden.com ServerAlias mail.coragarden.com ServerAlias autoconfig.coragarden.com ServerAlias autodiscover.coragarden.com ServerAdmin postmaster@coragarden.com CustomLog ${APACHE_LOG_DIR}/access.log vhost_combined ErrorLog ${APACHE_LOG_DIR}/error.log DirectoryIndex index.php index.html DocumentRoot /var/www/coragarden/mx/ <Directory /var/www/coragarden/mx/> Options FollowSymLinks AllowOverride All Require all granted </Directory> # autoconfig Alias /mail "/var/www/coragarden/mail/" <Directory /var/www/coragarden/mail/> Options FollowSymLinks AllowOverride All Require all granted </Directory> #RewriteEngine on #RewriteCond %{SERVER_NAME} =mail.coragarden.com [OR] #RewriteCond %{SERVER_NAME} =mx.coragarden.com #RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] </VirtualHost>

chown -R root:www-data /var/www/coragarden
chmod o-rwx /var/www/coragarden

cd /etc/apache2/site-enabled
ln -s ../sites-available/coragarden.conf
ln -s ../sites-available/mx.coragarden.conf
apachectl restart

Create certificate for website

certbot certonly -a apache --agree-tos --redirect --hsts --staple-ocsp --email postmaster@okdeb.com --cert-name coragarden.com -d coragarden.com,www.coragarden.com

Create certificate for the mail services

certbot certonly -a apache --agree-tos --redirect --hsts --staple-ocsp --email postmaster@okdeb.com --cert-name coragarden.com -d mx.coragarden.com,mail.coragarden.com,autodiscover.coragarden.com

setfacl -R -m u:www-data:rx /etc/letsencrypt/live/ /etc/letsencrypt/archive/

If you need to delete certificates
certbot revoke --cert-name mx.domain3.net
certbot delete --cert-name mx.domain3.net

You can also create certificate with multiple domains in one cert - not recommended

certbot certonly --webroot --agree-tos --redirect --hsts --staple-ocsp --email postmaster@okdeb.com --cert-name mxcerts \
-w /var/www/okdeb/mx -d mx.okdeb.com,mail.okdeb.com \
-w /var/www/coragarden/mx -d mx.coragarden.com,mail.coragarden.com

(U)pdate certificate/(C)ancel: U
Renewing an existing certificate for mx.okdeb.com and 3 more domains

Restart apache
apachectl restart

Postfix with Server Name Indication SNI (virtual mail domain certs)

Postfix can now use Server Name Indication SNI and so can have separate certs
for each virtual email domain. In this case we can use the web cert created
above with the same hostnames. We can still keep the regular website certs
separate or combine them all into one for easier management. We'll keep them
separate in this example.

The default values needs to be set in addition to the sni_maps line.

nano /etc/postfix/main.cf
# TLS parameters smtpd_tls_cert_file=/etc/letsencrypt/live/mx.okdeb.com/fullchain.pem smtpd_tls_key_file=/etc/letsencrypt/live/mx.okdeb.com/privkey.pem smtpd_tls_security_level=may smtpd_tls_loglevel = 1 smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache tls_server_sni_maps = hash:/etc/postfix/sni_maps

Every domain will neeed 2 entries, one for each hostname mx.domain.tld and
mail.domain.tld. We could also make a separate cert for each host but with
more certs per domain and many domains it complicates management.

nano /etc/postfix/sni_maps
mx.okdeb.com /etc/letsencrypt/live/mx.okdeb.com/privkey.pem /etc/letsencrypt/live/mx.okdeb.com/fullchain.pem mail.okdeb.com /etc/letsencrypt/live/mx.okdeb.com/privkey.pem /etc/letsencrypt/live/mx.okdeb.com/fullchain.pem mx.coragarden.com /etc/letsencrypt/live/mx.coragarden.com/privkey.pem /etc/letsencrypt/live/mx.coragarden.com/fullchain.pem mail.coragarden.com /etc/letsencrypt/live/mx.coragarden.com/privkey.pem /etc/letsencrypt/live/mx.coragarden.com/fullchain.pem

postmap -F /etc/postfix/sni_maps
systemctl restart postfix

Dovecot SNI maps - Dovecot also needs the default specified.

nano /etc/dovecot/conf.d/10-ssl.conf
# Default ssl_cert = </etc/letsencrypt/live/mx.okdeb.com/fullchain.pem ssl_key = </etc/letsencrypt/live/mx.okdeb.com/privkey.pem local_name mx.okdeb.com { ssl_cert = </etc/letsencrypt/live/mx.okdeb.com/fullchain.pem ssl_key = </etc/letsencrypt/live/mx.okdeb.com/privkey.pem } local_name mail.okdeb.com { ssl_cert = </etc/letsencrypt/live/mx.okdeb.com/fullchain.pem ssl_key = </etc/letsencrypt/live/mx.okdeb.com/privkey.pem } local_name mx.coragarden.com { ssl_cert = </etc/letsencrypt/live/mx.coragarden.com/fullchain.pem ssl_key = </etc/letsencrypt/live/mx.coragarden.com/privkey.pem } local_name mail.coragarden.com { ssl_cert = </etc/letsencrypt/live/mx.coragarden.com/fullchain.pem ssl_key = </etc/letsencrypt/live/mx.coragarden.com/privkey.pem }

systemctl restart dovecot

Configure https for coragarden.com and mx.coragarden.com

nano /etc/apache2/sites-available/ssl-coragarden.conf
<VirtualHost *:443> ServerName coragarden.com ServerAlias www.coragarden.com ServerAdmin postmaster@coragarden.com CustomLog ${APACHE_LOG_DIR}/access.log vhost_combined ErrorLog ${APACHE_LOG_DIR}/error.log DirectoryIndex index.php index.html DocumentRoot /var/www/coragarden/html <Directory /var/www/coragarden/html/> Options FollowSymLinks AllowOverride All Require all granted </Directory> SSLEngine on SSLCertificateFile /etc/letsencrypt/live/coragarden.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/coragarden.com/privkey.pem <FilesMatch "\.(?:cgi|shtml|phtml|php)$"> SSLOptions +StdEnvVars </FilesMatch> <Directory "/var/www/coragarden/cgi-bin"> AllowOverride None Options +ExecCGI -Indexes -MultiViews +SymLinksIfOwnerMatch Require all granted </Directory> #<Directory /var/www/coragarden/cgi-bin> # SSLOptions +StdEnvVars #</Directory> </VirtualHost>

nano /etc/apache2/sites-available/ssl-mx.coragarden.conf
<VirtualHost *:443> ServerName mx.coragarden.com ServerAlias mail.coragarden.com ServerAlias autoconfig.coragarden.com ServerAlias autodiscover.coragarden.com ServerAdmin postmaster@coragarden.com CustomLog ${APACHE_LOG_DIR}/access.log vhost_combined ErrorLog ${APACHE_LOG_DIR}/error.log DirectoryIndex index.php index.html SSLEngine on SSLCertificateFile /etc/letsencrypt/live/mx.coragarden.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/mx.coragarden.com/privkey.pem DocumentRoot /var/www/roundcube/ Alias /roundcube "/var/www/roundcube" Alias /webmail "/var/www/roundcube" <Directory /var/www/roundcube/> #Options FollowSymLinks MultiViews Options FollowSymLinks AllowOverride All Require all denied Require ip trusted_ip1 trusted_network2 </Directory> Alias /admin-login /usr/share/postfixadmin/public <Directory /usr/share/postfixadmin/> Options FollowSymLinks MultiViews AllowOverride All Require all denied Require ip trusted_ip1 trusted_network2 </Directory> Alias /mail "/var/www/coragarden/mail" Alias "/Autodiscover/Autodiscover.xml" "/var/www/coragarden/mail/Autodiscover.xml/index.php" <Directory /var/www/okdeb/mail/> <Directory /var/www/coragarden/mail/> DirectorySlash Off Options -Indexes +FollowSymLinks AllowOverride All Require all granted </Directory> <FilesMatch "\.(?:cgi|shtml|phtml|php)$"> SSLOptions +StdEnvVars </FilesMatch> </VirtualHost>

cd /etc/apache2/site-enabled
ln -s ../sites-available/ssl-coragarden.conf
ln -s ../sites-available/ssl-mx.coragarden.conf
apachectl restart

echo '<?php print "<!DOCTYPE html lang=\"en\"><html><head><title>Title</title></head>\n<body><h1>Hello World!</h1></body></html>"; ?>' > /var/www/coragarden/html/test.php
echo '<?php print "<!DOCTYPE html lang=\"en\"><html><head><title>Title</title></head>\n<body><h1>Hello World!</h1></body></html>"; ?>' > /var/www/coragarden/mx/test.php

Test to make sure it is working
https://coragarden.com/test.php
https://mail.coragarden.com/test.php

Remove the test files
rm /var/www/coragarden/html/test.php
rm /var/www/coragarden/mx/test.php

Setup Autoconfig auto discover
cd /tmp
git clone https://github.com/smartlyway/email-autoconfig-php
cd email-autoconfig-php/mail
mkdir -p /var/www/coragarden/mail
cp config-v1.1.xml /var/www/coragarden/mail
cd /tmp/email-autoconfig-php/Autodiscover
cp -r /tmp/email-autoconfig-php/Autodiscover/Autodiscover.xml /var/www/coragarden/mail

For outgoing server use port 587 with STARTTLS

nano /var/www/coragarden/mail/config-v1.1.xml
<?xml version="1.0"?> <clientConfig version="1.1"> <emailProvider id="coragarden.com"> <domain>example.org</domain> <displayName>coragarden.com</displayName> <displayShortName>coragarden.com</displayShortName> <incomingServer type="imap"> <hostname>mx.coragarden.com</hostname> <port>993</port> <socketType>SSL</socketType> <authentication>password-cleartext</authentication> <username>%EMAILADDRESS%</username> </incomingServer> <outgoingServer type="smtp"> <hostname>mx.coragarden.com</hostname> <port>587</port> <socketType>STARTTLS</socketType> <username>%EMAILADDRESS%</username> <authentication>password-cleartext</authentication> </outgoingServer> </emailProvider> </clientConfig>

nano /var/www/coragarden/mail/Autodiscover.xml/index.php
<?php $raw = file_get_contents('php://input'); $matches = array(); preg_match('/<EMailAddress>(.*)<\/EMailAddress>/', $raw, $matches); header('Content-Type: application/xml'); ?> <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006"> <Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a"> <User> <DisplayName>Cora Garden</DisplayName> </User> <Account> <AccountType>email</AccountType> <Action>settings</Action> <Protocol> <Type>IMAP</Type> <Server>mx.coragarden.com</Server> <Port>993</Port> <DomainRequired>off</DomainRequired> <SPA>off</SPA> <SSL>on</SSL> <AuthRequired>on</AuthRequired> <LoginName><?php echo $matches[1]; ?></LoginName> </Protocol> <Protocol> <Type>SMTP</Type> <Server>mx.coragarden.com</Server> <Port>587</Port> <DomainRequired>off</DomainRequired> <SPA>off</SPA> <SSL>on</SSL> <AuthRequired>on</AuthRequired> <LoginName><?php echo $matches[1]; ?></LoginName> </Protocol> </Account> </Response> </Autodiscover>

Login to webmail with the new account, setup acccount on thunderbird, and test email.

Login: jack@okbsd.com
Password: ***********

Check the source and the headers now have DKIM signature something like ...
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=okbsd.com;
s=Cy9jmkzC2USPfcJ4JIuzA07rpB0gS; t=1747783045;
bh=wiMQ3UTGd/zMN6+PeBSiAAUHXfKRtjj7J2UI7ZvJA+A=;
h=Date:From:To:Subject:From;
b=bkk87+VHY7HvSb1b9mq0b9bLc0XBMOzf6CnBMLcoLMAZ0yN1nDyrILNj9RiYUCJsq
hC6QsHu9S4t9Y8q85AoszhY78ddzfU8SLcg/IlTmWuiNWisrkKjAZ9ftPEtxVxkYKZ
VnpSveCK4O5Gw==

And

SPF: PASS with IP 15.204.113.148
DKIM: 'PASS' with domain coragarden.com
DMARC: 'PASS'

If you are adding this to your mail server and have setup SpamAssassin Amavis ClamAV antivirus
you will need to add the virtual domain to amavis or it won't get virus scanned.

Fix amavis to scan all configured virtual domains

nano /etc/amavis/conf.d/05-domain_id
@local_domains_acl = ( ".$mydomain",".coragarden.com",".domain3.org" );

Sometimes email clients aren't RFC compliant. When Outlook 2019 sends a test
mail it doesn't include a Date header, so it is a good idea to whitelist your own
domains in SpamAssassin. Skip this if you haven't setup SpamAssassin yet.

nano /etc/mail/spamassassin/local.cf
whitelist_from *@coragarden.com whitelist_from *@okdeb.com

systemctl restart postfix dovecot spamd spamass-milter

If you have problems with Identity and user signature in roundcube go back and configure Enigma.

cd /var/www/roundcube/plugins/enigma
cp config.inc.php.dist config.inc.php
$config['enigma_pgp_homedir'] = "/var/www/roundcube/plugins/enigma/home";
mkdir /var/www/roundcube/plugins/enigma/home
chown www-data:www-data /var/www/roundcube/plugins/enigma/home
chmod 750 /var/www/www/roundcube/plugins/enigma/home
pkg install gnupg
pear install Crypt_GPG

Customize the Roundcube landing page

nano /var/www/roundcube/config/config.inc.php
// Name your service. This is displayed on the login screen and in the window title $sn = $_SERVER['SERVER_NAME']; if (preg_match('/coragarden/', $sn)) { $config['product_name'] = 'Cora Garden Webmail'; } else if (preg_match('/okdeb/', $sn)) { $config['product_name'] = 'Ok Deb Webmail'; } else { $config['product_name'] = 'Roundcube Webmail'; }

I made 2 mistakes previously that I will mention. I was migrating
coragarden.com to a new mail server and left coragarden.com on the first mail
server and wasn't able to send test messages. The first mail server thought it
was itself. And, another case, I was migrating okbiz.net and coragarden to a
new server then because I had okbiz.net already set in /etc/hosts of the new
server I wasn't able to send to okbiz.net. 'DOH!' pulled a Homer.

Next Up - Blocking Spam

08 RoundCube WebMail PKG <- Intro -> 10 Blocking Spam