# Scaling AcelleMail for 100K+ Emails Per Day

> At 100K sends/day you cross the threshold where the default AcelleMail config starts to drag — queue depths climb, MySQL slow queries appear, occasional PHP-FPM 502s. This guide walks the bottleneck math, the configs to tune (queue pool size, MySQL InnoDB, PHP-FPM, nginx, Redis), when to split components onto separate hosts, and the cost/benefit at each step.

- Canonical URL: https://acellesend.com/kb/articles/scaling-for-100k-emails-per-day
- Author: Daniel Reyes, Technical Editor
- Category: Server Management — Cron jobs, queues, Supervisor, Redis, Nginx, performance tuning
- Content type: tutorial · Difficulty: advanced
- Tags: Acellemail
- Published: 2026-05-18 · Last updated: 2026-08-15
- Reading time: 12 min
- Publisher: AcelleMail Knowledge Base, https://acellesend.com/kb

---

## What this is for

At 100K sends/day, the default AcelleMail config starts to drag. You'll notice:

- Queue depths that don't drain (1k+ jobs queued even when "nothing is happening")
- MySQL slow query log filling up
- Occasional 502 Bad Gateway when admin pages load slowly
- Workers getting OOM-killed during list imports
- Customers complaining about "sending taking forever"

None of these are individually catastrophic, but each one compounds. **100K/day is the threshold where one well-tuned box starts to struggle — and where 30 minutes of targeted tuning gets you another 5× headroom.** This guide walks the math, the configs, and the architectural decisions, in the order you should hit them.

## Step 0 — Do the bottleneck math

100K sends/day = ~70 sends/minute *if perfectly even*. In reality, you get bursts — a 9 AM Tuesday campaign blast might be 100k sends in the first 20 minutes = **5,000 sends/minute peak**.

Each "send" is one job pulled from Redis → one HTTP call to the sending provider (SES/Mailgun/SendGrid) → one row inserted into `email_log`. A healthy worker handles ~20-50 sends/minute (limited by sending-provider API latency, ~50-200ms per call).

So to handle 5,000 sends/minute peak, you need:

```
5,000 sends/min ÷ 30 sends/min/worker = ~170 worker-slots needed at peak
```

That's significantly above the default 15-worker pool. **Either accept that bursts will queue up and drain over 5-10 minutes (often fine), or scale the worker pool**.

The right answer depends on your customer expectations. For most use cases, a 10-minute queue drain on a 100k-send burst is acceptable — and the default 15 + auto-scale-to-20 via `queue:adjust` handles it. For latency-sensitive transactional sends (welcome emails, password resets), bump the pool higher.

## Step 1 — Queue worker pool sizing

