Choosing between Nginx, Apache and Caddy is not a matter of taste once the numbers are on the table: they differ in per-connection memory, how they handle slow clients, what happens at 10,000 concurrent connections, and how much of your TLS lifecycle is automated. This article measures each on the same 4-vCPU instance, with the same application upstream, and reports the numbers that actually change decisions — RSS per worker, requests per second at fixed concurrency, p99 latency, and the operational cost of certificate management. Any of the three can comfortably serve a few thousand daily visitors on a modestly sized Linux VPS; the differences show up at steady high concurrency or under slow-client abuse.
The Test Rig
- 4 vCPU, 8 GB RAM, Ubuntu 24.04, kernel 6.8, local NVMe.
- Upstream: PHP-FPM 8.3, 8 workers, identical pool config for all three servers.
- Load generator:
wrk -t4 -c 400 -d 60s, plus a slow-client test usingslowhttptestto measure resistance to Slowloris-style connections. - Payload: static 4 KB HTML and a PHP endpoint doing one MySQL lookup — both reported separately, because static results mostly measure the kernel and dynamic results mostly measure the upstream.
Memory Footprint: The First Hard Limit
Measure it before and after 400 idle keep-alive connections:
<code>rmem() { ps -C "$1" -o rss= | awk '{s+=$1} END {printf "%d MB\n", s/1024}'; }
rmem nginx; rmem apache2; rmem caddy</code>
| Server | Idle (idle conns) | 400 keep-alive | Memory per conn |
|---|---|---|---|
| Nginx (1 worker, epoll) | ~9 MB | ~14 MB | ~12 KB |
| Caddy (1 process, Go runtime) | ~28 MB | ~46 MB | ~45 KB |
| Apache prefork | ~180 MB | ~1.1 GB | ~2.3 MB (process per conn) |
| Apache event MPM | ~22 MB | ~48 MB | ~60 KB |
Apache prefork is not obsolete by accident of momentum; it is still required by non-thread-safe modules (mod_php, some auth handlers). But its memory model is linear in concurrent connections, which makes it unsuitable above a few hundred concurrent slots on anything under 4 GB. Event MPM closes most of that gap and is the correct choice on Ubuntu/Debian’s modern packaging.
Static Throughput at 400 Concurrent Connections
<code>wrk -t4 -c400 -d60s --latency http://127.0.0.1/index.html</code>
| Server | Requests/sec | p50 | p99 | vCPU sat. |
|---|---|---|---|---|
| Nginx | 61,400 | 5.1 ms | 14 ms | 3.1 / 4 |
| Caddy | 54,900 | 5.8 ms | 17 ms | 3.4 / 4 |
| Apache event | 44,200 | 7.4 ms | 26 ms | 3.7 / 4 |
| Apache prefork | 17,300 | 21 ms | 88 ms | 4.0 / 4 |
The interesting column is p99. At high concurrency Nginx’s tail is about a third of Apache prefork’s, because its event loop never has a connection waiting for a process slot. Caddy’s tail sits between them; Go’s scheduler and garbage collector add a small amount of jitter that is invisible at 50 RPS and measurable at 50,000.
Dynamic Requests: Where the Upstream Dominates
<code>wrk -t4 -c200 -d60s http://127.0.0.1/api/lookup</code>
With PHP-FPM as the bottleneck all three converge to 2,300–2,500 RPS at p99 latency of 95–130 ms. This is the single most useful result in the article: if your page is 90% application time, the web server choice changes your headroom by a few percent, not a few multiples. Optimising the web server while the upstream is saturated is wasted effort — the same principle that makes profiling PHP-FPM before touching kernel parameters the right order of operations.
Where the front-end choice does show up in dynamic workloads is buffering and timeout semantics. If Nginx buffers the upstream response, a slow PHP endpoint ties up an FPM worker for as long as it runs while the client reads from Nginx’s buffer at whatever speed it likes. Disable buffering only for streaming endpoints; leaving it on for normal HTML protects the upstream from slow clients:
<code>proxy_buffering on;
proxy_buffer_size 8k;
proxy_buffers 8 8k;
proxy_busy_buffers_size 16k;
proxy_read_timeout 30s;</code>
Slow-Client Resistance
A Slowloris test opens connections and dribbles headers a byte at a time. Pure event-loop servers survive it with low memory but still consume file descriptors; process-per-connection servers collapse because each stalled client holds a whole process.
<code>slowhttptest -c 1000 -H -g -o slow -i 10 -r 200 -t 30</code>
| Server | RSS after 1,000 slow conns | Service still available? |
|---|---|---|
| Nginx | +31 MB | Yes |
| Caddy | +58 MB | Yes |
| Apache event | +62 MB | Yes |
| Apache prefork | MaxClients exhausted | No — 503s |
None of the three event-based servers replaces a proper rate limit. Configure timeouts explicitly, since defaults are generous:
<code># nginx
client_header_timeout 10s;
client_body_timeout 10s;
keepalive_timeout 65s;
keepalive_requests 1000;
limit_req_zone $binary_remote_addr zone=req:10m rate=20r/s;</code>
Configuration Model and Failure Modes
- Nginx — declarative, indentation-tolerant, with a genuinely good
nginx -tvalidator. Reloads are graceful. Failure mode: variable expansion is limited, and a misplaced brace produces a confusing error pointing at the wrong line. - Apache — the deepest module ecosystem and the best
.htaccesscompatibility for legacy applications. Failure mode: configuration sprawl acrossconf-enabled, plus MPM misconfiguration that silently caps concurrency. - Caddy — a JSON/Caddyfile config where TLS is automatic by default. Failure mode: automatic HTTPS depends on inbound port 80 and 443 reachability; behind a load balancer or with a private ACME challenge, you must configure
tls internalor DNS challenges explicitly.
TLS and Certificates
Nginx and Apache need an ACME client; Caddy embeds one. Measure the handshake cost, because it is the part users feel on first connection:
<code>openssl s_time -connect 127.0.0.1:443 -new -time 10 -www /index.html 2>/dev/null | grep connections</code>
On the same 4-vCPU box all three sustain roughly 900–1,100 full handshakes per second with ECDSA P-256 certificates, and roughly 3,500 with session resumption enabled. Enable OCSP stapling on Nginx and Apache; Caddy staples by default. The practical difference is operational: Caddy’s renewal is a non-event, while an Nginx setup needs a working certbot renew timer plus a reload hook — and a silently broken renewal is one of the most common causes of a surprise outage.
Recommendations by Workload
| Workload | Best fit | Why |
|---|---|---|
| Static + reverse proxy, high concurrency | Nginx | Lowest tail latency and memory per connection |
| Legacy PHP app with .htaccess | Apache (event MPM) | Compatibility without the prefork memory cost |
| Small self-hosted stack, few services | Caddy | Automatic TLS, single binary, minimal config |
| API gateway in front of many upstreams | Nginx | Mature upstream health checks and buffering controls |
| Internal tools behind a private network | Caddy | tls internal removes the CA dependency |
What to Verify After You Switch
Confirm the choice against your own traffic rather than these synthetic numbers. Run wrk or hey against a staging host with production-shaped requests, watch vmstat 1 for run-queue depth, and check that p99 — not average — improved. Then remove the server you did not pick and all of its config directories, because a stopped-but-installed web server listening on a wildcard address is a recurring cause of “port already in use” after a reboot. If your measurements show the front-end comfortably serving traffic while the box is still at 80% CPU, the next upgrade is more headroom in the underlying instance, not a different web server.




Leave a Reply
You must be logged in to post a comment.