Capacity and Sizing
What one machine runs, the limits the shipped defaults impose, the one component that cannot scale out yet, and which number to raise first.
What you are actually running
A default deployment is six long-running containers on one host: PostgreSQL, Redis, MinIO, the Go API, the Go SMTP daemon and the Next.js frontend. A seventh, the migration runner, executes once and exits. None of them is memory-hungry on its own, and the database is the only one whose appetite grows with your data rather than with your traffic.
There is no published benchmark for this project, and this page will not invent one. Throughput depends on your mail volume, your attachment sizes and your disk more than on anything in the code. What can be stated precisely is what the shipped configuration allows before it starts refusing work — which is usually the number you actually want when sizing a box.
Every value below is a default from config.example.yaml. All of them are
configurable; see Configuration for where
each one lives and which can be changed without a restart.
The limits the defaults impose
| Setting | Default | What it bounds |
|---|---|---|
database.max_open_conns | 25 | Postgres connections held by one API process |
database.max_idle_conns | 5 | Connections kept open between requests |
smtp.workers | 4 | Messages parsed concurrently |
smtp.queue_size | 1000 | Messages accepted and waiting on those workers |
smtp.max_size | 25 MiB | Largest message the daemon will accept |
defaults.max_attachment_size_mb | 25 | Largest attachment stored |
defaults.max_inboxes_per_domain | 100 | Live inboxes on one domain |
defaults.max_domains | 10 | Domains on the instance |
defaults.max_teams | 50 | Teams in the organization |
defaults.default_inbox_ttl | 10m | TTL an inbox gets when none is chosen |
defaults.max_inbox_ttl | 24h | Longest an inbox can live |
rate_limit.authenticated | 300/min | Requests per user |
rate_limit.unauthenticated | 60/min | Requests per IP |
The two worth thinking about before you deploy are smtp.queue_size and
smtp.workers. Together they are your burst tolerance: a thousand messages can
be accepted while four are being parsed, and a sender arriving at a full queue
is refused rather than kept waiting. If your traffic is bursty — a test suite
that sends a few hundred messages in a few seconds — raise the queue before you
raise anything else.
Disk is the thing that grows
Mail and attachments are the only data that accumulates without bound during normal operation, and attachments are much the larger of the two. At the default 25 MiB ceiling, a hundred inboxes each receiving a handful of attachment-carrying messages is measured in gigabytes rather than megabytes.
Inboxes expire and the cleanup worker deletes them along with their mail and attachments, so steady-state usage is governed by your TTLs rather than by how long the instance has been up. An instance where most inboxes use the 24-hour maximum holds roughly a day of traffic; one left on the ten-minute default holds almost nothing. The audit log and analytics rollups do accumulate indefinitely and are small.
The ceiling that is not a number
The API binary is the constraint, and not for any reason you can configure away. Every API process starts all seven background workers and there is no leader election, so a second replica does not share the load — it duplicates it, producing doubled webhook retries and doubled analytics rollups.
Until leader election exists, the API scales up rather than out. Give it a bigger machine, not a second one.
The SMTP daemon has no such restriction. It holds no timers and owns no periodic work — it accepts mail, parses it, stores it and publishes to Redis — so it can be replicated freely behind whatever is balancing port 25 or 2525. If inbound mail is your bottleneck, that is the part to add instances of.
Which number to raise first
Raise these in roughly this order, because each one stops mattering until the one before it is comfortable:
smtp.queue_size, if senders are being refused during bursts.smtp.workers, if the queue drains too slowly — this is CPU-bound parsing, so it wants cores.database.max_open_conns, if requests are waiting on the pool. Raise PostgreSQL's ownmax_connectionsto match; the default 100 leaves room for one API process at 25 and not many more.- Disk, before any of the above, if attachments are the story.
What to watch
The Prometheus metrics and the /healthz and /readyz endpoints described in
Monitoring are how you find out which of
these you have hit, rather than guessing. Queue depth and database pool
saturation are the two that tell you something is about to be refused, and both
move before anything visibly breaks.
If something is already failing rather than merely tight, the symptoms and their causes are collected in Troubleshooting, and the hardening checklist in Production Deployment covers the settings that matter before this instance faces the internet.