
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.
Related guides
Roll Out an /etc/hosts or resolv.conf Change Across Servers with Ansible
This guide pushes a name-resolution change — either a static entry in /etc/hosts or an upstream DNS server change — to a group of servers at once using Ansible. Getting name resolution wrong across a fleet is one of the fastest ways to take down a whole environment: a bad /etc/hosts line or a dead DNS server means services can't find each other, monitoring goes dark, and package mirrors and time sync stop working.
Harden SSH Across a Fleet with Ansible
This playbook drops a single sshd configuration file ( /etc/ssh/sshd_config.d/99-hardening.conf ) onto every host in your inventory and reloads the SSH service. It disables root login, disables password authentication, requires public-key auth, and tightens a handful of session and auth limits.
Automatically Ban Abusive IPs in Postfix with Fail2ban
The standard, boring way to block IPs that hammer your mail server is Fail2ban. It watches the mail log, counts matching failures per source IP inside a time window, and when a source crosses a threshold it inserts a firewall rule to drop that IP for a while. You could write a bash script that greps the log and pipes IPs into nft , and I'll say where that fits at the end — but reinventing Fail2ban is more error-prone than configuring it, so that's what this guide does.
Automate a Weekly Patch-and-Report Routine for a Small Server Fleet
This sets up a weekly, unattended package upgrade on each server, then emails you a plain-text report of what was upgraded and whether a reboot is now pending. The upgrade step runs apt-get upgrade non-interactively, so it changes installed software on the host. Package upgrades are not cleanly reversible: apt has no "undo the last upgrade" button, so treat this with the same caution as any change to a running server.




