Larvitz Blog

FreeBSD, Linux, all things cleanly engineered

Self-Hosting Email on FreeBSD: A Secure, Jailed Setup with Postfix and Dovecot



Self-hosting email is often described as a Sisyphean task. A constant battle against spam lists, deliverability issues, and security threats. While there’s truth to that reputation, running your own mail infrastructure remains one of the best ways to reclaim privacy and truly understand how the internet’s oldest federation protocol works.

In this article, I’ll walk through my current mail server setup running on FreeBSD 15.0. The design philosophy prioritizes security through isolation (VNET jails), data protection (ZFS encryption), and attack surface reduction (GeoIP filtering). This isn’t a beginner’s tutorial - I’ll assume familiarity with FreeBSD basics and focus on the architecture decisions and configuration details that make this setup work.

Mailserver setup

Architecture Overview

Before diving into configuration files, here’s how the pieces fit together:

                        Internet
                            |
                            v
                +-----------------------+
                |     PF Firewall       |
                |  GeoIP + NAT + RDR    |
                +-----------+-----------+
                            |
            +---------------+---------------+
            |                               |
     Port 25 (SMTP)              Ports 143/587/4190
     Open to all                 GeoIP restricted
            |                               |
            v                               v
    +-------+-------------------------------+-------+
    |              VNET Jail: mailstack             |
    |                                               |
    |  +----------+  +----------+  +-----------+    |
    |  | Postfix  |  | Dovecot  |  |  Rspamd   |    |
    |  |   MTA    |  |IMAP/LMTP |  |  Filter   |    |
    |  +----+-----+  +----+-----+  +-----+-----+    |
    |       |             |              |          |
    |       +------+------+------+-------+          |
    |              |                                |
    |              v                                |
    |     +------------------+                      |
    |     | /var/vmail       |  (nullfs mount)      |
    |     | ZFS encrypted    |                      |
    |     +------------------+                      |
    +-----------------------------------------------+
                            |
            +---------------+---------------+
            |                               |
    +-------+-------+               +-------+-------+
    | VNET Jail:    |               |    Host:      |
    |   webmail     |               | zroot/secure  |
    | (Roundcube)   |               | (encrypted)   |
    +---------------+               +---------------+

The key insight here is separation of concerns: the host handles networking and storage, while the jails run the actual services. Mail data lives on an encrypted ZFS dataset on the host and is mounted into the jail via nullfs. This means I can destroy and recreate the jail without touching any mail data, and backups of the encrypted dataset can be sent offsite without the backup server ever possessing the decryption key.

The Host System: FreeBSD 15.0

The foundation is a hardened FreeBSD 15.0 system. Security starts at the kernel level.

Security Hardening

I run with kern_securelevel="2", which is a significant step up from the default. At this level, even the root user cannot write to immutable files, load kernel modules, or modify the firewall rules without a reboot. This means even if an attacker gains root access, they can’t disable PF or load a rootkit.

Additional hardening via sysctl prevents users from seeing other processes and restricts access to kernel internals:

/etc/sysctl.conf

# Prevent users from seeing processes of other UIDs
security.bsd.see_other_uids=0
security.bsd.see_other_gids=0
security.bsd.see_jail_proc=0

# Disable unprivileged access to kernel internals
security.bsd.unprivileged_read_msgbuf=0
security.bsd.unprivileged_proc_debug=0

# Randomize PIDs to make race conditions harder to exploit
kern.randompid=1

# ZFS tuning
vfs.zfs.vdev.min_auto_ashift=12

# Allow large PF tables for GeoIP filtering
net.pf.request_maxcount=500000

The last setting is crucial for GeoIP filtering - the default PF table size is too small to hold the ~273,000 CIDR blocks we’ll be loading.

Kernel Module Loading

The necessary modules for jail networking are loaded at boot:

/boot/loader.conf

kern.geom.label.disk_ident.enable="0"
kern.geom.label.gptid.enable="0"
zfs_load="YES"

# Required for VNET jails
nullfs_load="YES"
if_bridge_load="YES"
if_epair_load="YES"
kern.racct.enable=1

