Scaling to Millions¶
The default Mailyte setup handles tens of thousands of emails per day comfortably. This guide separates what you can do today with configuration from what would be architecture work — several patterns often suggested for mail-at-scale (multiple MTA instances, read replicas, sharding) are not built into Mailyte as of 2026-08-30, and pretending otherwise wastes your incident hours.
When to Start Scaling¶
| Daily Volume | Approach |
|---|---|
| < 50,000 | Default single-server setup |
| 50,000 - 500,000 | Tune Postfix/MySQL/Redis, scale worker replicas, watch the queue |
| 500,000+ | Plan architecture work (below) — and talk to your capacity numbers first |
Queue depth is your early-warning signal. Watch it via the API — GET /api/v1/queue/queue/status and GET /api/v1/queue/mail-queue/deferred — or directly with docker exec postfix postqueue -p. A queue that keeps growing means you need more delivery capacity or receivers are throttling you.
Tuning the Single Server¶
Postfix Tuning¶
Postfix config is baked into the image
main.cf lives at mailer/postfix/config/main.cf and is copied into the image at build time — there is no mounted custom/main.cf, and postconf -e inside the container vanishes on the next recreate. To change Postfix settings: edit mailer/postfix/config/main.cf, rebuild (docker compose build postfix), and recreate the container.
Settings worth reviewing for throughput (standard Postfix knobs — set them in mailer/postfix/config/main.cf):
# Concurrent deliveries per destination
default_destination_concurrency_limit = 20
smtp_destination_concurrency_limit = 20
# Process limits
default_process_limit = 200
# Retry pacing
minimal_backoff_time = 60s
maximal_backoff_time = 600s
# Connection reuse to big receivers
smtp_connection_cache_on_demand = yes
smtp_connection_reuse_time_limit = 300s
Per-destination limits
Gmail and Microsoft rate-limit incoming SMTP connections. Don't set smtp_destination_concurrency_limit above 20 for external delivery or you'll get temporary blocks.
Also note the spool: /var/spool/postfix is a named volume (postfix_spool) shared with queue_manager — accepted-but-undelivered mail survives container recreation. Never delete that volume while mail is queued.
MySQL Tuning¶
The base compose file runs mysql:8.0.35 with no tuning flags. Add them via docker-compose.override.yml (dev) or a prod override so they survive updates:
services:
mysql:
command: >
--innodb-buffer-pool-size=2G
--innodb-redo-log-capacity=1G
--innodb-flush-log-at-trx-commit=2
--max-connections=500
--innodb-io-capacity=2000
Key settings:
innodb-buffer-pool-size— set to 50-70% of the RAM you can dedicate to MySQL (production caps the container at 2G by default — raise the compose memory limit together with the pool size)innodb-flush-log-at-trx-commit=2— trades a second of durability for a large write-throughput winmax-connections— enough for ~20 worker services plus Postfix/Dovecot lookups
Redis Tuning¶
services:
redis:
command: >
redis-server
--appendonly yes
--maxmemory 1gb
--maxmemory-policy allkeys-lru
Worker Replicas¶
Production already runs the hot stateless services at two replicas (docker-compose.prod.yml: api, webhooks, tracking have deploy: replicas: 2, reachable through Traefik / the compose network's DNS round-robin). Raising a count is a one-line change in the prod override:
This works only for services without a fixed host-port binding — which is exactly why the prod file removes those bindings for the replicated services.
Worker Concurrency Env Vars¶
The knobs that exist in code today:
| Variable | Service | Default |
|---|---|---|
WEBHOOK_WORKERS | webhooks | 5 |
STORAGE_API_WORKERS | storage_usage | 4 |
RAG_WORKERS | rag | 1 |
INDEXING_MAX_WORKERS | rag indexing | 4 |
Set them in the service's environment: block. (There are no TRACKING_WORKERS / ANALYTICS_WORKERS variables — scaling those services means more replicas, not a thread knob.)
Rate Limits as a Throughput Tool¶
Per-domain and per-mailbox sending limits (/api/v1/rate-limiter/rate-limits/...) and SMTP-credential hourly_limit/daily_limit let you shape which tenants consume delivery capacity — often the cheapest fix when one sender's burst is starving everyone else's queue.
What is NOT Built In (Architecture Work)¶
Be clear-eyed about these — none of them is a configuration option today:
- Multiple Postfix instances / inbound-outbound split. There is one
postfixservice. The image has noINSTANCE_TYPEconcept, and queue_manager does not load-balance across MTA instances. Running a second Postfix means designing spool ownership, SASL, and rspamd wiring yourself. - MySQL read replicas. Every service reads and writes a single
DB_HOST. There is noDB_READ_HOST/DB_WRITE_HOSTsplit in any worker's code. - Database sharding by organization. All tenants share one schema; nothing routes by org to different databases.
- A separate queue database. The Postfix spool plus the shared MySQL/Redis are the only queue stores.
If your volume genuinely demands these, treat them as engineering projects with the usual design/review cycle — and measure first: most deployments that think they need sharding actually need the Phase-1 tuning above plus list hygiene.
Vertical Scaling Checklist¶
Before any architecture work:
- NVMe storage — Maildir and InnoDB are IOPS-hungry; disk is the usual first wall
- RAM for the buffer pool — raise MySQL's compose memory limit and
innodb-buffer-pool-sizetogether - CPU for Rspamd — spam scanning is the per-message CPU cost on the inbound path
- Watch the right metrics — queue depth, deferral rate, MySQL
Threads_connected, disk latency
Monitoring at Scale¶
At higher volume, monitoring is not optional. The built-in stack covers it:
- Prometheus + Grafana run in the base compose file — see Monitoring Setup
- Alerting on queue depth (
MailQueueBackup/MailQueueCritical), bounce rate, disk, and DB connection pressure ships inmonitoring/prometheus/rules/mail_alerts.yml - Queue visibility via
GET /api/v1/queue/queue/status,GET /api/v1/queue/queue/domain/{domain}, andPOST /api/v1/queue/mail-queue/flushfor retrying deferred mail
Key Takeaways¶
- Tune before you scale — most setups never need more than the single-server tuning
- Queue depth is your early warning — if it keeps growing, find out whether it's your capacity or receiver throttling
- Replicas are the supported horizontal axis — stateless workers scale with
deploy.replicas; the MTA and databases do not - Shape traffic with rate limits — per-tenant limits protect the queue from any one sender
- Monitor everything — you can't optimize what you can't measure