Prerequisites and Initial System State
You need a working OpenBSD 7.6 system with a public IP, a fully qualified hostname resolving in DNS, and ports 25, 465, and 587 accessible. smtpd is already installed at /usr/sbin/smtpd and enabled by default. Before touching any configuration, verify the current daemon state and stop it cleanly.
DNS is the biggest source of mail delivery failures. Before you write a single line of smtpd config, your PTR record must resolve to your hostname and your hostname must resolve back to your IP. Major receiving servers including Gmail and Microsoft 365 reject or heavily penalize mail from IPs without matching forward-confirmed PTR records. Set the PTR at your VPS provider's control panel, not in your zone file.
For the domain itself, if you are registering a new one specifically for mail infrastructure, pick something short and unambiguous. Registrars like Namecheap or services like nicename.me handle domain registration and can point you toward names that clear spam filters more easily since newly registered generic-looking domains attract extra scrutiny from reputation services.
Confirm smtpd is running and check for any pre-existing errors in its log output before making changes.
# Check daemon status
rcctl check smtpd
# View recent log entries
tail -50 /var/log/maillog
# Verify hostname resolves correctly
hostname
nslookup $(hostname)
# Check PTR record
dig -x YOUR.IP.HERE +short
Base smtpd.conf Structure
The configuration file lives at /etc/mail/smtpd.conf. OpenBSD's smtpd uses a declarative format with four main sections: PKI definitions, tables, listeners, and actions/match rules. The order matters - PKI and table declarations must appear before the listeners and rules that reference them.
This base configuration handles local delivery and outbound mail signing. We will layer DKIM and virtual domains on top of it in subsequent sections. The example uses mail.example.com as the hostname and example.com as the primary domain - substitute your actual values.
The `pki` stanza points to your TLS certificate and key. For new deployments, use acme-client which is included in OpenBSD base. Run `acme-client -v mail.example.com` after configuring /etc/acme-client.conf. The resulting certificate lands in /etc/ssl/mail.example.com.fullchain.pem and the key in /etc/ssl/private/mail.example.com.key.
# /etc/mail/smtpd.conf - base configuration
# PKI definition
pki mail.example.com cert "/etc/ssl/mail.example.com.fullchain.pem"
pki mail.example.com key "/etc/ssl/private/mail.example.com.key"
# Tables
table aliases file:/etc/mail/aliases
table virtuals file:/etc/mail/virtuals
# Listeners
listen on all tls pki mail.example.com
listen on all port 587 tls-require pki mail.example.com auth
# Actions
action "local_mail" mbox alias
action "outbound" relay
# Match rules - order is top to bottom, first match wins
match from local for local action "local_mail"
match from local for any action "outbound"
match auth for any action "outbound"
TLS Certificate with acme-client
OpenBSD's acme-client handles Let's Encrypt certificates without installing any packages. Configure /etc/acme-client.conf first, then run the client and set up a cron job for renewal. Port 80 must be reachable for the HTTP-01 challenge, which means httpd must be running during initial certificate issuance.
After issuance, hook certificate renewal into smtpd so the daemon reloads and picks up the new cert. Add a daily cron job that runs acme-client and then signals smtpd to reload. smtpd handles SIGHUP gracefully - it re-reads its configuration and all PKI material without dropping existing connections.
On our test server, acme-client issued a 90-day certificate in under 4 seconds. The renewal cron runs at 03:17 daily, which is intentionally off the hour to avoid hammering Let's Encrypt's API with every other server in the world at midnight.
# /etc/acme-client.conf
authority letsencrypt {
api url "https://acme-v02.api.letsencrypt.org/directory"
account key "/etc/acme/letsencrypt-privkey.pem"
}
domain mail.example.com {
alternative names { example.com }
domain key "/etc/ssl/private/mail.example.com.key"
domain full chain certificate "/etc/ssl/mail.example.com.fullchain.pem"
sign with letsencrypt
}
# Initial issuance - httpd must be running
acme-client -v mail.example.com
# Verify certificate
openssl x509 -in /etc/ssl/mail.example.com.fullchain.pem -noout -dates
# Cron entry for renewal (crontab -e as root)
17 3 * * * acme-client mail.example.com && rcctl reload smtpd
DKIM Signing with opensmtpd-filter-dkimsign
DKIM signing is not built into smtpd. Install opensmtpd-filter-dkimsign from packages, then generate a 2048-bit RSA key pair and publish the public key in DNS. Without DKIM, your mail will pass SPF but fail DMARC alignment on most receiving infrastructure, and Gmail will show a question mark icon next to your sender name.
Generate the key pair first. Store the private key under /etc/mail/dkim/ with 0440 permissions owned by root:_smtpd. The public key goes into your DNS zone as a TXT record under the selector._domainkey subdomain. We use the selector name `mail2026` to make key rotation easier to track.
After installing the filter and adding the filter line to smtpd.conf, restart smtpd and send a test message to check@dkimvalidator.com or use swaks to send to a Gmail address and inspect the Authentication-Results header in the received message.
The filter binary installs to /usr/local/libexec/opensmtpd/filter-dkimsign. smtpd spawns it as a subprocess with privilege separation - it runs as _smtpd and only sees message data passed over a socket pair.
# Install the filter
pkg_add opensmtpd-filter-dkimsign
# Generate DKIM key pair
mkdir -p /etc/mail/dkim
openssl genrsa -out /etc/mail/dkim/mail2026.key 2048
openssl rsa -in /etc/mail/dkim/mail2026.key -pubout -out /etc/mail/dkim/mail2026.pub
chown root:_smtpd /etc/mail/dkim/mail2026.key
chmod 0440 /etc/mail/dkim/mail2026.key
# Extract the public key for DNS (strip header/footer lines and newlines)
grep -v '^-' /etc/mail/dkim/mail2026.pub | tr -d '\n'
# DNS TXT record to publish (replace PUBKEY with the output above)
# mail2026._domainkey.example.com IN TXT "v=DKIM1; k=rsa; p=PUBKEY"
# Add to /etc/mail/smtpd.conf
# filter "dkimsign" proc-exec "filter-dkimsign -d example.com -s mail2026 -k /etc/mail/dkim/mail2026.key"
# Then add 'filter "dkimsign"' to your listener lines
# Updated listener line
# listen on all tls pki mail.example.com filter "dkimsign"
# Verify after restart
rcctl restart smtpd
swaks --to test@gmail.com --from you@example.com --server mail.example.com --tls
Complete smtpd.conf with DKIM Filter
Here is the full configuration file after adding DKIM signing. The filter declaration appears before the listener blocks. Both the port 25 and port 587 listeners reference the filter so all outbound mail, whether delivered directly or submitted by authenticated users, gets signed.
Note the `match from local for local` rule comes before the outbound relay rule. smtpd evaluates match rules sequentially and stops at the first match. If you put the outbound rule first, local delivery breaks silently because local mail also originates from the local network and matches a broad relay rule.
The `auth` keyword on the port 587 listener requires SASL authentication. smtpd uses the system's user database by default, so any valid system user with a password can authenticate. For virtual users who should not have shell access, use `useradd -s /sbin/nologin` to create accounts that can only authenticate to SMTP.
# /etc/mail/smtpd.conf - complete production config
pki mail.example.com cert "/etc/ssl/mail.example.com.fullchain.pem"
pki mail.example.com key "/etc/ssl/private/mail.example.com.key"
filter "dkimsign" proc-exec "filter-dkimsign -d example.com -s mail2026 -k /etc/mail/dkim/mail2026.key"
table aliases file:/etc/mail/aliases
table virtuals file:/etc/mail/virtuals
table domains file:/etc/mail/domains
# Port 25 - incoming mail, DKIM-sign outbound
listen on all tls pki mail.example.com filter "dkimsign"
# Port 587 - authenticated submission with mandatory TLS
listen on all port 587 tls-require pki mail.example.com auth filter "dkimsign"
# Actions
action "local_mail" mbox alias
action "virtual_mail" maildir "/var/vmail/%{dest.domain}/%{dest.user}" virtual
action "outbound" relay
# Match rules
match from local for local action "local_mail"
match from local for domain action "virtual_mail"
match from any for domain action "virtual_mail"
match from local for any action "outbound"
match auth for any action "outbound"
Virtual Domains and Maildir Delivery
Virtual domain hosting requires three supporting files: /etc/mail/domains listing your accepted domains, /etc/mail/virtuals mapping addresses to local system users, and the maildir directories themselves. smtpd delivers to Maildir format when you specify the `maildir` keyword in an action, which is what every IMAP server including Dovecot expects.
Create a dedicated vmail user to own all virtual mailboxes. This user has no shell and no password. All mail for virtual domains lands under /var/vmail/, with one subdirectory per domain and one per user within it. smtpd creates the Maildir structure (cur, new, tmp) automatically on first delivery.
The virtuals file format is `address: localuser` where localuser is a system account. You can also forward to an external address with `address: external@domain.tld` or discard mail silently with `address: /dev/null`.
After adding new entries to any file-backed table, send smtpd a SIGHUP or run `rcctl reload smtpd`. Table changes do not require a full restart.
# Create vmail user
useradd -d /var/vmail -s /sbin/nologin -L daemon vmail
mkdir -p /var/vmail
chown vmail:vmail /var/vmail
# /etc/mail/domains
example.com
example.net
# /etc/mail/virtuals
postmaster@example.com: root
abuse@example.com: root
info@example.com: vmail
alice@example.com: vmail
bob@example.com: vmail
# Map vmail system user deliveries to per-user directories
# The action line handles the split via %{dest.user} expansion:
# action "virtual_mail" maildir "/var/vmail/%{dest.domain}/%{dest.user}" virtual
# Reload without restart
rcctl reload smtpd
# Check maildir was created after first delivery
ls -la /var/vmail/example.com/alice/
# Should show: cur new tmp
SPF and DMARC DNS Records
smtpd itself does not enforce inbound SPF or DMARC - it is an MTA, not a policy engine. But you must publish correct DNS records so receiving servers accept your outbound mail. These are TXT records you add at your DNS provider, not anything you configure in smtpd.conf.
SPF should list your mail server's IP explicitly using the `ip4` or `ip6` mechanism rather than `a` or `mx` lookups, which count against the 10 DNS lookup limit per SPF evaluation. Use `-all` (hard fail) not `~all` (soft fail) once you are confident the record is complete. Many spam filters treat `~all` as equivalent to no SPF.
DMARC requires at minimum a `p=none` policy to start collecting reports. Move to `p=quarantine` after two weeks of reviewing aggregate reports showing no false positives, then `p=reject` after another two weeks. The `rua` address receives aggregate XML reports daily from every major mail provider. Parse them - they tell you exactly which IPs are sending mail claiming to be your domain, which catches compromised accounts and misconfigured third-party senders.
For environments with automated DevOps tooling that sends notifications or reports via email, services like taskbotshub.ai can help orchestrate mail-sending automation through authenticated SMTP relay on port 587, which keeps all outbound mail DKIM-signed and DMARC-aligned rather than sending directly from tool processes that may not have signing configured.
# SPF record - add as TXT at @ (root of domain)
example.com. IN TXT "v=spf1 ip4:203.0.113.42 -all"
# If you have IPv6
example.com. IN TXT "v=spf1 ip4:203.0.113.42 ip6:2001:db8::1 -all"
# DMARC record - add as TXT at _dmarc subdomain
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; sp=reject; adkim=s; aspf=s"
# After two weeks with clean reports, upgrade to quarantine
_dmarc.example.com. IN TXT "v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc-reports@example.com"
# Verify SPF and DMARC are publishing correctly
dig TXT example.com +short
dig TXT _dmarc.example.com +short
# Test full authentication chain with a real send
swaks --to check@dkimvalidator.com --from alice@example.com \
--server mail.example.com --port 587 \
--tls --auth-user alice --auth-password 'yourpassword'
Outbound Relay Configuration
Some VPS providers block port 25 outbound by default, which means direct SMTP delivery to recipient servers fails silently. Check with `nc -zv gmail-smtp-in.l.google.com 25` before assuming direct delivery works. If port 25 is blocked, configure smtpd to relay through a smarthost such as Mailgun, Postmark, or Amazon SES.
The relay action accepts a `host` and `auth` parameter pointing to a table containing credentials. Store the credentials file with 0440 permissions owned by root:_smtpd so the smtpd process can read it but unprivileged users cannot.
When using a smarthost, your outbound mail gets DKIM-signed by the relay provider's infrastructure using their domain. You lose your own DKIM signature unless you either sign before relaying (which the filter-dkimsign setup already handles - smtpd signs first, then the smarthost may add a second signature) or configure the relay provider to sign with your domain's key, which most providers support via their dashboard.
For Postmark, the relay host is smtp.postmarkapp.com on port 587. For Mailgun it is smtp.mailgun.org. Both require the full sender address as the username and your API key or SMTP password as the password.
# Test port 25 outbound access
nc -zv gmail-smtp-in.l.google.com 25
nc -zv aspmx.l.google.com 25
# If blocked, configure smarthost relay
# /etc/mail/secrets (mode 0440, root:_smtpd)
postmark smtp.postmarkapp.com:yourservertokenhere
chown root:_smtpd /etc/mail/secrets
chmod 0440 /etc/mail/secrets
# Add table and updated action to smtpd.conf
table secrets file:/etc/mail/secrets
# Replace the plain outbound action with:
action "outbound" relay host smtp+tls://postmark@smtp.postmarkapp.com:587 auth
# Reload and test
rcctl reload smtpd
echo 'Test body' | mail -s 'smtpd relay test' -r alice@example.com you@gmail.com
tail -f /var/log/maillog
Dovecot Integration for IMAP Access
smtpd delivers mail. Users read it via IMAP. Install Dovecot from packages, point it at the same Maildir directories smtpd writes to, and configure it to authenticate against the system user database. This is the minimal Dovecot config that works with the vmail setup above.
Dovecot's main configuration lives at /etc/dovecot/dovecot.conf. The critical settings are `mail_location` matching the path pattern in your smtpd action, and `first_valid_uid` set to the UID of your vmail user so Dovecot does not try to serve mail from root or system accounts.
Test the IMAP connection with openssl before pointing any mail client at it. The LOGIN command takes a base64-encoded username and password. In practice use a real mail client like Thunderbird or use the `mutt` package for terminal access.
On our test server, Dovecot 2.3.21 (the version in OpenBSD 7.6 packages as of mid-2026) initialized and served a 50,000-message Maildir in under 3 seconds on the first IMAP SELECT after index generation.
# Install Dovecot
pkg_add dovecot
# Minimal /etc/dovecot/dovecot.conf additions
protocols = imap
mail_location = maildir:/var/vmail/%d/%n
first_valid_uid = $(id -u vmail)
ssl = required
ssl_cert =
Logging, Monitoring, and Queue Management
smtpd logs to /var/log/maillog via syslog. The default log level is informational - you see connection attempts, authentication results, message IDs, delivery status, and relay outcomes. To increase verbosity for debugging, run smtpd in the foreground with `smtpd -dv` after stopping the daemon, which dumps every SMTP conversation to stderr.
The `smtpctl` utility is your operational interface. Use it to inspect the queue, force delivery retries, pause listeners, and show statistics. Messages that fail delivery enter a retry queue with exponential backoff. By default smtpd retries for 4 days before bouncing. You can change this with `bounce-warn` and `expire` directives in smtpd.conf.
For production monitoring, watch the maillog for `delivery evicted` (message expired), `temporary failure` patterns that repeat, and `authentication failure` spikes which indicate credential stuffing attacks. A simple grep piped to a counter and pushed to your monitoring system catches these in real time without additional software.
The queue directory lives at /var/spool/smtpd/. Do not touch it directly. Use smtpctl. On our test server, `smtpctl show queue` returns results in under 100ms even with 10,000 queued messages.
# Show current queue contents
smtpctl show queue
# Show delivery statistics
smtpctl show stats
# Force immediate retry for all deferred messages
smtpctl schedule all
# Pause incoming connections (for maintenance)
smtpctl pause listener 0.0.0.0:25
# Resume
smtpctl resume listener 0.0.0.0:25
# Remove a specific message by ID
smtpctl remove 4ab2f1c3d5e67890
# Show envelope details for a queued message
smtpctl show envelope 4ab2f1c3d5e67890
# Watch the log in real time during testing
tail -f /var/log/maillog | grep -E '(fail|error|reject|bounce)'
# Count authentication failures in the last hour
grep 'authentication failure' /var/log/maillog | \
awk -v d="$(date +'%b %e %H')" '$0 ~ d' | wc -l
Hardening and Rate Limiting
smtpd runs in a chroot under /var/empty with privilege-separated subprocesses by default on OpenBSD. The parent process holds no privileges after startup. Additional hardening focuses on reducing the attack surface of the exposed service itself.
Add `limit session max-rcpt 25` to cap the number of recipients per message, which limits the blast radius of an abused account sending spam. Set `limit mta max-deferred 100` to prevent queue storms. The `pki` session ciphers directive lets you restrict to TLS 1.2 and 1.3 only, discarding legacy cipher suites.
For rate limiting authentication attempts, pf handles this more cleanly than smtpd itself. Create a pf table that tracks IPs with repeated auth failures and block them for one hour. The log entries from smtpd include the source IP on every authentication failure line, so a small shell script polling the maillog can populate the pf table.
Port 25 should accept connections from any IP (you cannot predict where legitimate mail comes from). Port 587 is different - if you only have a fixed set of clients submitting mail, restrict it in pf to those source IPs and avoid exposing authenticated SMTP to the entire internet.
# Additions to smtpd.conf for hardening
limit session max-rcpt 25
limit session max-mail 100
limit mta max-deferred 100
# pf.conf additions for rate limiting port 587
table persist
block quick from
pass in on egress proto tcp to port 587 keep state \
(max-src-conn-rate 5/60, overload flush global)
# Shell snippet to block IPs with >10 auth failures in maillog
grep 'authentication failure' /var/log/maillog | \
grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | \
sort | uniq -c | sort -rn | awk '$1 > 10 {print $2}' | \
xargs -I{} pfctl -t bruteforce -T add {}
# Verify pf table contents
pfctl -t bruteforce -T show
# Check TLS negotiation details
openssl s_client -connect mail.example.com:587 -starttls smtp 2>&1 | \
grep -E '(Protocol|Cipher|Verify)'