Network and ZFS Layout

The host handles the physical connection and bridges traffic to the jails. The network configuration uses a bridge interface that provides connectivity to all VNET jails:

/etc/rc.conf

hostname="mail"
keymap="us.kbd"

# Security hardening
kern_securelevel_enable="YES"
kern_securelevel="2"

# External Interface (VPS provider network)
ifconfig_vtnet0="inet 192.0.2.50/32 -lro -tso"
ifconfig_vtnet0_ipv6="inet6 2001:db8:1c1c:4d2::1/65"
defaultrouter="172.31.1.1"
ipv6_defaultrouter="fe80::1%vtnet0"

# Bridge for VNET Jails
cloned_interfaces="bridge0"
ifconfig_bridge0="inet 10.0.0.1 netmask 255.255.255.0"
ifconfig_bridge0_ipv6="inet6 2001:db8:1c1c:4d2:8000::1/65"

# Enable routing for jails
gateway_enable="YES"
ipv6_gateway_enable="YES"

# Services
dumpdev="NO"
pf_enable="YES"
zfs_enable="YES"
jail_enable="YES"
sshd_enable="YES"
pflog_enable="YES"
clear_tmp_enable="YES"
moused_nondefault_enable="NO"

The ZFS layout separates sensitive data onto an encrypted dataset. Here’s the relevant portion:

NAME                          USED  AVAIL  REFER  MOUNTPOINT
zroot/secure                 4.28G  29.2G   200K  /zroot/secure
zroot/secure/jails           4.04G  29.2G   264K  /jails
zroot/secure/jails/mailstack 1.40G  29.2G  1.00G  /jails/mailstack
zroot/secure/jails/webmail   1022M  29.2G   805M  /jails/webmail
zroot/secure/vmail            248M  29.2G   233M  /var/mail

The encryption uses AES-256-GCM:

$ zfs get encryption zroot/secure
NAME          PROPERTY    VALUE        SOURCE
zroot/secure  encryption  aes-256-gcm  -

This means all child datasets (jails and mail storage) inherit the encryption. After a reboot, the dataset must be manually unlocked before the jails can start - a deliberate trade-off between availability and security.

The Firewall: PF with GeoIP Filtering

I use PF (Packet Filter) to ruthlessly cut down on attack surface. The most effective measure has been GeoIP filtering. Since my users are all in Central Europe, I can block authentication attempts from everywhere else while keeping SMTP open for mail delivery.

/etc/pf.conf

# --- Macros ---
ext_if = "vtnet0"
jail_net = "10.0.0.0/24"
jail_net6 = "2001:db8:1c1c:4d2:8000::/65"
host_ipv6 = "2001:db8:1c1c:4d2::1"

mail_ipv4 = "10.0.0.3"
mail_ipv6 = "2001:db8:1c1c:4d2:8000::3"
webmail_ipv6 = "2001:db8:1c1c:4d2:8000::4"

# --- Tables ---
table <bruteforce> persist
table <jails_v4> { $jail_net }
table <jails_v6> { $jail_net6 }
table <geoip_users> persist

# --- Options ---
set limit table-entries 1000000
set skip on lo0
set block-policy drop
set loginterface $ext_if

# --- Scrub ---
scrub in all fragment reassemble
scrub out on $ext_if random-id

# --- NAT (IPv4 only for legacy support) ---
nat on $ext_if inet from <jails_v4> to any -> ($ext_if)

# --- RDR for services ---
# Client ports: GeoIP restricted
rdr pass on $ext_if inet proto tcp from <geoip_users> \
    to ($ext_if) port {143, 587, 4190} -> $mail_ipv4

# SMTP: Open to all (server-to-server)
rdr pass on $ext_if inet proto tcp from any \
    to ($ext_if) port 25 -> $mail_ipv4

# --- Filtering ---
# Block known bad actors immediately
block quick from <bruteforce>

# Default deny
block drop in log all
block drop out log all

# Allow all outbound (stateful)
pass out quick all keep state

