Infrastructure9 min read

Docker + Postfix: make containerized mail actually deliver

Containers don't have reverse DNS. Containers don't persist DKIM keys. Containers send with source IP that isn't your sending IP. Here's the five-layer fix that gets Docker Postfix out of Spam and into the Inbox.

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

  1. 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.
  2. 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.
  3. Ephemeral hostnames. Postfix defaults myhostname to the container's /etc/hostname, which is the container ID (f7a2c4b1d3e5). HELO announces this random hash — Gmail and Microsoft reject or score down.
  4. Queue loss on restart. Undeliverable mail sits in/var/spool/postfix. If you don't mount a volume, everydocker compose down eats the queue.
  5. 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 = yes
Relay vs direct-to-MX from containers

Relaying 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=none with rua during stabilization,quarantine or reject after 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: false

Persist 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?

Either works. Container gives you reproducibility; host gives you simpler PTR and queue persistence. For a single-host app, host Postfix is easier. For a Kubernetes cluster, containerize and relay through an ESP.

Can I use sendmail or msmtp instead of Postfix?

For a simple one-way relay with no inbound, 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?

Generate a new key at selector 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?

No — those are dev-only tools that capture mail locally without sending. Great in docker-compose.dev.yml, disastrous in production. Different compose file per environment.
Related reading

Check your deliverability across 20+ providers

Gmail, Outlook, Yahoo, Mail.ru, Yandex, GMX, ProtonMail and more. Real inbox screenshots, SPF/DKIM/DMARC, spam engine verdicts. Free, no signup.

Run Free Test →

Unlimited tests · 20+ seed mailboxes · Live results · No account required