U2 / Networking and remote access
Caddy vs Traefik vs NGINX vs NPM: reverse proxy measured
Tested on Caddy 2.11.7
Test result
On this 4 vCPU test VM, NGINX with upstream keepalive served the most requests per second (40,942 on plain HTTP) and used the least RAM, Traefik 25,941 and Caddy 17,888. A minimal NGINX config and Nginx Proxy Manager landed at 2,081 and 2,271 req/s, most likely because every proxied request opened a new upstream connection (not traced; see below).

The numbers
Each proxy forwarded one hostname to the same tiny static backend (an NGINX container serving a 65-byte page with logging off), and the load generator oha hit it with 64 concurrent keep-alive connections for 15 seconds, three times per row. The first table is plain HTTP. The first row skips the proxy altogether, so it shows how much headroom the backend itself has.
| Setup | Requests/s, median (range) | p50 ms, median (range) | p99 ms, median (range) | RAM idle, MiB | RAM under load, MiB | Proxy CPU under load, % of one vCPU |
|---|---|---|---|---|---|---|
| Backend alone (no proxy) | 89,266 (88,179 to 89,529) | 0.36 (0.36 to 0.36) | 3.31 (3.30 to 3.34) | 11.6 | 12.4 (max 12.9) | 221 |
| Caddy 2.11.7 | 17,888 (15,674 to 20,635) | 2.54 (2.51 to 2.62) | 19.01 (10.88 to 26.92) | 10.5 | 31.0 (max 35.0) | 261 |
| Traefik 3.7.17 | 25,941 (25,432 to 26,010) | 2.07 (2.05 to 2.12) | 8.46 (8.44 to 8.65) | 18.0 | 53.9 (max 58.7) | 244 |
| NGINX 1.31.8, minimal proxy_pass | 2,081 (2,074 to 2,208) | 28.10 (26.34 to 29.15) | 56.48 (51.27 to 71.91) | 4.7 | 10.3 (max 10.7) | 356 |
| NGINX 1.31.8, upstream keepalive | 40,942 (40,858 to 40,980) | 1.48 (1.46 to 1.48) | 3.85 (3.76 to 3.97) | 4.7 | 6.3 (max 6.7) | 150 |
| NGINX 1.31.8, upstream keepalive, access_log off | 50,178 (49,418 to 51,817) | 1.18 (1.16 to 1.19) | 3.48 (3.27 to 3.58) | 4.7 | 6.4 (max 6.7) | 168 |
| Nginx Proxy Manager 2.16.0 | 2,271 (2,210 to 2,318) | 22.64 (20.78 to 31.79) | 60.66 (58.85 to 75.77) | 67.7 | 73.9 (max 74.4) | 369 |
oha 1.16.0, 64 concurrent connections with keep-alive, 15 s per run after a 5 s warm-up, 3 runs per row. The load generator, proxy and backend share one 4 vCPU VM, so the numbers are relative to each other, not absolute. RAM and CPU are docker stats samples of the proxy container taken during the runs; the 'Backend alone' row reports the backend container.
Read it as a ranking within one box, not as a speed rating. The load generator, the proxy and the backend all share the same four vCPUs, and a docker stats sampling loop also ran inside that VM during every timed run, which costs some CPU. The host’s load average at the start was 1.20, 2.74 and 4.96 (1, 5 and 15 minutes, env.txt), so other lab jobs may have shared the host; absolute figures would move on other hardware. The backend alone served 89,266 req/s, well above every proxy, so the proxies were the limit here.
The surprise is the gap inside NGINX. Three configs of the same binary gave 2,081, 40,942 and 50,178 req/s. The minimal config is a plain proxy_pass, the shortest form. The second adds an upstream block with keepalive 64, proxy_http_version 1.1 and a cleared Connection header, as the upstream module docs describe for keepalive to backends. The proxy module docs list 1.1 as the default proxy_http_version from NGINX 1.29.7, so on 1.31.8 the version line is redundant and the difference comes from the upstream keepalive pool and the Connection header. The third row also turns off the access log.
The docs describe keepalive to a backend as needing both the pool and the cleared header, and the minimal config has neither, so the likely cause is a new upstream connection per request. The test did not trace connections to confirm that. It did record socket counts after the first run of each row (*.sockets.txt), but those varied between two full runs of this test and are not used as evidence.
Nginx Proxy Manager 2.16.0 landed next to the minimal config (2,271 req/s, p50 22.64 ms). It generates NGINX configuration for each host, and the generated file and the shared proxy.conf it includes are saved in npm.generated-nginx-host.conf. They contain no upstream block and no Connection override. This suggests NPM’s default is the same minimal shape, which is an inference from reading that file, not something the test varied. NPM also supports custom NGINX snippets; the test did not try adding keepalive through them.
With TLS
The same proxies, with a locally generated EC certificate on the same hostname and oha --insecure. Connections are reused, so this mostly measures encrypted throughput rather than handshakes.
| Setup | Requests/s, median (range) | p50 ms, median (range) | p99 ms, median (range) | RAM idle, MiB | RAM under load, MiB | Proxy CPU under load, % of one vCPU |
|---|---|---|---|---|---|---|
| Caddy 2.11.7 | 18,127 (15,379 to 19,527) | 2.71 (2.67 to 2.73) | 14.54 (11.15 to 28.21) | 10.5 | 30.2 (max 33.5) | 261 |
| Traefik 3.7.17 | 23,446 (21,654 to 24,373) | 2.31 (2.22 to 2.51) | 8.99 (8.77 to 9.94) | 18.3 | 52.9 (max 71.1) | 242 |
| NGINX 1.31.8, minimal proxy_pass | 2,251 (2,225 to 2,263) | 30.82 (24.50 to 34.08) | 56.19 (40.97 to 59.85) | 5.8 | 14.0 (max 14.7) | 355 |
| NGINX 1.31.8, upstream keepalive | 29,306 (29,059 to 29,519) | 2.05 (2.04 to 2.08) | 5.61 (5.61 to 5.63) | 5.1 | 9.7 (max 10.2) | 179 |
| NGINX 1.31.8, upstream keepalive, access_log off | 30,512 (29,148 to 33,042) | 1.88 (1.76 to 1.97) | 5.87 (5.39 to 6.11) | 5.1 | 9.7 (max 10.2) | 200 |
| Nginx Proxy Manager 2.16.0 | 2,123 (2,088 to 2,126) | 29.55 (29.02 to 29.94) | 60.78 (57.43 to 61.09) | 83.8 | 93.8 (max 94.8) | 368 |
oha 1.16.0, 64 concurrent connections with keep-alive, 15 s per run after a 5 s warm-up, 3 runs per row. The load generator, proxy and backend share one 4 vCPU VM, so the numbers are relative to each other, not absolute. RAM and CPU are docker stats samples of the proxy container taken during the runs; the 'Backend alone' row reports the backend container.
Against plain HTTP, Traefik fell from 25,941 to 23,446, Nginx Proxy Manager from 2,271 to 2,123, NGINX with keepalive from 40,942 to 29,306, and keepalive with the access log off from 50,178 to 30,512. Caddy was level (17,888 plain, 18,127 TLS, inside its own spread of 15,379 to 19,527). NGINX with the minimal config went up, from 2,081 to 2,251, and the two ranges (2,074 to 2,208 and 2,225 to 2,263) do not overlap; the test did not look into why. The top four kept their order and NGINX with keepalive kept its lead, but the bottom two swapped: Nginx Proxy Manager was ahead of the minimal config on plain HTTP and behind it on TLS. With TLS, keepalive with and without the access log overlap (29,059 to 29,519 against 29,148 to 33,042).
RAM and CPU
RAM under load is in the right-hand columns of both tables above, taken from docker stats samples of the proxy container during the runs. NGINX used 6.3 MiB with keepalive on plain HTTP, Caddy 31.0 MiB, Traefik 53.9 MiB, and Nginx Proxy Manager 73.9 MiB, which also runs an admin API. Idle RAM was 4.7, 10.5, 18.0 and 67.7 MiB in the same order. The stack idle measurements for the stock images are in the table below, and differ because they measure the stock image without a proxied host:
| Stack | Version | RAM at idle | CPU at idle | Images on disk | Ready after |
|---|---|---|---|---|---|
| NGINX | 1.31.8 | 0.00% | 93.6 MB | 0.8 s | |
| Caddy | 2.11.7 | 0.00% | 92.8 MB | 0.8 s | |
| Traefik | 3.7.17 | 0.00% | 249.2 MB | 2.2 s | |
| Nginx Proxy Manager | 2.16.0 | 0.04% | 1909.4 MB | 5.7 s |
How much configuration one host needs
| Proxy | Plain HTTP | HTTPS with a certificate file |
|---|---|---|
| Caddy | 3 lines (Caddyfile) | 4 lines (Caddyfile) |
| Traefik | 11 lines (dynamic file) + 3 static flags | 16 lines (dynamic file) + 3 static flags |
| NGINX, minimal | 7 lines | 9 lines |
| NGINX, upstream keepalive | 13 lines | 15 lines |
| Nginx Proxy Manager | 0 lines; 1 API call with 6 JSON fields (plus 1 call to create the admin user) | 0 lines; 2 more API calls (create certificate, upload 2 files) and 1 field added to the host |
Counted as non-blank, non-comment lines in the files under lab/tests/proxy-bench/files/. Traefik uses the file provider here, not Docker labels.
Caddy needed the fewest lines for both cases. Traefik was the longest of the file-configured proxies; this test used its file provider rather than Docker labels, and the “static flags” are three command-line options in the compose file. NPM needed no files at all, but only because an API call replaced them: one request with six JSON fields once the admin account exists.
Files that were tested
One compose file selects a proxy by service name, and bench.sh runs inside the VM:
Show the full file
# Backend + one proxy (selected by profile) on one network. The proxy answers to the alias bench.lab.
# PROXY_CONF is the config file for the nginx/caddy/traefik variants (set by bench.sh).
services:
backend:
image: nginx:1.31.8-alpine
volumes:
- ./backend.conf:/etc/nginx/conf.d/default.conf:ro
- ./index.html:/usr/share/nginx/html/index.html:ro
caddy:
profiles: [caddy]
image: caddy:2.11.7
volumes:
- ./${PROXY_CONF:-x}:/etc/caddy/Caddyfile:ro
- ./certs:/certs:ro
networks: { default: { aliases: [bench.lab] } }
nginx:
profiles: [nginx]
image: nginx:1.31.8-alpine
volumes:
- ./${PROXY_CONF:-x}:/etc/nginx/conf.d/default.conf:ro
- ./certs:/certs:ro
networks: { default: { aliases: [bench.lab] } }
traefik:
profiles: [traefik]
image: traefik:v3.7.17
command:
- --providers.file.filename=/dynamic.yml
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
volumes:
- ./${PROXY_CONF:-x}:/dynamic.yml:ro
- ./certs:/certs:ro
networks: { default: { aliases: [bench.lab] } }
npm:
profiles: [npm]
image: jc21/nginx-proxy-manager:2.16.0
ports: ["127.0.0.1:8081:81"]
volumes:
- npm_data:/data
- npm_certs:/etc/letsencrypt
networks: { default: { aliases: [bench.lab] } }
volumes:
npm_data:
npm_certs:This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
Show the full file
#!/usr/bin/env bash
# Runs inside the VM, from ~/pb. Writes raw outputs to ~/pb/raw. One proxy at a time, backend always up.
set -u
cd "$HOME/pb"
export COMPOSE_PROJECT_NAME=pb
OHA=ghcr.io/hatoo/oha:1.16.0
CONC=64; DUR=15s; WARM=5s; RUNS=3
NET=pb_default
mkdir -p raw certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout certs/leaf.key -out certs/leaf.crt \
-days 2 -subj /CN=bench.lab -addext subjectAltName=DNS:bench.lab >/dev/null 2>&1
chmod 644 certs/*
{ docker image ls --digests --format '{{.Repository}}:{{.Tag}} {{.Digest}}'; } > raw/images.txt 2>&1
oha() { docker run --rm --network "$NET" "$OHA" "$@"; }
wait_ready() { # wait_ready <url> [oha flags]
local url="$1"; shift
for _ in $(seq 1 60); do
if oha -n 1 --no-tui --output-format json "$@" "$url" 2>/dev/null | jq -e '.statusCodeDistribution["200"] == 1' >/dev/null 2>&1; then return 0; fi
sleep 2
done
echo "NOT READY: $url" >&2; return 1
}
sample() { # sample <container> <outfile> <flagfile>: docker stats until flag disappears
while [ -e "$3" ]; do
docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' "$1" >> "$2" 2>/dev/null
done
}
measure() { # measure <label> <container> <url> [oha flags]
local label="$1" cid="$2" url="$3"; shift 3
echo "== $label $(date -u +%T)"
sleep 8
for _ in 1 2 3; do docker stats --no-stream --format '{{.MemUsage}} {{.CPUPerc}}' "$cid"; done > "raw/$label.idle.txt"
oha -c "$CONC" -z "$WARM" --no-tui --output-format json "$@" "$url" > /dev/null 2>&1
for i in $(seq 1 $RUNS); do
local flag="raw/.sampling"; touch "$flag"; : > "raw/$label.run$i.stats.txt"
sample "$cid" "raw/$label.run$i.stats.txt" "$flag" &
local sp=$!
oha -c "$CONC" -z "$DUR" --no-tui --output-format json "$@" "$url" > "raw/$label.run$i.json" 2> "raw/$label.run$i.err"
rm -f "$flag"; wait "$sp"
if [ "$i" = 1 ]; then
{ echo "-- proxy /proc/net/sockstat"; docker exec "$cid" cat /proc/net/sockstat 2>&1
echo "-- VM nf_conntrack_count"; cat /proc/sys/net/netfilter/nf_conntrack_count 2>&1; } > "raw/$label.sockets.txt"
fi
jq -c '{rps:.summary.requestsPerSec,p50:.latencyPercentiles.p50,p99:.latencyPercentiles.p99,ok:.statusCodeDistribution}' "raw/$label.run$i.json"
done
}
cid() { docker compose ps -q "$1"; }
# --- direct baseline (backend only)
docker compose up -d backend >/dev/null 2>&1
wait_ready http://backend/
measure direct.http "$(cid backend)" http://backend/
# --- caddy / nginx / traefik: file-configured, one config per scheme
run_file_proxy() { # run_file_proxy <service> <label> <conf-prefix>
local svc="$1" label="$2" prefix="$3" v
# throwaway start: the first start of a binary reads it into the page cache, which docker stats counts as memory
PROXY_CONF="$( [ "$svc" = caddy ] && echo caddy/Caddyfile.http || { [ "$svc" = traefik ] && echo traefik/dynamic.http.yml || echo nginx/$prefix.http.conf; } )" docker compose up -d "$svc" >/dev/null 2>&1
sleep 5; docker compose rm -sf "$svc" >/dev/null 2>&1
for v in http tls; do
case "$svc" in
caddy) conf="caddy/Caddyfile.$v" ;;
traefik) conf="traefik/dynamic.$v.yml" ;;
nginx) conf="nginx/$prefix.$v.conf" ;;
esac
PROXY_CONF="$conf" docker compose up -d --force-recreate "$svc" >/dev/null 2>&1
if [ "$v" = http ]; then url=http://bench.lab/; fl=(); else url=https://bench.lab/; fl=(--insecure); fi
if wait_ready "$url" "${fl[@]}"; then measure "$label.$v" "$(cid "$svc")" "$url" "${fl[@]}"; fi
docker compose logs --no-color --tail=15 "$svc" > "raw/$label.$v.logs-tail.txt" 2>&1
docker compose rm -sf "$svc" >/dev/null 2>&1
done
}
run_file_proxy caddy caddy x
run_file_proxy traefik traefik x
run_file_proxy nginx nginx-minimal minimal
run_file_proxy nginx nginx-keepalive keepalive
run_file_proxy nginx nginx-keepalive-nolog keepalive-nolog
# --- Nginx Proxy Manager: configured through its API
docker compose up -d npm >/dev/null 2>&1; sleep 40; docker compose rm -sfv npm >/dev/null 2>&1; docker volume rm -f pb_npm_data pb_npm_certs >/dev/null 2>&1 # throwaway start, same reason as above; the backend stays up
docker compose up -d npm >/dev/null 2>&1
for _ in $(seq 1 90); do curl -s -m3 localhost:8081/api/ | jq -e '.status=="OK"' >/dev/null 2>&1 && break; sleep 2; done
api() { curl -s -m20 "$@"; }
api -XPOST localhost:8081/api/users -H 'content-type: application/json' \
-d '{"name":"Lab","nickname":"lab","email":"lab@example.com","roles":["admin"],"is_disabled":false,"auth":{"type":"password","secret":"lab-only-not-a-secret"}}' > raw/npm.create-user.json
TOKEN=$(api -XPOST localhost:8081/api/tokens -H 'content-type: application/json' -d '{"identity":"lab@example.com","secret":"lab-only-not-a-secret"}' | jq -r .token)
H="Authorization: Bearer $TOKEN"
HOSTBODY='{"domain_names":["bench.lab"],"forward_scheme":"http","forward_host":"backend","forward_port":80,"enabled":true,"meta":{}}'
echo "$HOSTBODY" > raw/npm.host-body.json
api -XPOST localhost:8081/api/nginx/proxy-hosts -H "$H" -H 'content-type: application/json' -d "$HOSTBODY" > raw/npm.create-host.json
HID=$(jq -r .id raw/npm.create-host.json)
sleep 4
wait_ready http://bench.lab/ && measure npm.http "$(cid npm)" http://bench.lab/
CID=$(api -XPOST localhost:8081/api/nginx/certificates -H "$H" -H 'content-type: application/json' -d '{"provider":"other","nice_name":"bench"}' | jq -r .id)
api -XPOST "localhost:8081/api/nginx/certificates/$CID/upload" -H "$H" -F certificate=@certs/leaf.crt -F certificate_key=@certs/leaf.key > raw/npm.cert-upload.json
api -XPUT "localhost:8081/api/nginx/proxy-hosts/$HID" -H "$H" -H 'content-type: application/json' \
-d "{\"domain_names\":[\"bench.lab\"],\"forward_scheme\":\"http\",\"forward_host\":\"backend\",\"forward_port\":80,\"certificate_id\":$CID}" > raw/npm.update-host.json
sleep 4
wait_ready https://bench.lab/ --insecure && measure npm.tls "$(cid npm)" https://bench.lab/ --insecure
docker compose exec -T npm sh -c 'cat /data/nginx/proxy_host/*.conf; echo ---- proxy.conf; cat /etc/nginx/conf.d/include/proxy.conf' > raw/npm.generated-nginx-host.conf 2>&1
docker compose logs --no-color --tail=15 npm > raw/npm.logs-tail.txt 2>&1
docker compose down -v >/dev/null 2>&1
echo done > raw/DONEConfigs per proxy are in lab/tests/proxy-bench/files/, for example the NGINX one that made the difference:
Show the full file
upstream app {
server backend:80;
keepalive 64;
}
server {
listen 80;
server_name bench.lab;
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}What went wrong or surprised
- The first full run of this test measured each proxy’s first start with a higher idle RAM than its second, because
docker statscounted the freshly read binary. The script now starts each proxy once and throws that start away before measuring. A first attempt at the Nginx Proxy Manager part of that fix also removed the backend container, so NPM’s requests failed, and the run was repeated. Neither affected the numbers published here. - NPM refused to start with a named volume that was not declared in compose (it checks that
/etc/letsencryptis a mount), which cost a few minutes during setup. The compose file in the test declares both volumes. - Run-to-run spread was large for Caddy (15,674 to 20,635 req/s on plain HTTP) and its p99 had the widest relative range of any proxy (10.88 to 26.92 ms, 2.5 times; in absolute terms the minimal NGINX and NPM p99 ranges are wider, about 20 and 17 ms). The cause was not investigated.
What this test did not cover
- Real networks, real TLS handshakes per connection, HTTP/2 and HTTP/3. Connections were reused for the whole run.
- Many hosts, large responses, websockets and streaming. One tiny static page was served.
- Let’s Encrypt or any internal CA integration: the certificate was one self-made EC certificate loaded from a file. Caddy’s own CA is covered in a separate guide.
- Tuning beyond the NGINX keepalive change. Caddy, Traefik and NPM ran with their defaults; their own tuning options were not tried.
- Access logging on the other proxies. The NGINX image logs each request to stdout by default; in this test’s log tails Caddy and Traefik showed no per-request lines (no documentation page was found that states their defaults), and the NGINX rows with and without logging show how much that alone is worth on this test.
- CPU limits and memory limits on the containers.
Sources and how this was made
Show the full file
#!/usr/bin/env bash
# proxy-bench: Caddy, Traefik, NGINX and Nginx Proxy Manager in front of one static backend, same load generator.
TEST_ID=proxy-bench; VM_NAME=b-proxy-bench
source "$(dirname "$0")/../access-common.sh"
HERE="$ROOT/lab/tests/$TEST_ID"
rm -rf "$RAW"; mkdir -p "$RAW"
vm_start
capture_env
vm ssh 'rm -rf ~/pb; mkdir -p ~/pb/raw'
vm scp-to "$HERE/files/." pb/ 2>/dev/null || { vm scp-to "$HERE/files" pb_files; vm ssh 'cp -r ~/pb_files/. ~/pb/'; }
vm scp-to "$HERE/bench.sh" pb/bench.sh
# pre-pull so downloads do not run during measurements
vm ssh 'for i in nginx:1.31.8-alpine caddy:2.11.7 traefik:v3.7.17 jc21/nginx-proxy-manager:2.16.0 ghcr.io/hatoo/oha:1.16.0; do docker pull -q $i; done' > "$RAW/pull.txt" 2>&1
vm ssh 'cd ~/pb && nohup ./bench.sh > raw/bench.log 2>&1 &'
for _ in $(seq 1 240); do
vm ssh 'test -f ~/pb/raw/DONE' 2>/dev/null && break
sleep 15
done
vm scp-from pb/raw/. "$RAW/"
python3 -I "$HERE/summarize.py" "$RAW" "$HERE/files" "$OUT"- NGINX docs: ngx_http_upstream_module, keepalive
- NGINX docs: ngx_http_proxy_module, proxy_http_version
How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.