# Anti-spoofing
antispoof quick for { $ext_if, bridge0 }

# SSH only from GeoIP restricted IP ranges
pass in quick on $ext_if proto tcp from <geoip_users> to any port 22 \
    flags S/SA keep state \
    (max-src-conn 5, max-src-conn-rate 3/30, \
     overload <bruteforce> flush global)

# Block and log all other SSH attempts
block in log quick on $ext_if proto tcp from any to any port 22 \
    label "ssh_not_trusted"

# IPv6: Mail client ports with GeoIP restriction
pass in quick on $ext_if inet6 proto tcp from <geoip_users> \
    to $mail_ipv6 port {143, 587, 4190} flags S/SA keep state

# IPv6: SMTP open to all
pass in quick on $ext_if inet6 proto tcp from any \
    to $mail_ipv6 port 25 flags S/SA keep state

# IPv6: Webmail with GeoIP restriction
pass in quick on $ext_if inet6 proto tcp from <geoip_users> \
    to $webmail_ipv6 port {80, 443} flags S/SA keep state

# Essential ICMPv6
pass in quick inet6 proto ipv6-icmp icmp6-type \
    { echoreq, echorep, neighbrsol, neighbradv, toobig, timex, paramprob }

# ICMP for IPv4 (ping, MTU discovery)
pass in inet proto icmp icmp-type { echoreq, unreach }

# Jail egress
pass in quick on { bridge0 } from <jails_v4> to ! 10.0.0.0/24 keep state
pass in quick on { bridge0 } inet6 from <jails_v6> \
    to ! 2001:db8:1c1c:4d2:8000::/65 keep state

The dual approach to traffic handling is visible in the rules:

  • SMTP (port 25): from any - must be globally accessible for mail delivery
  • Client ports (143, 587, 4190): from <geoip_users> - restricted to allowed countries

I covered the GeoIP update script in detail in my previous article on GeoIP-aware firewalling. The short version: I parse MaxMind’s GeoLite2 CSV data for my target countries (DE, AT, CH, NL, LU, FR) and load the resulting ~273,000 CIDR blocks into PF in chunks to avoid memory exhaustion.

Jail Architecture: VNET for True Isolation

I use VNET jails rather than traditional IP alias jails. Unlike IP aliases, VNET gives each jail its own fully virtualized network stack with dedicated interfaces. This allows firewall rules inside the jail if needed and keeps the host’s networking clean.

/etc/jail.conf

mailstack {
    enforce_statfs = 2;
    devfs_ruleset = 4;
    exec.clean;
    exec.consolelog = /var/log/jails/mailstack_console.log;
    exec.start = '/bin/sh /etc/rc';
    exec.stop = '/bin/sh /etc/rc.shutdown';
    host.hostname = "mailstack";
    mount.devfs;
    path = "/jails/mailstack";

    # Mount the mail storage from the host
    mount += "/var/mail /jails/mailstack/var/vmail nullfs rw 0 0";

    # VNET configuration
    vnet;
    vnet.interface = "e0b_mailstack";
    exec.prestart += "epair0=$(ifconfig epair create) && \
        ifconfig ${epair0} up name e0a_mailstack && \
        ifconfig ${epair0%a}b up name e0b_mailstack";
    exec.prestart += "ifconfig bridge0 addm e0a_mailstack";
    exec.prestart += "ifconfig e0a_mailstack description \
        \"vnet0 host interface for Jail mailstack\"";
    exec.poststop += "ifconfig e0a_mailstack destroy";
}

webmail {
    enforce_statfs = 2;
    devfs_ruleset = 4;
    exec.clean;
    exec.consolelog = /var/log/jails/webmail_console.log;
    exec.start = '/bin/sh /etc/rc';
    exec.stop = '/bin/sh /etc/rc.shutdown';
    host.hostname = "webmail";
    mount.devfs;
    path = "/jails/webmail";

    vnet;
    vnet.interface = "e0b_webmail";
    exec.prestart += "epair0=$(ifconfig epair create) && \
        ifconfig ${epair0} up name e0a_webmail && \
        ifconfig ${epair0%a}b up name e0b_webmail";
    exec.prestart += "ifconfig bridge0 addm e0a_webmail";
    exec.prestart += "ifconfig e0a_webmail description \
        \"vnet0 host interface for Jail webmail\"";
    exec.poststop += "ifconfig e0a_webmail destroy";
}

