Most Postfix installations sending serious volume are running with the wrong settings — not the default settings, which are actually pretty reasonable for low volume, but settings that someone copied from a blog post in 2016 and have been cargo-culted ever since. The result is usually one of two failure modes: throughput that soft-caps at some fraction of what the server should be capable of, or throughput that looks fine until a specific ISP starts deferring and suddenly the deferred queue balloons to 200,000 messages and the whole thing seizes.
I've debugged both failure modes more times than I care to count. This guide is what I wish I'd had the first time — not an exhaustive parameter reference (you have the Postfix documentation for that), but the specific settings that actually move the needle on performance, the interactions between them that make tuning non-obvious, and the monitoring approach that tells you what is actually happening before it becomes a crisis at 2am.
Where Postfix Throughput Actually Dies
Before touching any tuning parameter, understand where your current bottleneck is. Postfix throughput is limited by the lowest-capacity point in the pipeline, and tuning the wrong stage does nothing. The three places throughput dies:
1. The master.cf process limit: Postfix's master.cf sets a global default_process_limit that caps the number of concurrent smtp client processes. The default is 100. If you have smtp_destination_concurrency_limit = 40 set in main.cf but default_process_limit = 100 in master.cf, you can have at most 100 smtp processes total — meaning you can simultaneously connect to at most 2.5 destinations at 40 connections each. On a modern 8-core server with decent network, this is a silly bottleneck.
2. The queue manager's active queue capacity: The queue manager reads messages from the deferred and incoming queues and places them in the active queue for delivery. qmgr_message_active_limit caps how many messages the active queue holds simultaneously. The default of 20,000 sounds like a lot — until you're sending to five ISPs at 40 connections each and processing 800 messages per second. At that rate, 20,000 active messages is roughly 25 seconds of queue depth. If ISP rate limiting causes any of those 800/second to defer, the active queue fills to capacity and the queue manager stops pulling from deferred. Throughput flatlines.
3. Filesystem I/O: Every message in Postfix involves multiple file operations: write to incoming, hardlink to active, delete from active after delivery. On a heavily-loaded server sending 300,000 messages per day, the queue directory sees thousands of file operations per hour. If /var/spool/postfix is on a shared ext4 filesystem with everything else on the root device, this I/O competes with everything the OS is doing. The symptoms look like CPU and network sitting idle while throughput is 30% of expected — the bottleneck is directory operations.
# Quick bottleneck diagnosis: # 1. Is the active queue capacity being hit? postfix/qmgr:.*message-active.limit # in /var/log/mail.log # If you see this, raise qmgr_message_active_limit # 2. Are you hitting the process limit? ps aux | grep smtp | grep -v grep | wc -l # If this is near default_process_limit, raise it # 3. Is I/O the bottleneck? iostat -x 1 10 | grep -E "Device|sda" # If %util is above 70% for the queue filesystem, it's I/O
Queue Manager: The Settings Nobody Reads Until It Breaks
The queue manager (qmgr) is the traffic controller that decides which messages get delivered when. Its settings control throughput in ways that the smtp client settings cannot compensate for. The parameters that actually matter:
# /etc/postfix/main.cf — queue manager settings # How many messages qmgr holds in the active queue simultaneously # Default: 20000 — raise this proportionally with your send volume # For 500k+ daily sends, start with 50000 qmgr_message_active_limit = 50000 # How many recipients qmgr processes per message delivery attempt # Default: 50 — fine for most cases qmgr_message_recipient_limit = 50 # Maximum size of the active queue in bytes (not messages) # Default: 0 (unlimited) — leave at 0 unless you have memory constraints # qmgr_message_active_size_limit = 0 # How many delivery transports qmgr uses simultaneously # Raise if sending through multiple transport maps # Default: 20 qmgr_clog_warn_time = 10s # Minimum time between retries for deferred messages # Default: 1000s (about 16 minutes) — conservative, fine for most cases # Reduce to 300s if you need faster retry on soft bounces minimal_backoff_time = 300s maximal_backoff_time = 4000s # How long to keep trying a deferred message before bouncing # Default: 5d — appropriate for most use cases maximal_queue_lifetime = 5d
The setting that trips up most people: qmgr_message_active_limit. The documentation says this is "the maximum number of messages in the active queue." What it means in practice is: after the active queue hits this limit, qmgr stops pulling from the deferred and incoming queues entirely. Everything stalls. Messages continue being injected, the deferred queue grows, throughput flatlines. If you are seeing this in your logs — message_active limit reached — the immediate fix is to raise this limit. The proper fix is to understand why the active queue is filling: usually because ISP rate limiting is causing a high fraction of active messages to defer simultaneously.
SMTP Client Concurrency: The Number That Controls Everything
The smtp client process is where the actual delivery happens. Its concurrency settings determine how many simultaneous SMTP sessions Postfix maintains, which directly controls throughput ceiling and ISP relationship health simultaneously. Getting this wrong in one direction reduces throughput; getting it wrong in the other direction gets you rate-limited or blocked.
# /etc/postfix/main.cf — smtp client settings # Global maximum concurrent smtp processes # Must also raise in master.cf (default_process_limit) # This is the hard ceiling for all smtp delivery simultaneously smtp_destination_concurrency_limit = 40 # Maximum connections to a single destination domain # This is what ISPs see as "connections from your IP to us" # Gmail tolerates 20-40; Yahoo tolerates 20-30; Microsoft wants 10-15 # Default: 20 — appropriate for most destinations # Override per-destination in transport maps (see below) smtp_destination_concurrency_limit = 20 # Maximum messages per SMTP connection (pipelining) # Higher = more efficient use of connections; some ISPs limit this # Gmail: 100+ fine; Yahoo: 250 fine; Microsoft: 100 smtp_destination_rate_delay = 0 # How long to cache an SMTP connection before closing it # Default: 200s — fine; raise to 300s for high-volume destinations smtp_connection_cache_time_limit = 300s # master.cf — raise the process limit to actually use concurrency # Edit the smtp entry in /etc/postfix/master.cf: # smtp inet n - n - - smtpd # smtp unix - - n - 200 smtp # ^^^ # This is the process limit for smtp # Default 100, raise to match concurrency needs
Here is the interaction that catches everyone: smtp_destination_concurrency_limit in main.cf sets the per-destination limit, but the total number of smtp processes available is set by the process count in master.cf. If you have smtp_destination_concurrency_limit = 40 but only 100 smtp processes in master.cf, you can connect to at most 2 destinations simultaneously at 40 connections each. To send to 10 destinations at 40 connections each, you need 400 smtp processes in master.cf. The math is simple but the interaction is easy to miss because the default main.cf and master.cf settings look like they should work together but don't at scale.
Per-Destination Rate Limits: Staying Out of ISP Jails
The global smtp_destination_concurrency_limit is a blunt instrument. What you actually need is a different concurrency limit for Gmail (which is tolerant of high concurrency), Microsoft (which rate-limits aggressively), Yahoo (somewhere in between), and small ISPs (which may only want 2-3 concurrent connections). Postfix allows this through transport maps combined with per-transport settings.
# Step 1: Create per-destination transport rules
# /etc/postfix/transport
gmail.com smtp-gmail:
googlemail.com smtp-gmail:
google.com smtp-gmail:
hotmail.com smtp-microsoft:
outlook.com smtp-microsoft:
live.com smtp-microsoft:
msn.com smtp-microsoft:
yahoo.com smtp-yahoo:
ymail.com smtp-yahoo:
aol.com smtp-yahoo:
# Everything else uses default smtp:
# Compile the transport map:
postmap /etc/postfix/transport
# Step 2: Reference the transport map
# /etc/postfix/main.cf
transport_maps = hash:/etc/postfix/transport
# Step 3: Define per-transport settings in master.cf
# /etc/postfix/master.cf — add transport-specific smtp entries:
smtp-gmail unix - - n - 40 smtp
-o smtp_destination_concurrency_limit=40
-o smtp_destination_rate_delay=0
-o smtp_extra_recipient_limit=100
smtp-microsoft unix - - n - 12 smtp
-o smtp_destination_concurrency_limit=12
-o smtp_destination_rate_delay=1s
# 1s delay between messages = ~60 messages/minute per connection
# Helps avoid Microsoft rate limiting
smtp-yahoo unix - - n - 25 smtp
-o smtp_destination_concurrency_limit=25
-o smtp_destination_rate_delay=0
The smtp_destination_rate_delay setting for Microsoft is worth explaining. Microsoft's rate limiting (the 451 4.7.650 error) is often triggered not by the number of connections but by the rate of message submission within those connections. Adding a 1-second delay between messages in each connection reduces the message rate to approximately 60 messages per minute per connection — which Microsoft's rate limiting handles without issue, even at 12 concurrent connections (720 messages per minute total to Microsoft). This is far more effective than reducing connection count while keeping message rate high, which still triggers the rate limiter.
Transport Maps for ISP-Specific Routing
Transport maps in Postfix do more than rate limiting — they allow routing different email streams through different network source addresses. This is the Postfix equivalent of PowerMTA's VMTA architecture: you can send transactional email from IP 198.51.100.10 and marketing email from 198.51.100.11 by using transport maps to route different sender domains through different smtp client processes bound to different source IPs.
# Route based on sender domain (for reputation isolation):
# /etc/postfix/sender_transport
@transactional.brand.com smtp-transactional:
@marketing.brand.com smtp-marketing:
# In main.cf:
sender_dependent_default_transport_maps = hash:/etc/postfix/sender_transport
# smtp-transactional transport bound to transactional IP:
# /etc/postfix/master.cf
smtp-transactional unix - - n - 100 smtp
-o smtp_bind_address=198.51.100.10
-o smtp_helo_name=mail1.brand.com
# Dedicated IP for transactional — reputation protected from marketing
smtp-marketing unix - - n - 50 smtp
-o smtp_bind_address=198.51.100.11
-o smtp_helo_name=mail2.brand.com
# Separate IP for marketing campaigns
# Verify the routing is working:
# postmap -q "@marketing.brand.com" hash:/etc/postfix/sender_transport
# Should return: smtp-marketing:
One gotcha with sender-based transport maps: sender_dependent_default_transport_maps requires the sender address (MAIL FROM) to match — not the From: header. If your application sends with a different MAIL FROM (envelope sender) than the From: header, the routing will use the MAIL FROM for lookup. Verify with postmap -q before assuming the routing is working correctly.
Connection Caching and Why It Matters More Than You Think
SMTP connection establishment has overhead: DNS lookup, TCP handshake, TLS negotiation, and SMTP greeting exchange. For a server sending 300,000 messages per day to Gmail, if every message requires a new connection that's 300,000 TLS handshakes — each taking 10-50ms. At 50ms per handshake that's 15,000 seconds of connection overhead, completely wasted on ceremony rather than message delivery.
Postfix's connection caching (enabled by the smtp_connection_cache_on_demand parameter and the scache process) reuses established connections across message deliveries to the same destination. The impact on high-volume sending is significant: on a production server sending 400,000 messages per day to Gmail, enabling and properly configuring connection caching typically reduces the TLS handshake overhead by 85-90%, which translates directly to higher effective throughput from the same connection count.
# Enable connection caching in master.cf — scache process:
# The scache entry should already be in master.cf; confirm it's active:
scache unix - - n - 1 scache
-o max_idle=5s
# Configure cache settings in main.cf:
smtp_connection_cache_on_demand = yes
smtp_connection_cache_destinations =
gmail.com googlemail.com
yahoo.com ymail.com aol.com
hotmail.com outlook.com live.com
# Only cache connections to ISPs where we send significant volume
smtp_connection_cache_time_limit = 300s
# Keep cached connections for 5 minutes
# Verify caching is working:
# Look for 'save connection' in mail.log for cached connection reuse:
grep "save connection" /var/log/mail.log | tail -20
# Should see high frequency for major ISPs if caching is working
Queue Directory Structure and Filesystem Performance
This is the optimisation that makes the biggest difference and that almost nobody implements until they're troubleshooting a performance crisis. Postfix's queue directories — incoming, active, deferred, bounce — generate enormous numbers of file operations when you're sending at scale. Small files being created, hardlinked, and deleted thousands of times per minute stress traditional filesystem structures badly.
# Optimal queue filesystem setup: # 1. Separate partition/volume for /var/spool/postfix # Use XFS — better than ext4 for many-small-files workloads # Size: estimate 1-2KB per queued message * your peak queue depth # For 200k peak queue: ~400MB; add 10x safety margin = 4GB # 2. Mount options for XFS queue partition: # /etc/fstab entry: # /dev/sdb1 /var/spool/postfix xfs defaults,noatime,nodiratime 0 0 # noatime: don't update access time on reads — reduces write I/O by ~30% # nodiratime: same for directories # 3. Postfix queue directory hashing: # By default, Postfix uses 1-level directory hashing for the queue # For >100k daily messages, use hash_queue_depth = 2 # In main.cf: hash_queue_depth = 2 # This distributes queue files across 256 subdirectories instead of 16 # Dramatically reduces per-directory file count at high queue depths # WARNING: requires running 'postfix stop; postfix start' after change # Postfix will reorganize the queue on startup — takes a few minutes # Monitor filesystem performance during sending: iostat -x 5 | grep -E "sdb|Device" # Target: %util below 60%, await below 5ms # If %util is above 80%, you need either faster storage or lower concurrency
The hash_queue_depth = 2 change is particularly impactful for servers seeing high queue depths. At depth 1 (the default), Postfix creates 16 subdirectories in each queue directory, and all files for destinations starting with the same character go into the same subdirectory. With a 50,000-message active queue, many of those subdirectories contain 3,000+ files — and directory operations on directories with thousands of entries are slow on any filesystem. At depth 2, Postfix creates 256 subdirectories (16×16), distributing the files more evenly. The per-directory file count drops to around 200 at the same queue depth, and directory operations become fast again.
Monitoring What Is Actually Happening
Postfix performance tuning without monitoring is flying blind. These are the metrics that tell you what is actually happening, not what you think should be happening:
# Real-time queue depth by queue type:
for queue in incoming active deferred bounce; do
count=$(find /var/spool/postfix/$queue -type f 2>/dev/null | wc -l)
echo "$queue: $count messages"
done
# Delivery rate (messages per minute) — run for 60 seconds:
start=$(grep "status=sent" /var/log/mail.log | wc -l)
sleep 60
end=$(grep "status=sent" /var/log/mail.log | wc -l)
echo "Delivery rate: $((end-start)) messages/minute"
# Bounce analysis — what are we bouncing on and why:
grep "status=bounced" /var/log/mail.log | grep -oP "dsn=\K[^,]+" | sort | uniq -c | sort -rn | head -10
# Deferred message analysis — why are things deferring:
grep "status=deferred" /var/log/mail.log | grep -oP "Connection refused|Connection timed out|Message size|relay=\K[^,]+" | sort | uniq -c | sort -rn | head -10
# Per-ISP throughput breakdown:
grep "status=sent" /var/log/mail.log | grep -oP "relay=[a-zA-Z0-9.-]+" | sed 's/relay=//' | awk -F'.' 'NF>=2{print $(NF-1)"."$NF}' | sort | uniq -c | sort -rn | head -20
# Active smtp processes (real concurrency):
ps aux | grep "smtp -t unix" | grep -v grep | wc -l
The deferred message analysis script is the most useful diagnostic tool here. Run it every morning for the first two weeks after any tuning change. The pattern it reveals: if Microsoft appears in the top 3 deferred destinations with connection refused or rate limited reasons, the Microsoft concurrency limit needs reduction. If Yahoo appears with "try again later" messages, the Yahoo concurrency may be too high. If random small ISPs appear with "connection timed out," those ISPs may need to be handled by a separate low-concurrency transport to avoid blocking the main delivery pipeline.
Performance tuning Postfix for high volume is an iterative process. Start with the queue manager and process limit changes — they are risk-free and provide immediate throughput improvement. Add per-destination transport maps and rate delays — these improve ISP relationships and reduce deferred queue buildup. Optimise the filesystem separately — it requires more planning (separate partition) but pays dividends at high queue depths that the other changes create capacity for. Monitor throughout, because the interaction effects between settings are real and the only way to understand them for your specific traffic mix is to watch what the server actually does with the configuration you give it.