One Postfix to route them all: self-hosted multi-tenant mail delivery
January 2026 — on getting email routing right when you have more than one domain
Most self-hosted email guides start with a single domain. Install Mailu or Mailcow, point MX at your server, done. That works well until you have multiple domains that need to receive mail, or you’re providing mail routing for several distinct tenants, or you want a single SMTP gateway that routes inbound mail to different downstream servers based on recipient domain.
That’s the problem this solves: a central Postfix relay that accepts mail for multiple domains and routes each domain’s mail to the correct downstream server — with automatic queuing if a downstream is temporarily unavailable.
The architecture
Internet → MX record → Central Postfix relay
│
┌─────────┼─────────┐
↓ ↓ ↓
customer-a customer-b customer-c
mailserver mailserver mailserver
One public IP and one MX record. Three (or more) independent downstream mail servers, each handling their own domain. The relay’s job is routing and queuing — not storing or serving mail.
The key Postfix settings
Two settings do the actual work.
relay_domains — tells Postfix it is permitted to accept mail for these domains. Without this, Postfix returns “Relay access denied” for any domain that isn’t its own hostname.
relay_domains = customer-a.test, customer-b.test, customer-c.test
transport_maps — tells Postfix where to deliver mail for each domain. This is a lookup table mapping domains to protocol:[hostname]:port entries.
transport_maps = lmdb:/etc/postfix/transport
# /etc/postfix/transport
customer-a.test smtp:[mailpit-customer-a]:1025
customer-b.test smtp:[mailpit-customer-b]:1025
customer-c.test smtp:[mailpit-customer-c]:1025
The brackets [] around the hostname are critical. Without them, Postfix tries to look up an MX record for the hostname. With brackets, it connects directly to that A record (or container name in Docker). The colon-port suffix overrides the default SMTP port (25).
Queue tuning for fast feedback
Default Postfix queue settings are designed for production internet mail — long retry windows, slow backoff. For a development setup where you’re testing failure scenarios, you want faster feedback:
maximal_queue_lifetime = 1h # Give up after 1 hour instead of 5 days
maximal_backoff_time = 300s # Wait max 5 minutes between retries
minimal_backoff_time = 30s # First retry after 30 seconds
queue_run_delay = 30s # Check queue every 30 seconds
smtp_dns_support_level = disabled # Don't try DNS lookups for internal names
With these settings, if a downstream server goes down, Postfix queues the mail and retries every 30 seconds. When the server comes back, queued mail delivers within 30 seconds. In production, you’d extend the retry window and backoff times significantly.
Testing with Mailpit
For the downstream servers, I use Mailpit — a lightweight SMTP sink with a web UI. It accepts any mail delivered to it, stores it in memory, and lets you verify exactly what arrived at which “server.”
# docker-compose.yml
services:
postfix:
build: ./postfix
ports:
- "25:25"
environment:
- RELAY_DOMAINS=customer-a.test,customer-b.test,customer-c.test
mailpit-customer-a:
image: axllent/mailpit
ports:
- "8025:8025" # Web UI for customer-a
mailpit-customer-b:
image: axllent/mailpit
ports:
- "8026:8025" # Web UI for customer-b
mailpit-customer-c:
image: axllent/mailpit
ports:
- "8027:8025" # Web UI for customer-c
tester:
image: alpine/swaks
depends_on: [postfix]
# Used to send test emails via scripts
Swaks (the “Swiss Army Knife of SMTP”) runs in the tester container and sends test emails:
# Send to customer-a domain
swaks --to user@customer-a.test --from sender@test.com \
--server postfix --port 25
# Verify it arrived at customer-a's Mailpit (not customer-b or customer-c)
# http://localhost:8025 → should show the message
# http://localhost:8026 → should be empty
This is how you verify the routing is correct. Mail to customer-a.test should appear only in customer-a’s Mailpit. If it appears elsewhere, your transport map is wrong.
The failure scenario test
The most important thing to verify is queue behaviour:
# 1. Stop customer-b's mailpit
docker compose stop mailpit-customer-b
# 2. Send mail to customer-b
swaks --to user@customer-b.test --server postfix --port 25
# Postfix accepts it (returns 250 OK), queues it internally
# 3. Check the queue
docker exec postfix mailq
# Mail is sitting in the deferred queue, retry in 30s
# 4. Restart customer-b's mailpit
docker compose start mailpit-customer-b
# 5. Within 30 seconds, check customer-b's Mailpit
# http://localhost:8026 → message appears
This confirms the guarantee: Postfix accepts and holds mail even when the downstream is unavailable. No mail is lost during maintenance windows or unexpected downtime.
The lmdb: format
One detail that trips people up: the lmdb: prefix in transport_maps = lmdb:/etc/postfix/transport.
The transport file is a plain text file. Postfix can’t read it directly as a hash table — you need to compile it:
postmap /etc/postfix/transport
This creates transport.lmdb (or .db on older systems). If you edit the transport file and don’t run postmap, Postfix continues using the old compiled version. Always run postmap after editing, then postfix reload.
When would you actually use this?
A few real scenarios:
Multi-tenant SaaS: you provide email-related services for multiple clients. Each client has their own mail server (or you host separate mail servers per client). One public SMTP entry point routes inbound mail correctly.
Domain consolidation: you own multiple domains that need to receive mail. Each domain routes to a different mailbox provider or self-hosted server. One MX record, one relay, clean routing.
Staged migration: you’re migrating from one mail server to another and want to route specific domains to the new server while keeping others on the old one during testing. Change one line in the transport map. No DNS changes needed.
Redundancy: the relay itself can be a simple forwarding layer with no storage. If a downstream server is down, mail queues at the relay. The downstream can be restored without losing mail in transit.
What Postfix does well
Postfix is old, battle-tested, and designed exactly for this use case. Its configuration is verbose but explicit — there’s very little “magic” that isn’t visible in main.cf. The queue management (view queue, force retry, delete specific messages) is mature and well-documented.
For a routing-only relay with no user mailboxes, it’s also lightweight. The container runs with minimal memory. You’re not paying the overhead of a full mail stack for what is essentially a routing service.