The epair interfaces create a virtual cable - one end attached to the host’s bridge, the other inside the jail. When the jail stops, the interface is automatically destroyed.

Inside the mailstack jail, the network is configured like any FreeBSD system:

/jails/mailstack/etc/rc.conf

ifconfig_e0b_mailstack_name="vnet0"
ifconfig_vnet0="inet 10.0.0.3 netmask 255.255.255.0"
ifconfig_vnet0_ipv6="inet6 2001:db8:1c1c:4d2:8000::3/65"
ifconfig_vnet0_descr="jail interface for bridge0"
defaultrouter="10.0.0.1"
ipv6_defaultrouter="2001:db8:1c1c:4d2:8000::1"

# Disable sendmail (we use Postfix)
sendmail_enable="NO"
sendmail_submit_enable="NO"
sendmail_outbound_enable="NO"
sendmail_msp_queue_enable="NO"

# Services
syslogd_flags="-ss"
cron_flags="-J 60"
postfix_enable="YES"
dovecot_enable="YES"
rspamd_enable="YES"
local_unbound_enable="YES"

Note that I run a local Unbound resolver inside the jail. This ensures DNS queries for DKIM verification and SPF checks don’t leak to external resolvers and provides caching for the many lookups mail processing requires.

The Mail Stack Configuration

Inside the mailstack jail, three components work together: Postfix handles SMTP, Dovecot manages storage and IMAP access, and Rspamd filters spam and signs outgoing mail with DKIM.

Postfix (The MTA)

Postfix handles both receiving mail from the internet and accepting submissions from authenticated users. The configuration enforces strict restrictions to reject spam early in the SMTP conversation.

/usr/local/etc/postfix/main.cf (key settings)

compatibility_level = 3.10

# Identity
myhostname = mail.example.org
myorigin = $myhostname
mydestination = localhost.$mydomain, localhost

# Network
inet_interfaces = all
inet_protocols = all
mynetworks_style = host

# TLS (Incoming & Outgoing)
smtpd_tls_cert_file = /etc/mail/certs/cert.pem
smtpd_tls_key_file = /etc/mail/certs/key.pem
smtpd_tls_security_level = may
smtpd_tls_auth_only = yes
smtpd_tls_loglevel = 1
smtp_tls_security_level = may
smtp_tls_loglevel = 1
smtp_tls_CApath = /etc/ssl/certs

# SASL (Auth via Dovecot)
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
smtpd_sasl_security_options = noanonymous

# Virtual Domains
virtual_mailbox_domains = example.org example.net
virtual_mailbox_maps = hash:/usr/local/etc/postfix/vmailbox
virtual_alias_maps = hash:/usr/local/etc/postfix/virtual
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000
virtual_minimum_uid = 5000

# Hand off to Dovecot for local delivery
virtual_transport = lmtp:unix:private/dovecot-lmtp

# Rspamd integration via milter protocol
smtpd_milters = inet:localhost:11332
non_smtpd_milters = inet:localhost:11332
milter_protocol = 6
milter_default_action = accept

# Strict HELO requirements
smtpd_helo_required = yes
smtpd_helo_restrictions =
    permit_mynetworks,
    permit_sasl_authenticated,
    reject_invalid_helo_hostname,
    reject_non_fqdn_helo_hostname,
    permit

# Sender restrictions
smtpd_sender_restrictions =
    permit_mynetworks,
    permit_sasl_authenticated,
    reject_non_fqdn_sender,
    reject_unknown_sender_domain,
    permit

# Recipient restrictions
smtpd_recipient_restrictions =
    permit_mynetworks,
    permit_sasl_authenticated,
    reject_unauth_destination,
    reject_non_fqdn_recipient,
    reject_unknown_reverse_client_hostname,

