Your Rails / Django / Node / Laravel app runs in a container. The container has a sidecar Postfix for outbound SMTP, or connects to a Postfix service container. Mail leaves the host fine — then Gmail flags 80% of it as Spam. You check SPF: pass. DKIM: pass. DMARC: pass. Still Spam. What now?
Containerized mail hits deliverability issues that bare-metal Postfix never sees. This article lays out the five failure modes unique to Docker, and the working Dockerfile + docker-compose + DNS config that solves them.
Five deliverability failures unique to containers
- Source IP mismatch. Container sends via Docker bridge NAT; outbound appears to come from the host IP. If the host PTR doesn't match your mail hostname, FCrDNS fails at every recipient.
- DKIM keys in images. Key files baked into Docker images leak via layer caches. Keys mounted as secrets but not persisted across container restarts cause DKIM failures mid-send.
- Ephemeral hostnames. Postfix defaults
myhostnameto the container's/etc/hostname, which is the container ID (f7a2c4b1d3e5). HELO announces this random hash — Gmail and Microsoft reject or score down. - Queue loss on restart. Undeliverable mail sits in
/var/spool/postfix. If you don't mount a volume, everydocker compose downeats the queue. - No IPv6 reverse. Docker's default IPv6 is on host. If IPv6 AAAA exists but PTR6 doesn't, Gmail prefers IPv6 and fails FCrDNS.
Dockerfile: Postfix that doesn't embarrass you
# Dockerfile - Postfix + OpenDKIM relay sidecar
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
postfix postfix-pcre \
opendkim opendkim-tools \
ca-certificates rsyslog \
supervisor \
&& rm -rf /var/lib/apt/lists/*
# Do NOT bake DKIM keys into image. Mount via docker secret/volume.
RUN mkdir -p /etc/opendkim/keys && chown opendkim:opendkim /etc/opendkim
COPY postfix/main.cf /etc/postfix/main.cf
COPY postfix/master.cf /etc/postfix/master.cf
COPY opendkim/opendkim.conf /etc/opendkim.conf
COPY opendkim/KeyTable /etc/opendkim/KeyTable
COPY opendkim/SigningTable /etc/opendkim/SigningTable
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
EXPOSE 25 587
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]Supervisord runs Postfix, OpenDKIM and rsyslogd together — containers expect PID 1 and only one process, but mail needs all three. Alternative: split into three containers with a shared socket volume (more moving parts, cleaner failure modes).
docker-compose: persistence + network
# docker-compose.yml
services:
app:
build: ./app
environment:
SMTP_HOST: postfix
SMTP_PORT: 587
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
depends_on: [postfix]
postfix:
build: ./postfix
hostname: mail.yourdomain.com # critical: HELO identity
domainname: yourdomain.com
environment:
POSTFIX_myhostname: mail.yourdomain.com
POSTFIX_mynetworks: 127.0.0.0/8,[::1]/128,172.16.0.0/12
volumes:
- postfix-queue:/var/spool/postfix # persist queue
- postfix-data:/var/lib/postfix # persist master.db
- ./secrets/dkim:/etc/opendkim/keys:ro # mount DKIM keys
ports:
- "25:25" # only if you accept inbound; else omit
- "587:587"
restart: unless-stopped
volumes:
postfix-queue:
postfix-data:The hostname: directive sets both/etc/hostname inside the container and the HELO identity Postfix announces. Without it, Postfix announces the container ID. Mounting the queue volume means a container restart doesn't drop deferred mail.
Postfix main.cf for containers
# postfix/main.cf
# Identity
myhostname = mail.yourdomain.com
mydomain = yourdomain.com
myorigin = $mydomain
# Networks - trust the Docker subnet, reject everyone else
mynetworks = 127.0.0.0/8 [::1]/128 172.16.0.0/12
# SMTP relay - most containerized setups relay through SES/Mailgun/SendGrid
# (doing direct-to-MX from containers is possible but extra work)
relayhost = [email-smtp.eu-west-1.amazonaws.com]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = static:${SMTP_USER}:${SMTP_PASS}
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt
smtp_tls_wrappermode = no
# DKIM - via OpenDKIM milter on Unix socket or TCP
smtpd_milters = inet:localhost:8891
non_smtpd_milters = inet:localhost:8891
milter_default_action = accept
milter_protocol = 6
# Be strict about what we accept
smtpd_recipient_restrictions =
permit_mynetworks
permit_sasl_authenticated
reject_unauth_destination
# Do not announce Postfix version
smtpd_banner = $myhostname ESMTP
disable_vrfy_command = yesRelaying through a reputable ESP (SES, Mailgun, Postmark) is the lowest-risk deliverability path from a container. The ESP holds the sending IP reputation; your container just hands off with auth. Direct-to-MX from a container is possible but you need static IP, PTR, warmup and care — almost never worth it for an app that mostly sends transactional mail.
DKIM key handling — don't bake into images
DKIM private keys are secrets. Baking them into a Dockerfile embeds them in image layers that anyone with pull access can extract. Instead, mount them as a volume or Docker secret:
# Generate DKIM keypair on a secure host, not in CI
$ opendkim-genkey -d yourdomain.com -s s1 -b 2048
# Mount the private key read-only
services:
postfix:
secrets:
- source: dkim-s1
target: /etc/opendkim/keys/yourdomain.com/s1.private
mode: 0600
secrets:
dkim-s1:
file: ./secrets/dkim/s1.private # not committed to git
# Publish public key at s1._domainkey.yourdomain.com as TXT record:
# v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...The private key file never enters the image. If someone steals the image they get your code, not your DKIM key. Rotate keys by issuing a new selector (s2), publishing both TXT records, switching OpenDKIM's SigningTable, and retiring s1 after 30 days.
DNS for the container's public face
- A record:
mail.yourdomain.com→ host IP (the IP that leaves Docker's NAT). - PTR: host IP →
mail.yourdomain.com. Set this at your VPS provider; not possible from inside Docker. - SPF:
v=spf1 include:amazonses.com -all(if relaying through SES). Match the relay. - DKIM: published at the selector matching what OpenDKIM signs with. 2048 bits.
- DMARC:
p=nonewithruaduring stabilization,quarantineorrejectafter two weeks of clean reports.
IPv6 gotcha
Docker's default networks have IPv6 off. Your host usually has IPv6 on. Postfix inside the container follows inet_protocols = all and attempts IPv6 first. If your host has AAAA but no PTR6, every IPv6 delivery to Gmail fails FCrDNS.
# Force IPv4 only in Postfix if you don't have PTR6
# /etc/postfix/main.cf
inet_protocols = ipv4
# Then in docker-compose, don't expose IPv6
services:
postfix:
networks:
- app-net
networks:
app-net:
enable_ipv6: falsePersist the queue, watch the logs
The Postfix queue lives in /var/spool/postfix. Without a volume mount, every container replacement loses deferred mail. Mount it. Also persist/var/log/mail.log or ship via syslog to a log aggregator — the log is how you notice Gmail tempfailing you for reputation reasons.
Test the container's actual placement
Compose up, trigger your app's highest-stakes mail, send it to 20+ seed addresses across providers. Read the Authentication-Results headers. This is the only way to tell if your container's mail is actually being DKIM-signed, SPF-aligned, and delivered to Inbox.
- Free seed mailboxes at Inbox Check — no signup, 20+ providers.
- Send one real message per provider family.
- Read Inbox vs Spam vs Promotions and the raw headers.
- Re-test after any Dockerfile or compose change.
Frequently asked questions
Should I run Postfix in a container or on the host?
Can I use sendmail or msmtp instead of Postfix?
msmtp is lighter and fine. Postfix wins if you need queueing, retries, and a real submission path.sendmail (the binary, not the MTA) usually just shells out to Postfix or msmtp.How do I rotate DKIM keys without downtime?
s2. Publish both TXT records. Update OpenDKIM's SigningTable to use s2. Reload. New messages sign with s2; in-flight verify against s1. Removes1 from DNS after 30 days.Is Mailhog/Mailcatcher production-safe?
docker-compose.dev.yml, disastrous in production. Different compose file per environment.