Gino Eising
Gino Eising
Nerd by Nature
Jan 30, 2026 5 min read

One Postfix to route them all: self-hosted multi-tenant mail delivery

thumbnail for this post

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.