The submission port (587) for authenticated users is configured in master.cf:

/usr/local/etc/postfix/master.cf (submission entry)

submission inet n       -       n       -       -       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_milters=inet:localhost:11332

The virtual mailbox and alias maps define which addresses are valid and where they’re delivered:

/usr/local/etc/postfix/vmailbox

# example.org
admin@example.org        OK
backups@example.org      OK

# example.net
user@example.net         OK
accounts@example.net     OK

/usr/local/etc/postfix/virtual

# Aliases for example.org
postmaster@example.org   admin@example.org
abuse@example.org        admin@example.org
webmaster@example.org    admin@example.org

# Catch-all for specific services -> accounts
github@example.net       accounts@example.net
hetzner@example.net      accounts@example.net

After modifying these files, rebuild the hash databases:

postmap /usr/local/etc/postfix/vmailbox
postmap /usr/local/etc/postfix/virtual
postfix reload

Dovecot (Storage & Authentication)

Dovecot is the workhorse. It serves IMAP to clients, handles authentication for Postfix via SASL, receives mail from Postfix via LMTP, and processes Sieve filter rules. The special_use attributes implement RFC 6154. This signals to modern clients (iOS, Outlook) which folders are for Sent, Trash, and Drafts, preventing the chaos of mixed ‘Sent’ and ‘Sent Messages’ folders.

/usr/local/etc/dovecot/dovecot.conf

protocols = imap lmtp sieve
auth_mechanisms = plain login

# Security - require TLS
ssl = required
ssl_cert = </etc/mail/certs/cert.pem
ssl_key = </etc/mail/certs/key.pem
ssl_min_protocol = TLSv1.2

# Mail storage location
mail_location = maildir:/var/vmail/%d/%n/Maildir
mail_uid = 5000
mail_gid = 5000
mmap_disable = yes

# Sieve for server-side filtering
plugin {
  sieve = /var/vmail/%d/%n/.dovecot.sieve
  sieve_dir = /var/vmail/%d/%n/sieve
}

# Authentication (simple passwd-file for easy management)
passdb {
  driver = passwd-file
  args = scheme=BLF-CRYPT username_format=%u /usr/local/etc/dovecot/users
}

userdb {
  driver = static
  args = uid=5000 gid=5000 home=/var/vmail/%d/%n
}

# Socket for Postfix SASL authentication
service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    user = postfix
    group = postfix
  }
}

# Socket for Postfix LMTP delivery
service lmtp {
  unix_listener /var/spool/postfix/private/dovecot-lmtp {
    mode = 0660
    user = postfix
    group = postfix
  }
}

protocol lmtp {
  mail_plugins = $mail_plugins sieve
}

# Standard IMAP folders (RFC 6154)
namespace inbox {
  inbox = yes
  separator = /

  mailbox Drafts {
    special_use = \Drafts
    auto = subscribe
  }
  mailbox Junk {
    special_use = \Junk
    auto = subscribe
  }
  mailbox Trash {
    special_use = \Trash
    auto = subscribe
  }
  mailbox Sent {
    special_use = \Sent
    auto = subscribe
  }
  mailbox "Sent Messages" {
    special_use = \Sent
  }
  mailbox Archive {
    special_use = \Archive
    auto = subscribe
  }
}

The users file contains password hashes (generated with doveadm pw -s BLF-CRYPT):

/usr/local/etc/dovecot/users

user@example.net:{BLF-CRYPT}$2y$05$...hash...
admin@example.org:{BLF-CRYPT}$2y$05$...hash...

Rspamd (The Gatekeeper)

Rspamd analyzes incoming messages and handles DKIM signing for outgoing mail. Without valid DKIM signatures, your mail will likely land in Gmail’s spam folder.

/usr/local/etc/rspamd/local.d/dkim_signing.conf

path = "/var/lib/rspamd/dkim/$domain.key";
selector = "mail";
allow_username_mismatch = true;

Generate DKIM keys for each domain:

rspamadm dkim_keygen -s mail -d example.org \
    -k /var/lib/rspamd/dkim/example.org.key > /var/lib/rspamd/dkim/example.org.pub