Per the [supervisor setup guide](https://acellesend.com/kb/articles/setting-up-queue-workers-and-cron-jobs#step-3--sizing-the-pools-for-your-workload), the two-tier pool default is 2 master + 15 worker = 17 total at Medium tier (4 vCPU / 8 GB).

For 100k+/day, the right starting point is **Large tier (8 vCPU / 16 GB) with 4 + 30 workers**:

```bash
# /etc/supervisor/conf.d/acellemail-master.conf
numprocs=4   # was 2

# /etc/supervisor/conf.d/acellemail-worker.conf
numprocs=30  # was 15
```

```bash
sudo supervisorctl reread && sudo supervisorctl update
sudo supervisorctl status
# Should now show 4 + 30 = 34 RUNNING processes
```

**Memory math:** each worker peaks ~256-512 MB. 30 × 384 MB ≈ 11.5 GB just for workers — fits comfortably in 16 GB after MySQL (3 GB), Redis (1 GB), PHP-FPM web (1 GB), nginx (negligible), OS (1 GB) = ~17 GB worst case. If you're tight, scale to 24 GB.

Verify queue drain rate is acceptable:

```bash
# Trigger a 10k send-test campaign, then watch the drain:
watch -n 2 'redis-cli llen queues:batch; redis-cli llen queues:high'
```

You want both to be back to near-zero within 5 minutes.

## Step 2 — MySQL tuning

Edit `/etc/mysql/mysql.conf.d/mysqld.cnf`:

```ini
[mysqld]
# Cache hot indexes + data in RAM. Set to 50-75% of system RAM if MySQL is alone.
# For a co-located AcelleMail + MySQL on 16 GB, 4-6 GB is a good baseline.
innodb_buffer_pool_size = 4G

# Larger redo logs = fewer flushes under heavy write load (campaign blast)
innodb_log_file_size = 512M

# Allow more concurrent connections (30 workers + web + admin + cron)
max_connections = 300

# Disable query cache (deprecated in MySQL 8; removed in MySQL 9; can hurt perf at scale)
query_cache_type = 0
query_cache_size = 0

# Trade durability for throughput — durable enough for queues; tolerates ~1s of lost commits on crash
innodb_flush_log_at_trx_commit = 2

# Optimize for SSD (default is OK for most installs; tune if you see I/O bottleneck in slow log)
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000

# Per-table tablespaces (newer default, but verify)
innodb_file_per_table = 1
```

Restart MySQL:

```bash
sudo systemctl restart mysql
```

Verify the buffer pool is actually using the new size:

```bash
sudo mysql -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"
# Expect: 4294967296 (4 GB in bytes)
```

> **`innodb_flush_log_at_trx_commit = 2` trade-off:** the default `1` flushes the redo log to disk on every commit (full durability). Setting `2` flushes only once per second (~1s of writes lost on power failure). For email-sending workloads this is acceptable — at-most-1-second of `email_log` writes might be lost, but the sending itself completed (recipient got the email). The throughput gain is significant (often 3-5×). If you need full durability, leave it at 1 and accept slower writes.

### Watch the slow query log

```bash
# Enable in mysqld.cnf:
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1.0
log_queries_not_using_indexes = 0

sudo systemctl restart mysql

# Watch:
sudo tail -f /var/log/mysql/slow.log
```

Common AcelleMail slow queries and their fixes:

- `SELECT ... FROM subscribers WHERE list_id = X AND status = 'subscribed'` slow → ensure compound index on `(list_id, status)` exists (run `SHOW INDEX FROM subscribers` to verify)
- `SELECT ... FROM email_log WHERE campaign_id = X` slow → add index on `campaign_id`
- `SELECT ... FROM jobs ORDER BY id LIMIT 1` slow → switch queue driver to Redis (it eliminates `jobs` table polling)

## Step 3 — PHP-FPM tuning

Edit `/etc/php/8.3/fpm/pool.d/www.conf`:

```ini
pm = dynamic
pm.max_children = 50      # default 5
pm.start_servers = 10     # default 2
pm.min_spare_servers = 5  # default 1
pm.max_spare_servers = 20 # default 3
pm.max_requests = 500     # recycle workers periodically — prevents memory creep

; Important: limit how long a single FPM worker can hang before being killed
; — protects against a slow outbound HTTP call blocking the pool
request_terminate_timeout = 60s
```

Restart PHP-FPM:

```bash
sudo systemctl restart php8.3-fpm
```

**Memory math:** 50 workers × ~80 MB peak = ~4 GB worst case. Confirms our 16 GB sizing.

> **`request_terminate_timeout = 60s` is a hard lesson from production.** Without it, a single web request that hangs (slow remote API call, DNS timeout, blocked syscall) holds a worker indefinitely. 5 such requests = entire FPM pool blocked = site appears down. 60-second timeout kills the hung worker; supervisor restarts it. Set this on every production install.

## Step 4 — Nginx tuning

Edit `/etc/nginx/nginx.conf`:

```nginx
worker_processes auto;                # one per CPU core

events {
    worker_connections 2048;          # default 768 — too low for high-traffic sites
    use epoll;                        # default on Linux; explicit for clarity
    multi_accept on;
}

http {
    # gzip on responses
    gzip on;
    gzip_min_length 1024;
    gzip_types text/plain text/css text/javascript application/javascript application/json application/xml;
    gzip_comp_level 5;

    # Reasonable client + buffer settings for the admin UI
    client_max_body_size 300M;
    client_body_buffer_size 128k;
    client_header_buffer_size 8k;
    large_client_header_buffers 4 16k;

    # Keepalives reduce TCP overhead from the admin's repeated AJAX polls
    keepalive_timeout 65;
    keepalive_requests 100;
}
```

Reload nginx:

```bash
sudo nginx -t && sudo systemctl reload nginx
```

## Step 5 — Redis on the same box (still — for now)

At 100k/day, **Redis stays on the same box**. Splitting it off is premature optimization until you hit ~1M/day or you need HA. Confirm Redis is the queue driver per [Redis for Queue Processing](https://acellesend.com/kb/articles/redis-for-queue-processing):

```bash
grep QUEUE_CONNECTION /var/www/acellemail/.env
# Expect: QUEUE_CONNECTION=redis

# Confirm Redis is sized for the workload:
redis-cli config get maxmemory
# Should be at least 2GB for 100k/day; 4GB for headroom

redis-cli config get maxmemory-policy
# MUST be: noeviction (queues require this — see Redis article)
```

## Step 6 — When to split DB to its own host

Co-located DB starts to bottleneck around **5-10M sends/month** (~150k-300k/day average). Symptoms:

- `iostat -x 1` shows sustained 80%+ disk %util during campaigns
- Top reports `mysqld` consistently in the top 2 CPU consumers
- `SHOW PROCESSLIST` shows 100+ active connections with worker queries waiting

At that point, move MySQL to its own host:

- **DigitalOcean Managed DB** — easy, ~$15/mo for the smallest tier. Adds 1-2ms latency per query (private network). Worth it for the operational simplicity.
- **AWS RDS** — same model. `db.t3.medium` is a reasonable starting point at ~$55/mo on-demand or ~$35 with 1-year Reserved.
- **Self-managed on a separate droplet** — cheapest, more ops work. Pick if you already have MySQL expertise in-house.

In AcelleMail's `.env`:

```env
DB_HOST=10.0.0.5         # private IP of the DB box (NEVER use public IP)
DB_DATABASE=acellemail
DB_USERNAME=acellemail
DB_PASSWORD=...

# Force private network if your cloud provides one
```

Run `php artisan config:clear` after.

## Step 7 — When to add a second app server

At ~10-20M sends/month (300k-600k/day), one app server starts to struggle even after all the tuning above. The right next step is **horizontal scaling**:

```
[Load Balancer]
       ↓
  ┌────┴────┐
[App1]   [App2]   ← stateless — run web UI + workers
       ↓
[MySQL on managed]
[Redis on its own box, replicated]
[Object storage for shared storage/ — S3 / DO Spaces / B2]
```

Key changes from single-server:

1. **Move `storage/` to shared object storage** (S3 / DO Spaces). Both app servers must see the same files.
2. **Move sessions to Redis** (`SESSION_DRIVER=redis` in `.env`). Otherwise customers get logged out when the LB routes them to a different app server.
3. **Add a sticky-session policy on the LB** for the WYSIWYG editor (it does background autosaves keyed to session).
4. **Run cron + supervisor on only one node** (or use leader-election like `php artisan schedule:work` with a lock). Running cron on both = double-firing every scheduled task.
5. **Add a Redis password** if Redis is now on a network reachable from multiple boxes.

This is the boundary where you should consider Docker / Kubernetes — orchestrating multiple stateless app instances by hand gets tedious fast. See the [Docker deployment guide](https://acellesend.com/kb/articles/docker-deployment-guide-for-acellemail).

## Step 8 — Sending-provider rate limits

You can scale AcelleMail all you want, but **your sending provider's rate limit caps your throughput**. Watch these:

| Provider | Default limit | How to raise |
|---|---|---|
| Amazon SES | 14 sends/sec sandbox; production starts at 50/s and ramps with reputation | Open AWS Support ticket; usual cadence is +50/s/week as reputation builds |
| SendGrid | 100 sends/sec on Pro plan | Upgrade plan |
| Mailgun | 100 sends/sec on starter; higher tiers go to 1000/s | Upgrade plan |
| Postmark | 10 sends/sec default; up to 100/s on request | Email support |

At 100k/day = ~1.16 sends/second average, you're well within any provider's limits. At 1M/day = 11.5/sec average, you'll bump SES's sandbox cap during burst peaks. Plan ahead — see [SES Sending Limits Cookbook](https://acellesend.com/kb/articles/ses-sending-limits-cookbook).

## Quick-reference tuning checklist

After all the above, your config delta from default looks like:

| Component | Setting | Default | Tuned (100k/day) |
|---|---|---|---|
| Supervisor master | numprocs | 2 | 4 |
| Supervisor worker | numprocs | 15 | 30 |
| MySQL | innodb_buffer_pool_size | 128M | 4G |
| MySQL | max_connections | 151 | 300 |
| MySQL | innodb_log_file_size | 50M | 512M |
| MySQL | innodb_flush_log_at_trx_commit | 1 | 2 |
| PHP-FPM | pm.max_children | 5 | 50 |
| PHP-FPM | request_terminate_timeout | (unset) | 60s |
| nginx | worker_connections | 768 | 2048 |
| Redis | maxmemory | (unset) | 2-4G |
| Redis | maxmemory-policy | noeviction (already) | noeviction (verify) |
| `.env` | QUEUE_CONNECTION | sync | redis |
| `.env` | CACHE_DRIVER | file | redis |
| `.env` | SESSION_DRIVER | file | redis |

## Common issues

| Symptom | Cause | Fix |
|---|---|---|
| Queue depth grows during campaign blast, doesn't drain | Worker pool too small | Step 1 — bump worker numprocs |
| MySQL CPU pegs at 100% during sends | Buffer pool too small; reading from disk constantly | Step 2 — increase `innodb_buffer_pool_size` |
| Random 502 Bad Gateway on admin pages | PHP-FPM pool exhausted | Step 3 — bump `pm.max_children`; add `request_terminate_timeout` |
| OOM kills during list import | `memory_limit` too low for big CSV | `php.ini` `memory_limit = 1G` (for fpm + cli) |
| Workers slow even when queue is empty | Misconfigured `--sleep` (workers spinning) | Verify supervisor configs include `--sleep=3` |
| Mail-merge campaigns take minutes per recipient | Heavy template + many merge tags | Profile with `xhprof` / `blackfire`; cache merged content where possible |
| Sending IP getting rate-limited by Gmail/Outlook | Throughput exceeds receiver's per-IP cap | Add IP rotation; see [Multi-Server Rotation Pattern](https://acellesend.com/kb/articles/multi-server-rotation-pattern) |
| Free disk space dropping fast | `email_log` table growing without bound | `system:cleanup` daily task should prune; verify cron is firing |
| `php artisan` operations slow | Cached config / view files stale | `php artisan optimize:clear` after major config changes |

## When to stop tuning and just scale up

A single tuned 8 vCPU / 16 GB box handles ~1M-2M sends/month comfortably. Above that, **add hardware before tuning further**:

- 2M+/mo → 16 GB → 24-32 GB RAM; consider RDS for DB
- 5M+/mo → Multi-app-server architecture (Step 7)
- 20M+/mo → Multi-region, dedicated sending IPs, custom partition for `email_log`, full DBA review

The cost of an additional $30-100/mo of hardware is much less than the cost of 4 hours of your time spent micro-tuning.

## FAQ

**Should I tune PHP-FPM `pm = static` or `pm = ondemand` instead of `dynamic`?** For a busy-most-of-the-time AcelleMail (campaigns going out throughout the day), `dynamic` is the best balance. `static` wastes RAM on idle nights. `ondemand` adds latency on the first request after idle. `dynamic` is the right default for 100k/day.

**Why not Cloudflare in front?** Cloudflare can absorb traffic spikes to public AcelleMail pages (tracking pixel endpoints, unsubscribe links). It can't help with worker throughput (those are SSE/API calls from the server). Worth adding for the tracking-pixel layer; not a substitute for the tuning above.

**Should I tune the MySQL `tmp_table_size` / `max_heap_table_size`?** Defaults are usually fine. If `SHOW STATUS LIKE 'Created_tmp_disk_tables'` shows a high number relative to `Created_tmp_tables`, bump both to 256M.

**What about HTTP/2 / HTTP/3?** nginx 1.24+ supports both. Modest improvement on admin UI responsiveness; no impact on send throughput. Worth enabling: `listen 443 ssl http2;` in the vhost.

**Does Acelle support read replicas?** Yes — Laravel's `read` / `write` database config in `config/database.php` supports separate read endpoints. Useful at 5M+/mo when read-heavy operations (campaign reports, subscriber search) start to slow down. Pre-configured Laravel pattern; AcelleMail honours it.

**Can I cap a single campaign's send rate?** Yes — set a per-sending-server throttle. See [Sending Throttling Strategies](https://acellesend.com/kb/articles/sending-throttling-strategies) for the full configuration.

## Related articles

- [Setting Up Queue Workers and Cron Jobs](https://acellesend.com/kb/articles/setting-up-queue-workers-and-cron-jobs) — the supervisor baseline (this article extends it)
- [Redis for Queue Processing](https://acellesend.com/kb/articles/redis-for-queue-processing) — Redis is a prerequisite for any meaningful scaling
- [Automated Database Backups](https://acellesend.com/kb/articles/automated-database-backups) — protect your scaled-up DB
- [Server Requirements and Hosting Options](https://acellesend.com/kb/articles/server-requirements-and-hosting-options) — pick the right tier for your volume
- [Install AcelleMail on AWS EC2](https://acellesend.com/kb/articles/install-acellemail-on-aws-ec2) — for the multi-AZ / multi-server architecture
- [Docker Deployment Guide for AcelleMail](https://acellesend.com/kb/articles/docker-deployment-guide-for-acellemail) — for the multi-app-server pattern
- [Configuring Amazon SES with AcelleMail](https://acellesend.com/kb/articles/configuring-amazon-ses-with-acellemail) — pair with SES at scale
- [SES Sending Limits Cookbook](https://acellesend.com/kb/articles/ses-sending-limits-cookbook) — raise the sending-provider cap
- [Multi-Server Rotation Pattern](https://acellesend.com/kb/articles/multi-server-rotation-pattern) — multi-IP setup beyond a single SES account
- [Sending Throttling Strategies](https://acellesend.com/kb/articles/sending-throttling-strategies) — pace sends to avoid receiver throttling
- [Post-Install Hardening Checklist](https://acellesend.com/kb/articles/post-install-hardening-checklist) — apply hardening after every scale-up

---

## Related articles

- [Redis for Queue Processing](https://acellesend.com/kb/articles/redis-for-queue-processing): AcelleMail's default queue driver is the database — fine for hobby installs, terrible for production. Switching to Redis cuts queue-dispatch latency by 10×, eliminates MySQL's `jobs`-table contention under load, and unlocks better worker auto-scaling. This guide walks the install, the Acelle config, the tuning knobs that matter, and the monitoring hooks.
- [Setting Up Queue Workers and Cron Jobs](https://acellesend.com/kb/articles/setting-up-queue-workers-and-cron-jobs): AcelleMail uses Laravel queues + a system cron to do everything beyond rendering web pages — sending campaigns, processing automations, handling bounces, refreshing stats, dispatching scheduled sends. This guide walks the production-correct two-tier supervisor split (master + worker), the cron schedule, and the operational discipline that keeps the queue healthy under load.
- [Automated Database Backups for AcelleMail](https://acellesend.com/kb/articles/automated-database-backups): The first backup is the cheapest you'll ever take. By the time you actually need to restore, the cost of NOT having a backup is hours of downtime, lost subscribers, and a furious customer-success conversation. This guide walks the production-correct backup setup: daily mysqldump with rotation, offsite copies, file-tree snapshots, restore drill workflow, and the monitoring that tells you when it stops working.
- [BIMI with VMC — Cost, Process, and Whether It's Worth It](https://acellesend.com/kb/articles/bimi-with-vmc-cost-walkthrough): VMC (Verified Mark Certificate) costs $1,500/yr per domain and takes 4-8 weeks to obtain. This guide walks the cost math, the trademark requirements, the alternative paths, and a decision framework for whether VMC is right for your sending program.
- [Building Your Email Marketing SaaS](https://acellesend.com/kb/articles/building-your-email-marketing-saas): The technology side of an AcelleMail SaaS is solved. The hard part is the business: who you sell to, how you price, how you reach them, and how you keep them. This guide is the GTM + pricing playbook — opinionated, market-tested patterns for launching an email-marketing SaaS on the AcelleMail stack.
