Containerized Postfix SMTP Relay to Microsoft 365 with Certbot and Docker Compose
Running a Containerized Postfix SMTP Relay to Microsoft 365 with Certbot and Docker Compose
If you manage a Microsoft 365 tenant and need internal applications, printers, UPS units, or other devices to send email through Exchange Online, an SMTP relay is the standard solution. This post walks through building a clean, containerized Postfix relay that authenticates to M365 using a TLS certificate — no username/password, no IP allowlisting on the M365 side. Just a certificate-based inbound connector and a Postfix container that stays out of your way.
What This Setup Does
- Accepts SMTP connections on ports 25 and 587 from trusted sources
- Relays mail to Microsoft 365 via a certificate-authenticated inbound connector
- Issues and stores TLS certificates via Certbot using the Cloudflare DNS-01 challenge
- Runs entirely in Docker Compose with no host-level Postfix installation
- Logs to stdout for easy inspection via
docker compose logs
Why Alpine, Not Ubuntu
If you've tried containerizing Postfix before, you may have run into a frustrating DNS resolution problem where outbound mail delivery silently fails with errors like:
Host or domain name not found. Name service error for name=yourdomain-com.mail.protection.outlook.com type=AAAA: Host not found
This happens because on Ubuntu and Debian-based images, Postfix's outbound smtp process runs inside a chroot jail under /var/spool/postfix/. The chrooted process can't access /etc/resolv.conf on the real filesystem, so DNS queries fail entirely.
Alpine Linux does not enable chroot by default for most Postfix processes. Combined with one targeted master.cf change (covered below), Alpine sidesteps the problem cleanly.
Prerequisites
- A Linux VPS with Docker and Docker Compose installed
- Outbound port 25 unblocked by your VPS provider (Hetzner, for example, blocks this by default — submit a support ticket to have it lifted)
- A domain managed in Cloudflare DNS
- A Cloudflare API token with DNS edit permissions for the target zone
- An A record for your relay hostname (e.g.
relay.yourdomain.com) pointing to your VPS public IP — set to DNS only, no proxy - One or more accepted domains configured in your Microsoft 365 tenant
Project Structure
smtp-relay/
├── docker-compose.yml
├── Dockerfile
├── entrypoint.sh
├── cloudflare.ini
└── config/
├── main.cf
├── master.cf
├── mynetworks
├── transport
├── tls_policy
└── header_checks
The Configuration Files
cloudflare.ini
Certbot uses this to authenticate with Cloudflare and create the DNS-01 challenge record:
dns_cloudflare_api_token = <your-cloudflare-api-token>
chmod 600 cloudflare.ini
config/mynetworks
This file controls which source IPs Postfix will accept relay requests from. Uses CIDR notation with OK:
# Trusted senders
203.0.113.10/32 OK # office public IP
198.51.100.5/32 OK # secondary relay or VPS
# 100.64.0.0/10 OK # Netbird overlay (uncomment if using Netbird)
This file uses cidr: map type — no postmap required.
config/transport
Routes outbound mail to the correct M365 MX endpoint for each accepted domain. Add one entry per domain in your M365 tenant:
domain1.com :[domain1-com.mail.protection.outlook.com]:25
domain2.com :[domain2-com.mail.protection.outlook.com]:25
* :[domain1-com.mail.protection.outlook.com]:25
A few things to note here:
- Dots in domain names become dashes in the
.mail.protection.outlook.comhostname - Brackets around the hostname suppress MX lookups — Postfix connects directly to the host
- The wildcard
*at the bottom catches any domain not explicitly listed and routes it to your primary M365 endpoint - Multiple domains in the same M365 tenant can share one MX endpoint — M365 accepts mail for all accepted domains regardless of which endpoint it arrives on
This file uses texthash: map type on Alpine — no postmap required.
config/tls_policy
Enforces TLS encryption on outbound connections to M365. Add one entry per M365 domain:
[domain1-com.mail.protection.outlook.com]:25 encrypt
[domain2-com.mail.protection.outlook.com]:25 encrypt
The encrypt keyword means Postfix will refuse to deliver if TLS is unavailable — appropriate for a connector that authenticates via certificate. This file also uses texthash:.
config/header_checks
This is a PCRE file that prepends two headers to every relayed message. These headers signal to Exchange Online that the mail should be treated as internal rather than anonymous/external — which affects mail flow rules, junk filtering, and sender display in Outlook:
/^To:/i PREPEND X-MS-Exchange-CrossPremises-AuthAs: Internal
/^From:/i PREPEND X-MS-Exchange-CrossPremises-AuthSource: relay.yourdomain.com
Replace relay.yourdomain.com with your actual relay hostname. No postmap needed — Postfix reads PCRE files directly.
config/main.cf
compatibility_level = 3.6
smtpd_banner = $myhostname ESMTP $mail_name
biff = no
append_dot_mydomain = no
# Relay hostname — must match the TLS certificate CN/SAN
myhostname = relay.yourdomain.com
# Disable local alias lookup (not needed on a relay)
alias_maps =
alias_database =
myorigin = /etc/mailname
mydestination = localhost.localdomain, localhost
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
inet_protocols = all
# Map files
# cidr: and texthash: are used instead of hash: — Alpine has no postfix-hash package
mynetworks = cidr:/etc/postfix/mynetworks
transport_maps = texthash:/etc/postfix/transport
header_checks = pcre:/etc/postfix/header_checks
# 150MB message size limit (matches Exchange Online)
message_size_limit = 157286400
# Relay restrictions
smtpd_relay_restrictions =
permit_mynetworks permit_sasl_authenticated reject_unauth_destination
# Inbound TLS (for senders connecting to this relay)
smtpd_tls_security_level = may
smtpd_tls_auth_only = yes
smtpd_tls_key_file = /etc/letsencrypt/live/relay.yourdomain.com/privkey.pem
smtpd_tls_cert_file = /etc/letsencrypt/live/relay.yourdomain.com/fullchain.pem
smtpd_tls_dh1024_param_file = /etc/postfix/dh2048.pem
smtpd_tls_protocols = !SSLv3, !SSLv2, !TLSv1, !TLSv1.1
smtpd_tls_ciphers = high
smtpd_tls_exclude_ciphers = aNULL, MD5, EXPORT
smtpd_tls_mandatory_protocols = $smtpd_tls_protocols
smtpd_tls_mandatory_ciphers = $smtpd_tls_ciphers
smtpd_tls_mandatory_exclude_ciphers = $smtpd_tls_exclude_ciphers
smtpd_tls_received_header = yes
smtpd_tls_session_cache_timeout = 3600s
tls_preempt_cipherlist = no
tls_random_source = dev:/dev/urandom
# Outbound TLS (for connecting to M365)
smtp_tls_policy_maps = texthash:/etc/postfix/tls_policy
smtp_tls_cert_file = /etc/letsencrypt/live/relay.yourdomain.com/fullchain.pem
smtp_tls_key_file = /etc/letsencrypt/live/relay.yourdomain.com/privkey.pem
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
# Send Postfix logs to stdout for Docker log capture
maillog_file = /dev/stdout
Two important notes:
- Do not add your M365 domains to
mydestination. That tells Postfix to deliver locally instead of relaying. alias_maps =andalias_database =are set to empty to suppress Alpine's defaultlmdb:alias lookup, which would produce errors since no alias database exists on a relay box.
config/master.cf
Start from Postfix's default master.cf. Two changes are required:
1. Disable chroot on the outbound smtp process.
Find this line:
smtp unix - - y - - smtp
Change the chroot column (y) to n:
smtp unix - - n - - smtp
This is the most critical change in the entire setup. The third column controls whether the process runs in a chroot jail. When y, the outbound smtp process looks for DNS config inside /var/spool/postfix/etc/resolv.conf — a path that doesn't exist in a fresh container — and DNS resolution fails silently. Setting it to n gives the process access to the real filesystem and resolv.conf.
2. Enable the submission port (587).
Find and uncomment:
#submission inet n - y - - smtpd
So it reads:
submission inet n - y - - smtpd
Dockerfile
FROM alpine:3.19
RUN apk add --no-cache \
postfix \
postfix-pcre \
openssl \
cyrus-sasl
COPY config/main.cf /etc/postfix/main.cf
COPY config/master.cf /etc/postfix/master.cf
COPY config/mynetworks /etc/postfix/mynetworks
COPY config/transport /etc/postfix/transport
COPY config/tls_policy /etc/postfix/tls_policy
COPY config/header_checks /etc/postfix/header_checks
# Set mailname — used as envelope sender domain of last resort
RUN echo "yourdomain.com" > /etc/mailname
# Fix file permissions to suppress Postfix security warnings
RUN chmod 640 /etc/postfix/main.cf \
/etc/postfix/master.cf \
/etc/postfix/transport \
/etc/postfix/tls_policy \
/etc/postfix/mynetworks \
/etc/postfix/header_checks
# Generate DH params for TLS forward secrecy
RUN openssl dhparam -out /etc/postfix/dh2048.pem 2048
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]
A few Alpine-specific notes:
postfix-hashdoes not exist in Alpine 3.19 — this is whytexthash:is used instead ofhash:throughout main.cfpostfix-pcreis a separate package and must be explicitly installed forheader_checksto work- No
postmapstep is needed sincetexthash:andcidr:map types read plain text files directly
entrypoint.sh
Keep this minimal. Create it directly on the server to avoid Windows CRLF line ending issues, which cause Alpine's shell to fail silently:
cat > entrypoint.sh << 'EOF'
#!/bin/sh
exec postfix start-fg
EOF
chmod +x entrypoint.sh
docker-compose.yml
services:
certbot:
image: certbot/dns-cloudflare
volumes:
- certs:/etc/letsencrypt
- ./cloudflare.ini:/cloudflare.ini:ro
command: >
certonly
--dns-cloudflare
--dns-cloudflare-credentials /cloudflare.ini
--non-interactive
--agree-tos
-m admin@yourdomain.com
-d relay.yourdomain.com
postfix:
build: .
network_mode: host
depends_on:
certbot:
condition: service_completed_successfully
volumes:
- certs:/etc/letsencrypt:ro
restart: unless-stopped
volumes:
certs:
Two design decisions worth explaining:
network_mode: host — M365's SMTP endpoints (*.mail.protection.outlook.com) publish IPv6 (AAAA) records. Docker's default bridge network does not expose the host's IPv6 stack to containers. Host networking gives the container direct access to the VPS's IPv6 address, ensuring Postfix can resolve and connect to M365 endpoints. Port mappings are not needed with host networking — Postfix binds directly to host ports 25 and 587.
depends_on: service_completed_successfully — Certbot is not a long-running service. It runs, issues the certificate, and exits with code 0. Postfix only starts after Certbot exits cleanly, ensuring cert files exist at the expected paths before Postfix tries to load them.
Microsoft 365 Inbound Connector
- Sign in to the Exchange Admin Center
- Navigate to Mail flow > Connectors and click Add a connector
- Set From to "Your organization's email server" and To to "Office 365"
- Name the connector (e.g.
relay.yourdomain.com) - Under How to identify email from your server, select the certificate option and enter your relay's FQDN as the subject name to match (e.g.
relay.yourdomain.com) - Enable the connector and save
When Postfix connects to M365, it presents the Let's Encrypt certificate during the TLS handshake. M365 verifies that the certificate's subject name matches what is configured in the connector. This is the authentication mechanism — no IP allowlisting required.
Handling Mail to External Recipients
The header_checks file prepends X-MS-Exchange-CrossPremises-AuthAs: Internal to every relayed message. This is intentional for internal recipients — it tells Exchange to treat the mail as trusted internal. However, when Exchange receives a message with that header destined for an external address (like @gmail.com), it gets confused and rejects it with:
451 4.4.4 Mail received as unauthenticated, incoming to a recipient domain configured in a hosted tenant which has no mail-enabled subscriptions
The fix is two Exchange Online transport rules that strip the internal headers before Exchange attempts outbound delivery to external addresses.
Create the Transport Rules via PowerShell
The Exchange Admin Center GUI only allows one "Remove a header" action per rule. PowerShell handles both headers cleanly. Connect to Exchange Online first:
Install-Module -Name ExchangeOnlineManagement
Connect-ExchangeOnline -UserPrincipalName admin@yourdomain.com -Device
Then create the two rules:
New-TransportRule -Name "Strip AuthAs Header for External Recipients" `
-SentToScope "NotInOrganization" `
-RemoveHeader "X-MS-Exchange-CrossPremises-AuthAs"
New-TransportRule -Name "Strip AuthSource Header for External Recipients" `
-SentToScope "NotInOrganization" `
-RemoveHeader "X-MS-Exchange-CrossPremises-AuthSource"
Verify they were created correctly:
Get-TransportRule | Where-Object {$_.Name -like "*Strip*"} | Select Name, RemoveHeader, SentToScope
Expected output:
Name RemoveHeader SentToScope
---- ------------ -----------
Strip AuthAs Header for External Recipients X-MS-Exchange-CrossPremises-AuthAs NotInOrganization
Strip AuthSource Header for External Recipients X-MS-Exchange-CrossPremises-AuthSource NotInOrganization
With these rules in place the behavior is:
- Mail to internal recipients (
@yourdomain.com) — headers stay, treated as internal ✅ - Mail to external recipients (
@gmail.com, etc.) — headers stripped, routed as normal outbound ✅
Sender Domain Must Be an Accepted Domain
A separate but related issue: if your application sends from a subdomain (e.g. noreply@app.yourdomain.com), M365 will reject it unless that subdomain is an accepted domain in your tenant. The error looks like:
451 4.4.4 Mail received as unauthenticated, incoming to a recipient domain configured in a hosted tenant which has no mail-enabled subscriptions
Fix this in Exchange Admin Center → Settings → Domains → Add domain, adding the subdomain as an Internal relay domain. This tells Exchange to accept mail from that sender and route it outbound without requiring a mailbox.
Routing Personal Microsoft Accounts (outlook.com, hotmail.com)
If your transport map uses a wildcard * pointing to your M365 inbound endpoint, mail to personal Microsoft accounts (@outlook.com, @hotmail.com, @live.com) will fail with:
451 4.4.62 Mail sent to the wrong Office 365 region
Personal Microsoft accounts use separate MX servers from Exchange Online business. Add explicit entries to config/transport to route them via direct MX delivery instead:
domain1.com :[domain1-com.mail.protection.outlook.com]:25
domain2.com :[domain2-com.mail.protection.outlook.com]:25
outlook.com :[]
hotmail.com :[]
live.com :[]
* :[domain1-com.mail.protection.outlook.com]:25
The empty :[]: syntax tells Postfix to perform a standard MX lookup and deliver directly rather than routing through your M365 endpoint.
SPF Records
Add your VPS public IP to the SPF record for each domain you relay from. In Cloudflare DNS, update the TXT record for each domain:
v=spf1 ip4:<your-vps-public-ip> include:spf.protection.outlook.com ~all
Without this, relayed mail may land in recipients' junk folders.
Deploying
docker compose up --build -d
On first run, Certbot issues the certificate and exits. Postfix starts once Certbot completes. On subsequent runs, Certbot skips issuance (cert already exists) and exits immediately.
Check logs:
docker compose logs -f postfix
A clean startup looks like:
postfix/master[1]: daemon started -- version 3.8.13, configuration /etc/postfix
Testing
From a machine whose IP is in mynetworks:
openssl s_client -connect relay.yourdomain.com:587 -starttls smtp
Wait for Verify return code: 0 (ok), then send a test message:
EHLO relay.yourdomain.com
mail from: test@yourdomain.com
rcpt to: recipient@yourdomain.com
data
To: recipient@yourdomain.com
From: test@yourdomain.com
Subject: Relay Test
Testing containerized Postfix relay.
.
quit
A successful relay returns:
250 2.0.0 Ok: queued as XXXXXXXXXX
In the Postfix logs, successful delivery to M365 looks like:
status=sent (250 2.6.0 ... Queued mail for delivery)
If mail is queued but not arriving, use Message Trace in Exchange Admin Center under Mail flow > Message trace to trace it on the M365 side.
Certificate Renewal
Let's Encrypt certificates expire after 90 days. Add a cron job on the host:
crontab -e
0 12 * * * cd /path/to/smtp-relay && docker compose run --rm certbot renew && docker compose exec postfix postfix reload
This runs daily at noon, renews if within 30 days of expiry, and reloads Postfix to pick up the new cert.
Troubleshooting Reference
| Symptom | Cause | Fix |
|---|---|---|
Host or domain name not found type=AAAA |
Outbound smtp process is chrooted | Set smtp unix - - n in master.cf |
554 5.7.1 Relay access denied |
Source IP not in mynetworks | Add IP to config/mynetworks, rebuild |
unsupported dictionary type: hash |
Alpine has no postfix-hash package | Use texthash: instead of hash: in main.cf |
open database /etc/postfix/aliases.lmdb |
Alpine defaults to lmdb for aliases | Set alias_maps = and alias_database = to empty |
exec /entrypoint.sh: no such file or directory |
CRLF line endings in entrypoint.sh | Recreate with heredoc on the server |
| Connection refused on port 587 | Submission port not enabled | Uncomment submission inet in master.cf |
| Mail sent but not received | M365 connector not set up or SPF missing | Create inbound connector; update SPF records |
451 4.4.4 Mail received as unauthenticated |
Sender domain not an accepted domain in M365 | Add subdomain as Internal relay domain in Exchange Admin Center |
451 4.4.62 Mail sent to wrong Office 365 region |
Personal outlook.com/hotmail.com routed to business endpoint | Add explicit transport entries with empty relay for those domains |
| External recipients not receiving mail | Internal headers not stripped before outbound delivery | Create Exchange transport rules to remove CrossPremises headers for NotInOrganization |
Summary
The combination of Alpine Linux, network_mode: host, disabling chroot on the outbound smtp process, and using texthash: map types resolves every compatibility issue that makes containerizing Postfix tricky on standard Debian-based images. The result is a lightweight, self-contained relay that requires no host-level configuration beyond firewall rules and runs cleanly in Docker Compose.