chmod 640 /var/lib/rspamd/dkim/example.org.key
chown rspamd:rspamd /var/lib/rspamd/dkim/example.org.key

The public key goes into your DNS as a TXT record at mail._domainkey.example.org.

Boot and Recovery Procedure

Because the mail data lives on an encrypted ZFS dataset, the system requires manual intervention after a reboot. This is a deliberate security trade-off.

After the system boots:

# Unlock the encrypted dataset
zfs load-key -r zroot/secure

# Mount all ZFS filesystems
zfs mount -a

# Start the jails
service jail start

This sequence ensures that even if someone gains physical access to the server (or its disks), they cannot read the mail data without the encryption passphrase.

Backups: Encrypted at Rest

One of ZFS’s killer features for mail hosting is the ability to send encrypted raw streams. The backup server never sees unencrypted data.

# Create a recursive snapshot
zfs snapshot -r zroot@backup_20260118

# Send encrypted stream to offsite backup
# The -w flag sends the raw encrypted blocks
zfs send -R -w -i @backup_20260117 zroot@backup_20260118 | \
    ssh backup-user@backup.example.net zfs recv -F -o mountpoint=none \
    -o canmount=off data1/mailserver

The backup server stores the data in encrypted form. Even if it’s compromised, the attacker only gets encrypted blocks without the key.

Verification

After everything is configured, verify the running services inside the jail:

root@mailstack:/ # ps auxw | grep -E 'postfix|dovecot|rspamd'
root     16095  0.0  1.3 130672 53916  -  SsJ  13:21   0:00.10 rspamd: main process
rspamd   16324  0.0  1.3 131056 54692  -  SJ   13:21   0:00.12 rspamd: rspamd_proxy process
rspamd   16521  0.0  1.4 131964 56244  -  SJ   13:21   0:01.09 rspamd: controller process
rspamd   16619  0.0  1.4 132592 58544  -  SJ   13:21   0:00.58 rspamd: normal process
root     35467  0.0  0.1  15984  4104  -  IsJ  13:21   0:00.15 /usr/local/sbin/dovecot
dovecot  36239  0.0  0.1  15916  3832  -  IJ   13:21   0:00.03 dovecot/anvil
root     69385  0.0  0.4  61164 14668  -  IsJ  13:21   0:00.11 /usr/local/libexec/postfix/master
postfix  69918  0.0  0.4  61188 14676  -  IJ   13:21   0:00.03 pickup -l -t unix -u
postfix  70681  0.0  0.4  61244 14744  -  IJ   13:21   0:00.03 qmgr -l -t unix -u

Test SMTP connectivity:

$ telnet mail.example.org 25
220 mail.example.org ESMTP Postfix
EHLO test.example.com
250-mail.example.org
250-STARTTLS
250-AUTH PLAIN LOGIN
...

Test email deliverability using tools like mail-tester.com or MXToolbox. A properly configured server with SPF, DKIM, and DMARC should score 10/10.

Conclusion

Running a mail server in 2026 on FreeBSD 15.0 feels robust. The combination of PF’s fine-grained control, VNET jails for network isolation, ZFS’s native encryption, and GeoIP filtering creates a platform that is secure by design.

While it requires initial effort to configure correctly - especially getting DNS records (SPF, DKIM, DMARC) aligned - the result is a private, secure communication hub that you truly own. You aren’t mining your own data for ads, and you aren’t subject to the whims of a provider who might lock your account without recourse.

The architecture also makes maintenance straightforward: update the host and jails independently, restore from encrypted backups without exposing plaintext data, and adjust GeoIP rules as your travel patterns change.

Is it more work than using a hosted provider? Absolutely. Is it worth it? For those who value understanding their infrastructure and maintaining control over their communications, unquestionably yes.


References


The mail protocols we use today were designed in an era of mutual trust between operators. That trust has eroded, but the protocols remain remarkably resilient. With careful configuration and modern security layers, self-hosted email is not just viable - it’s a statement of digital autonomy.