[SHARE] mail-bsd — Ansible playbooks for a full mail stack on FreeBSD and OpenBSD

Hi all,

I've been setting up mail servers on FreeBSD and OpenBSD for a while and got tired of repeating the same steps every time, so I wrote a set of Ansible playbooks to automate the whole thing.

mail-bsd deploys a complete mail stack from scratch with a single command:

- OpenSMTPD — MTA (ports 25, 465, 587)
- Dovecot 2.3 — IMAP + Sieve filtering
- Rspamd — spam filtering and DKIM signing

What the playbook does:
- Installs and configures all packages from Jinja2 templates
- Generates a 2048-bit RSA DKIM key pair
- Generates 4096-bit Diffie-Hellman parameters for Dovecot
- Sets up standard mailbox folders with auto-expunge on Junk (30 days)
- Uses bsdauth on OpenBSD and PAM on FreeBSD

Prerequisites are minimal: SSH access as root, and a TLS certificate already in place. Everything else is handled by the playbook.

The repo also includes the full DNS records table (A, MX, SPF, DKIM) and instructions to extract the DKIM public key for the TXT record, which is always the fiddly part.

A note on Dovecot: the playbooks target 2.3, which is the version currently in both ports trees. They will be updated once 2.4 lands.


Feedback welcome — especially from anyone running this on less common setups.
 
Some notes:
Code:
- name: Install required packages
      community.general.openbsd_pkg:
Use ansible.builtin.package

Code:
    - name: Deploy rspamd configuration
      ansible.builtin.template:
        src: templates/dkim_signing.conf.j2
        dest: "/etc/rspamd/local.d/dkim_signing.conf"
        owner: "{{ rspamduser }}"
        group: "{{ rspamduser }}"
        mode: '0640'
Wrong directory (should be /usr/local/etc/{...}), just one example, there are more incorrect paths. This will also fail because the directory path doesn't exist.

Code:
    - name: Generate DKIM private key
      community.crypto.openssl_privatekey:
You should add a requirements.yml.

Code:
    - name: Enable and start services
      ansible.builtin.service:
        name: "{{ item }}"
        state: restarted
        enabled: true
      loop:
        - dovecot
        - smtpd
        - rspamd
Should be state: started, and a restart handler added if/when any of the configuration files are modified. Currently your services get restarted every time the playbook is run, it should be idempotent.
 
I went with root login for simplicity since many BSD setups don't have sudo installed by default,
It could use su(1) instead. But this would require a user account in the wheel group. So either way you're going to need to prepare the images in order for it to work.
 
Thanks. I have cloned your repo and have had a quick look. I like reading playbooks developed by others as I often find something new to learn after 12 years of using Ansible.
 
It could use su(1) instead.

There is also mdo/mac_do now in the FreeBSD kernel since 14.3 (partial support), but fully implemented in 15.0 .

 
Another suggestion: Use 'notify' so when Ansible does change something, it runs a handler to do an appropriate action following that change. This could be restarting a service, including another special task to run, or rebooting. Whenever, I use a template there is usually an associated handler to be notified.
 
Within a few days of beginning my Ansible journey, I switched to rigidly sticking to creating all of my playbooks as Ansible Roles. I never use the flat playbook style that you have used. Using the role structure makes it trivial to create multi-OS and multi-distro variations particularly if you learn how to use meta and variable scoping.

Ansible is not object oriented but you can achieve inheritance of generic roles using meta. I use this heavily. I write playbooks to be as generic as possible and use these as my 'base class', then create derived roles that deploy specific customisations which invoke the base class deployment first using meta/main.yml

If using roles, always prefix your variable names with the name of the role, you will appreciate this later when you have created many roles.
E.g. for a role called 'nginx'
The servername variable would be nginx_servername
The port variable would be nginx_port

I found this Ansible role tutorial that includes an interesting solution for doas/sudo:

Ansible role directory structure:

I started off using 'common' as a role, but I refactored it some years ago into multiple roles following an X500 style hierarchy that separated common tasks into tasks common for C=, tasks common for S=, L=, OU=, DN= etc. Common can quickly become not as common as you would hope it to be.
 
Back
Top