Setup Email Server From Scratch On FreeBSD #2 - 09 Virtual Domains
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