There is one question I ask in every server project, and the answer decides more about day-to-day operations than any benchmark table: who terminates the TLS connection, and where does the configuration for that live? A reverse proxy is the ingress layer in front of your applications. It accepts the connection from the internet, speaks TLS, distributes requests to backends, sets headers, and keeps WebSocket connections open, while the application behind it only speaks plain HTTP on an internal port.
My first reverse proxy was nginx on a root server, and I built that config by gluing together three blog posts. What I learned from it: this layer is rarely the problem while it runs, and it is always the problem when it stops renewing itself or when a timeout is set too short. That is what this article is about.
Where the reverse proxy starts and where it stops
Before comparing the three candidates, one boundary, because it gets lost in a lot of discussions. There is already an article on nginx here on the blog: tuning nginx and PHP-FPM properly. That one covers the application side: FastCGI, worker processes, PM settings, meaning how PHP requests get processed quickly once they have arrived at the backend.
This article covers the layer in front of that, the ingress layer. The component that listens on port 80 and 443, holds certificates, forwards connections, and distributes requests across backends. The two articles complement each other and do not overlap. Badly tuned FastCGI values will not turn a slow proxy with a good TLS config into a fast one, and a perfect PHP-FPM setup will not rescue a configuration pointing at an expired certificate chain.
The three candidates in one sentence each
nginx is the classic. A C-based web server that has been running servers since the 2000s, goes easy on resources, and has a config example for almost every task somewhere online. Caddy is the younger one with a different promise: TLS is default behavior rather than an extra chore, and the configuration is deliberately shorter than the others. Traefik comes from the container world, its natural source of configuration is Docker labels, and it ships its own service discovery that knows which containers are currently running.
All three do what a reverse proxy has to do: terminate TLS, distribute HTTP to backends, pass WebSockets through, set headers, run health checks. The difference is everything around that. And that is the decision you actually make, not the decimal place in throughput.
Why have this middle layer at all? Because it pulls work into a single place. TLS ends at the proxy, so backends no longer need certificates or a renewal job, and the proxy becomes the one spot where access logs converge, security headers get set, and the domain-to-port mapping is decided. Move a service to a different port and you change one line in the proxy and nothing in the application. In exchange you pay for an extra process to keep up, an extra config to care for, and one more place to look when something is unreachable.
Benchmark: static delivery on the same machine
For comparisons like this I take an Ubuntu LTS, the same machine, the same CPU, no container in between, and have all three serve a static file. The numbers from my last run: nginx lands at roughly 45,230 requests per second, Caddy at roughly 38,450, and Traefik at roughly 31,890. So nginx leads, Caddy sits about 15 percent below, and Traefik is a bit further back.
What that means in practice needs to be said out loud: this ranking almost never decides anything. 45,000 requests per second is far beyond what most self-built setups will ever see. Even a busy blog sits one to two orders of magnitude below that on a per-second average. The peaks that actually hurt in production are burst loads, slow backends, and large uploads, not static delivery.
Memory is more interesting because it is permanent. At idle, nginx sits at roughly 85 MB, Caddy at roughly 120 MB, and Traefik at roughly 180 MB. Traefik carries the cost of its service discovery, which maintains container lists in the background and keeps the routing table current. On a host with 1 GB of RAM this is a non-issue, spread over twenty edge nodes it becomes a line in your planning.
To put the measurement in context: these are bare numbers with no application behind them, no PHP, no database. As soon as a backend answers, the bottleneck almost always moves there and the proxy turns into a constant instead of a variable. If you want to judge a real proxy setup, measure the case that actually happens in production: a page with 40 requests, a 200 MB upload, a WebSocket that stays open for eight hours. The bare benchmark only tells you which proxy carries the least overhead when there is nothing else to do.
The gap widens with long-lived connections. 50,000 open WebSockets at once cost roughly 1.2 GB of memory with nginx, around 1.4 GB with Caddy, and around 1.8 GB with Traefik. If you push chats, terminals, or live dashboards through the proxy, you are not deciding on speed here, you are deciding whether the machine can hold the connections at all. And if you want to estimate the throughput those connections will push over the wire before the proxy even lands on a server, the download time calculator is the tool for that.
Config size: 22 lines versus 6
The most honest comparison between the three is not the benchmark, it is the configuration file. I gave all of them the same task: one domain, HTTPS, redirect from HTTP to HTTPS, two proxy headers, one backend on an internal port.
In nginx that comes to 22 lines in a server block, 11 of them non-empty. If you write the block out, you see the typical nginx precision: every header, every redirect, every TLS reference is stated explicitly. In Caddy the same job is done in 3 to 6 lines, because TLS, the redirect, and the usual proxy headers are part of normal behavior. In Traefik you get roughly 17 lines of static configuration for entry points, provider, and ACME resolver, plus 5 labels per service. A mistake I see regularly: someone forgets the static half and then wonders why the labels on the container do nothing at all.
That is the core of these numbers. nginx writes down what happens. Caddy omits what is obvious. Traefik splits the configuration across two places, one static on disk and one dynamic on the container.
The left half of the graphic shows the line counts, the right half shows the certificate lifecycle in four steps, broken down by what each proxy does for it. The lifecycle is the more telling half. In nginx's column you find an external tool and a systemd timer. In Caddy's column the request, the challenge, and the renewal are simply built-in behavior. With Traefik it runs through an ACME resolver whose state lives in a file that only the Traefik user is allowed to read.
TLS and certificates: this is where operational effort shows up
Certificates are the point where a proxy stops being judged by configuration taste and starts being judged by operational effort. A certificate is valid for 90 days, so it has to be renewed. Always. On every host, every year.
With nginx, renewal does not run inside the proxy but in a second system. certbot or acme.sh fetch the certificates, a separate systemd timer triggers it, and the proxy only picks up the new files after a reload. It has worked reliably for years, it is just one more moving part that needs monitoring. I wrote down how that looks in detail in automating Let's Encrypt, including the timer unit and the hooks for the reload.
Caddy builds it into the proxy. ACME is default behavior, it fetches certificates from Let's Encrypt, falls back to ZeroSSL when needed, and renews with exponential backoff, with a first attempt starting up to 30 days before expiry. When I renew a certificate on Caddy, I am not doing it because I maintain something by hand, I am doing it because I saw an error in a log. Two details belong in the runbook: the certificate path lives under /var/lib/caddy/.local/share/caddy and belongs in your backup, because a freshly installed Caddy that has to restore from nothing will re-request every certificate and can trip rate limits. And for purely internal names there is `tls internal`, which uses a local CA instead of trying to validate a public domain.
Traefik works with an ACME resolver defined per entry point. Its state lives in a file named acme.json. And here comes the trap that cost me half an hour the first time: acme.json must not be readable by other users. Traefik checks the file permissions and refuses the file if too many eyes could look inside. The file belongs in your backup with mode 600, and the error message it produces does not read like a permissions problem at first.
Known limits: cache, mTLS, HTTP/3
All three proxies have limits you should know in advance, because later they decide architecture.
On HTTP response caching, nginx is clearly ahead. proxy_cache is mature, well documented, and proven across thousands of setups over the last ten years. Traefik in its open source form ships no response cache at all, that is a feature of the commercial editions. Caddy can cache, but only through a plugin you add yourself. If you want to cache responses at the edge, you get there faster with nginx.
With client certificates, meaning mTLS, the other two pull ahead. nginx solves it with ssl_client_certificate and ssl_verify_client, Caddy with its own client_auth block inside the site definition. Traefik only supports it through the file provider, meaning a static config file instead of labels, which is a break in an environment that otherwise runs entirely on labels.
For HTTP/3, Caddy is the only one where it is default behavior, QUIC is simply on. Traefik supports it, but still as an experimental feature. With nginx it only works through a third-party module that you build and maintain yourself. If you want HTTP/3 today without tinkering, you end up with Caddy.
Which proxy when
When this question comes up, benchmark tables get reinterpreted as rankings even though they are nothing of the sort. The decision does not fall on the throughput winner, it falls on two things: where does the configuration come from, and how much operational effort am I willing to carry?
If the configuration comes from a file and you keep it in Git, take nginx, provided you have experience with it and need caching or unusual rewrites. If you want a file but do not want to build the TLS part yourself, take Caddy. It is the proxy for hosts you set up once and then leave alone: web servers, small API containers on a VM, a reverse proxy on a NAS or a Raspberry Pi. A handful of lines, TLS on its own, reload on its own.
If the configuration is dynamic instead, if services come and go, if containers are recreated on every deploy and the one with the right label should get the traffic, take Traefik. It is the only one where registering a service means setting a label and starting a container. The price is the memory footprint and the fact that a typo in a label is live immediately.
There is a combination I run myself fairly often: Traefik at the edge, because that is where services change, and an nginx behind it per backend serving the application. You do not have to pick one, you have to decide where which configuration comes from. nginx as pure file management and Traefik as label management combine well, as long as it is clear who is responsible for which hostname.
The second factor is operational effort, and it fits into a single question: who changes the configuration when a new service shows up? If it is a human logging into a server, they want a file with clear lines and a reload command. If it is a pipeline starting a container, it does not want to touch a file at all. Both paths are right, they just rule each other out. A setup where half the routing config sits in a file and the other half in labels is exactly the construction nobody fully understands six months later.
Operational traps I have hit all of them
The first trap is the HTTP-01 challenge. With this method Let's Encrypt checks whether a specific file on port 80 of your host is reachable. Which means port 80 has to be open to the internet, even if you only ever use HTTPS yourself. I closed that port once after a firewall change, and the error message read like a DNS problem. When certificate renewal suddenly fails, the first question is not "is the DNS right?", it is "is port 80 still open?".
The second trap is startup order. The proxy has to be up before the backends are ready, otherwise it starts with a broken configuration, or it serves 502s until the applications have come up. In systemd setups that means: the proxy service starts early and the application later, and the proxy has to treat a backend outage as temporary, not as a config error in its log.
The third trap is timeouts. WebSockets need long timeouts, large uploads need long timeouts, long-running queries need long timeouts. The defaults are too short for all of these cases. The connection does not drop immediately, it drops after two minutes, right in the middle of an upload or a database dump. And since it is the proxy closing the connection, the application never sees a thing.
The fourth trap is Traefik-specific: labels take effect immediately. There is no deploy step and no validation that catches a typo in a routing rule. A label that binds a service to the wrong domain is live the moment the container starts. I have bound a staging domain to a production backend that way without a single warning anywhere.
The fifth trap is more of a relief, but you still have to know it. Caddy sets X-Forwarded-For and X-Forwarded-Proto itself and ignores whatever a client sends in those headers. That is exactly right, it stops someone from fooling access logic with a forged header. The catch: your own header with the same name, defined somewhere in your config, is not appended, it is replaced. If you build logic on that header downstream, know which proxy writes it.
Versions in autumn 2026
For orientation, the versions I am working with here: Traefik is at v3.7.x, nginx runs 1.30.x on the stable branch and 1.31.x on mainline, Caddy is at v2.11. All three are actively maintained, so this is not about picking a project that someone may or may not keep developing. The question is not future-proofing, it is fit.
With nginx it is worth knowing your branch: stable gets security and bug fixes, mainline gets new features and, despite the name, is the branch recommended for production. With Docker images, version pinning is the second place I keep finding mistakes: a tag like latest pulls a different version on the next deploy, and a proxy that changed itself during a restart is the least pleasant kind of surprise. I pin the version in the compose file and update it on purpose.
Comparing fingerprints with the hash generator
One point that gets underestimated with certificates is checking the chain itself. If you want to know whether your proxy is really serving the certificate your internal CA signed, you do not compare the certificate, you compare its fingerprint. On the command line that is openssl x509 -fingerprint -sha256, which prints the SHA-256 hash of the certificate chain. Compare that hash against the one your CA or your monitoring has documented and you know in five seconds whether someone slipped in a different certificate or whether the proxy is still pointing at an old path.
For formatting hashes like that I use the hash generator. It takes text or file contents and returns the SHA-256 value, which you can line up directly against the openssl fingerprint and against the CA's documentation. Sounds like a detail, but it is the fastest check I know when a certificate problem does not look like a configuration mistake.
Bottom line: decide the ingress layer, don't try to win it
After all the numbers, a simple split remains, and it is the one I have run for years. nginx when the configuration lives in a file, you have the experience, and you need caching or mTLS. Caddy when you want a file and would rather not write TLS, redirects, and headers by hand. Traefik when the configuration should come from container labels and services change more often than the config file does.
Anyone trying to make this decision on the benchmark winner is making it on the wrong axis. Those 15 percent of throughput between nginx and Caddy have never shown up on any real day in my operations. The day a certificate expired because the timer was dead showed up instantly, on a customer's website.
So if you are putting up a reverse proxy this month, I would write down three things first: who renews the certificate, where the configuration lives, and when somebody last checked whether port 80 is still open. Answer those three and you have solved the hard parts long before the first request travels through the proxy.
As an aside, structured logs with journald from this blog fits well here, because the proxy is exactly the point where everything arrives that will later end up in a log, and a proxy that feeds journald cleanly shortens the troubleshooting after an outage considerably.