“What hardware do we need for a thousand users?” is the single most common pre-sales question we get, and the honest answer starts with a correction: a thousand users is not a thousand transfers. Most sizing mistakes come from conflating three very different numbers — provisioned accounts, open sessions, and active transfers — and buying hardware for the wrong one.
This post walks through how we size Stylus SFTP Server deployments, using the server's actual internals — the threading model, the bounded queues, the staging directory, the quota engine — rather than hand-wavy per-user multipliers. The numbers below come straight out of the shipping code, so you can verify every one of them against your own installation's conf/ directory.
First, define "concurrent"
Three populations, three orders of magnitude apart in cost:
- Provisioned accounts — rows in
sftp_users(or LDAP entries). Nearly free. Ten thousand accounts cost you kilobytes of database and nothing at runtime until they log in. - Open sessions — authenticated SSH connections, most of them idle. Cheap but not free: each holds a socket, session keys, and channel state. The server's default idle timeout is 600 seconds (
<idle-timeout-seconds>insftp-server.xml), so a client that uploaded one file at 9:00 and walked away still counts against your session population until 9:10. This is why “concurrent sessions” is usually 3–5× your instantaneous transfer count. - Active transfers — sessions with bytes moving right now. This is where the CPU and I/O costs live.
For a “1000 concurrent users” deployment, the realistic peak workload is 1000 open sessions with something like 100–200 transfers actually streaming at any instant. Size for that, not for 1000 simultaneous 10 GB uploads — unless your workload genuinely is 1000 simultaneous uploads, in which case your bottleneck is the network, not the server.
One administrative note first: the concurrent-session ceiling in Stylus SFTP Server is set by your activation key, and the enforcement is graceful — a session over the limit is rejected with the SSH standard SSH2_DISCONNECT_TOO_MANY_CONNECTIONS code, so clients see a clean protocol-level refusal, not a hung socket. Make sure the key you license matches the session population you size for.
CPU: it's crypto, and crypto is cheap now
An SFTP server's CPU bill is dominated by one thing: encrypting and decrypting the SSH transport. Everything else — path resolution, permission checks, directory listings — rounds to zero next to AES on the wire.
The threading model matters more than the core count. Stylus SFTP Server exposes it directly in sftp-server.xml:
<threading>
<io-threads>0</io-threads> <!-- 0 = auto: 2 × CPU cores -->
<worker-threads>0</worker-threads>
</threading>
With both at 0 (the default), the NIO engine sizes itself at 2 × CPU cores for I/O threads. This is event-driven I/O, not thread-per-connection: 1000 open sessions do not mean 1000 threads. A small pool of I/O threads multiplexes all sockets, which is precisely why idle sessions are cheap. You only override these values when you've measured a reason to.
For throughput math: any server CPU from the last decade has hardware AES (AES-NI), and a single modern core sustains on the order of a gigabyte per second of AES-GCM. So the crypto for a fully saturated 10 Gb/s network link (~1.2 GB/s) fits in one to two cores. The practical guidance:
- 4 cores — comfortable for 1000 sessions on a 1 Gb/s link. The wire saturates long before the CPU does.
- 8 cores — the right floor for a 10 Gb/s link, or when the server also runs the embedded H2 database, the Web Portal, and WebDAV alongside SFTP.
- More than 8 cores buys you almost nothing for file transfer specifically. Spend the money on disk instead.
One caveat: authentication spikes. BCrypt password verification is deliberately expensive — that's the point of BCrypt. A morning burst of 500 logins in one minute is a real CPU event in a way that 500 ongoing transfers are not. The 30-second default auth timeout (<auth-timeout-seconds>) bounds how long unauthenticated connections can camp, and public-key auth largely sidesteps the cost. If your users all connect at 9:00 AM sharp, prefer keys over passwords.
RAM: bounded by design
Here's the part of the sizing story we're proudest of, because it's a design decision rather than a benchmark: every internal cache and queue in the server has a hard cap. Memory use does not grow unboundedly with load; it grows to the caps and stops. From the shipping code:
- The audit event queue is a
LinkedBlockingQueuecapped at 10,000 events. A background writer drains it in batches of 100 per database round-trip. If the database stalls and the queue fills, new events are dropped with a WARN log — the server sheds audit load rather than ballooning the heap or blocking transfers. - The account-resolution cache (username → user id, used by the bandwidth meter) is a Caffeine cache capped at 10,000 entries with a 1-hour TTL.
- The permission cache (per-user effective folder capabilities) is capped at 10,000 entries with a 5-minute TTL.
- Even the real-time notification hub caps itself: 8 WebSocket connections per user, 1,000 globally.
What's left is per-session state, and that splits the same way as the CPU story. An idle authenticated session costs tens of kilobytes — socket buffers, cipher state, channel bookkeeping. An active transfer costs more, because SFTP clients keep multiple write requests in flight (typically a few hundred kilobytes of window per channel), and those buffers live on the heap while the bytes are in motion.
Run the numbers for the 1000-user peak: 1000 idle-ish sessions at ~50 KB is 50 MB; 200 active transfers at ~1 MB of in-flight buffering is another 200 MB; the bounded caches and queues add tens of MB at their caps. That's well under 1 GB of live data. Add JVM overhead, the embedded H2 page cache, and GC headroom (you want the heap roughly 2× live data so the collector isn't running hot), and the recommendation is:
A 4 GB heap is comfortable for 1000 concurrent sessions. 8 GB of machine RAM total, leaving room for the OS page cache — which is doing real work caching your users' hot files.
One thing to check on your install: the Windows service registration does not set -Xmx, so the JVM defaults to 25% of physical RAM. On a 64 GB box that's a 16 GB heap you probably didn't intend. Set it explicitly via the service wrapper's JVM options (++JvmOptions=-Xmx4g on the prunsrv registration) and the heap becomes a number you chose rather than one you inherited.
Disk: the only resource that actually runs out
CPU and RAM degrade gracefully under pressure. Disk does not — a full volume is a hard stop for every user at once. We wrote about learning this the hard way when a single 9 GB upload ran our database out of disk, twice. Disk sizing deserves the most care, and it has three components.
1. Home directories: quota math
The server ships with per-user disk quotas, default 1 GB per user, hard-enforced (filesystem-config.xml). Hard enforcement means the write that would exceed the quota is rejected at the protocol level — the accounting is a baseline walk of the home directory on first write plus a lock-free running delta, so enforcement doesn't cost a directory scan per write. Your ceiling is arithmetic:
1000 users × 1 GB hard quota = 1 TB worst-case home storage
Real occupancy will be far lower — file exchange homes are transient by nature — but the quota product is the number the volume must survive. If you raise quotas for specific accounts (per-user overrides live in the sftp_quotas table), the ceiling moves with them.
2. Staging: same volume, on purpose
Every upload is written as filename.UUID.tmp in a centralized .staging directory and atomically renamed into place on clean channel close. ATOMIC_MOVE only works when source and target are on the same filesystem volume — so the staging tree deliberately mirrors the target drive layout to guarantee it. Two sizing consequences:
- In-flight uploads consume space on the same volume as the homes they're destined for. 200 concurrent 100 MB uploads is 20 GB of staging occupancy at peak, on top of committed files.
- Aborted transfers leave orphaned
.tmpfiles. The server cleans these up on startup once they exceed the configured age (default 2 hours,PT2H), so orphans are a bounded, self-healing cost rather than a slow leak.
3. The guard rails
Because disk exhaustion is the one failure that takes everyone down, the server actively defends the volume. The VolumeGuard checks free space when an upload starts and rejects it if the volume is below the minimum threshold (default 1 GB free). For large transfers it re-checks during the write, every 1 GB streamed — each check is one GetDiskFreeSpaceEx/statfs syscall, no directory walks — so a multi-gigabyte upload can't blow through the floor between checks. When free space approaches the threshold, admin alerts fire before users see failures.
Budget the volume as: quota ceiling + peak staging + database growth + guard headroom. For the 1000-user case on default quotas, a 2 TB volume is a comfortable answer; audit rows and the compacted throughput history (samples older than 24 hours are rolled up ~720:1 into hourly buckets and kept 30 days) add gigabytes, not terabytes.
The sizing table
| Resource | 1000 sessions, 1 Gb/s | 1000 sessions, 10 Gb/s |
|---|---|---|
| CPU cores | 4 | 8 |
JVM heap (-Xmx) | 4 GB | 4–6 GB |
| Machine RAM | 8 GB | 16 GB |
| Home volume (default 1 GB quotas) | 2 TB | 2 TB + staging peak |
| Disk type | SSD strongly preferred | NVMe |
Modest, isn't it? That's the honest conclusion of the exercise: a well-built SFTP server on event-driven I/O is not a hungry workload. The event-driven session model makes idle sessions nearly free, hardware AES makes the wire crypto nearly free, and the bounded-everything internal design means the heap doesn't scale with abuse. The resources that deserve your attention are the network link and the disk — and the disk mostly because failure there is total rather than gradual.
Validate with your own numbers
Sizing guides are hypotheses; your traffic is the experiment. The server records per-user throughput samples every 5 seconds into sftp_throughput_samples, and the activity charts render exactly the curves this post is guessing at: concurrent session counts, aggregate bandwidth, per-user rates. Run a two-week evaluation against realistic traffic, read the peaks off the chart, and replace every estimate above with a measurement. We wrote a field guide to those charts if you want to know what each line is sourced from.
And when the measurement disagrees with this post — trust the measurement.