
Deploy a Postfix Relay with Ansible
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 = encryptforces 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_CAfilepoints at the system CA bundle so the smarthost's certificate is actually verified./etc/ssl/certs/ca-certificates.crtis the Debian/Ubuntu path (from theca-certificatespackage); on RHEL/Alma it is/etc/pki/tls/certs/ca-bundle.crt.inet_interfaces = loopback-onlystops 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.
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.
