Shore Up
A small neighbourhood post box collecting local letters and handing them all to a single trusted courier, who carries the bundle onward to a large central sorting depot.
Linux

Deploy a Postfix Relay with Ansible

Ketan Aagja8 min read
No ratings yet

Before you run this

This playbook installs Postfix on one or more target hosts and configures it as a send-only relay (a "smarthost" client): local mail is handed to an upstream provider over an authenticated, TLS-encrypted SMTP submission connection, and the host does not accept mail from the network. Its purpose is to give servers a reliable way to send notifications, cron output, and application mail without each app talking to your provider directly.

Privileges: the tasks install a package and rewrite files under /etc/postfix, so they run with become: true (sudo/root) on the targets. On the control node you only need Ansible and SSH access to those targets. The upstream SMTP password is a secret — keep it in ansible-vault, not in plain group vars.

Test first: read the playbook, then run it against a single throwaway VM (or with --check --diff) before you touch anything that other systems depend on. Installing Postfix where a mail server already runs will collide with the existing MTA, so never point this at a live mail host.

What it changes, and reversibility: it installs the postfix package, appends a marked block to /etc/postfix/main.cf, and writes /etc/postfix/sasl_passwd (containing your credentials) plus its hashed .db. The main.cf block is fenced with Ansible markers and can be removed cleanly; the credential file must be deleted by hand. Setting inet_interfaces = loopback-only means the host will stop listening for inbound SMTP on external interfaces — deliberate for a relay, but confirm nothing was relying on it receiving mail.

I assume an Ansible 2.15+ control node, Ubuntu 22.04 LTS targets, and an upstream that accepts authenticated submission on port 587. On RHEL/Alma the package is still postfix but you would use ansible.builtin.dnf, and the CA bundle path differs (see the note on smtp_tls_CAfile below).

The variables

Keep non-secret settings in group_vars/relay.yml:

# group_vars/relay.yml
relay_smarthost: "smtp.example.com"   # your provider's submission host
relay_port: 587
relay_username: "postmaster@example.com"

Put the password in an encrypted file so it never lands in git in the clear:

ansible-vault create group_vars/relay_secret.yml

Inside that file:

relay_password: "REPLACE-WITH-REAL-PASSWORD"

Replace smtp.example.com, the username, and the password with your real values. relay here is just the inventory group name — put your target hosts under [relay] in your inventory.

The playbook

---
# postfix-relay.yml
- name: Configure Postfix as a send-only relay
  hosts: relay
  become: true

  tasks:
    # Preseed the debconf answers so apt doesn't open an interactive dialog.
    - name: Preseed Postfix mailer type
      ansible.builtin.debconf:
        name: postfix
        question: postfix/main_mailer_type
        value: "Internet Site"
        vtype: select

    - name: Preseed Postfix system mail name
      ansible.builtin.debconf:
        name: postfix
        question: postfix/mailname
        value: "{{ ansible_fqdn }}"
        vtype: string

    - name: Install Postfix
      ansible.builtin.apt:
        name: postfix
        state: present
        update_cache: true

    # Relay-specific settings appended as a marked block. Directives later in
    # main.cf win, so this block overrides the packaged defaults cleanly and
    # can be removed later with state: absent.
    - name: Apply relay settings to main.cf
      ansible.builtin.blockinfile:
        path: /etc/postfix/main.cf
        marker: "# {mark} ANSIBLE MANAGED RELAY BLOCK"
        block: |
          relayhost = [{{ relay_smarthost }}]:{{ relay_port }}
          smtp_sasl_auth_enable = yes
          smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
          smtp_sasl_security_options = noanonymous
          smtp_tls_security_level = encrypt
          smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
          inet_interfaces = loopback-only
          inet_protocols = ipv4
      notify: Restart Postfix

    # Credential map. 0600 so only root can read the password.
    - name: Write SASL credential map
      ansible.builtin.copy:
        dest: /etc/postfix/sasl_passwd
        content: "[{{ relay_smarthost }}]:{{ relay_port }} {{ relay_username }}:{{ relay_password }}\n"
        owner: root
        group: root
        mode: "0600"
      notify: Rebuild SASL map

  handlers:
    - name: Rebuild SASL map
      ansible.builtin.command:
        cmd: postmap /etc/postfix/sasl_passwd
      notify: Restart Postfix

    - name: Restart Postfix
      ansible.builtin.service:
        name: postfix
        state: restarted

Run it:

ansible-playbook -i inventory postfix-relay.yml --ask-vault-pass

Do a dry run first — --check --diff shows exactly which lines would change without touching the host.

Why these directives

A few of the settings above are the load-bearing ones, so it's worth being explicit:

  • relayhost = [smtp.example.com]:587 — the square brackets tell Postfix to use the host as given and skip an MX lookup, which is what you want for a fixed submission host.
  • smtp_tls_security_level = encrypt forces TLS on the outbound connection and refuses to send if it can't negotiate it. That is the right posture when you're shipping credentials.
  • smtp_tls_CAfile points at the system CA bundle so the smarthost's certificate is actually verified. /etc/ssl/certs/ca-certificates.crt is the Debian/Ubuntu path (from the ca-certificates package); on RHEL/Alma it is /etc/pki/tls/certs/ca-bundle.crt.
  • inet_interfaces = loopback-only stops Postfix listening for inbound mail on the network — a relay client has no business accepting external connections.

The postmap command turns the plain sasl_passwd file into the hashed sasl_passwd.db that Postfix actually reads; the handler runs it whenever the credential file changes. If your Postfix build was compiled without Berkeley DB support (uncommon on stock Ubuntu) the hash: type won't exist — check postconf -m on the target and, if so, switch both the map type and the postmap invocation to lmdb per the Postfix documentation. Don't guess this; postconf -m lists exactly which map types your binary supports.

Verify it worked

SSH to a target and check the service and the effective configuration:

systemctl status postfix
postconf -n            # shows only non-default settings; confirm relayhost etc.
postconf relayhost     # should print your smarthost

Confirm the hashed map exists:

ls -l /etc/postfix/sasl_passwd.db

Then send a real test message. If mailutils (which provides the mail command) isn't installed, swaks is a good alternative — either way, install it separately:

echo "relay test $(date)" | mail -s "relay test" you@example.com

Watch the log as it goes out:

tail -f /var/log/mail.log

A successful hand-off shows a line ending in status=sent with the smarthost's response. status=deferred with an authentication or TLS error tells you the credentials or port are wrong. Check the queue with postqueue -p; an empty queue means everything was accepted.

Undo

To back the relay settings out, remove the managed block by re-running a task with state: absent on the same blockinfile (Ansible finds it by the marker) and delete the credential files, then restart:

# on the target, manual rollback:
sudo rm -f /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
sudo systemctl restart postfix

To remove Postfix entirely on a test box, sudo apt purge postfix. Don't purge a host that other services rely on for local mail delivery without confirming what replaces it. Because the config change is a single marked block, reverting it is low-risk — but the credential file is the one thing Ansible won't clean up for you, so remove it by hand when you decommission the relay.

For the official directive reference, see the Postfix documentation for postconf(5) and the "Enabling SASL authentication in the Postfix SMTP client" section on the Postfix project site, and Ansible's own docs for the debconf, blockinfile, and apt modules.

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.