Nginx vs Caddy vs Traefik: pick a proxy
Three services, one VPS, one public IP. How Nginx, Caddy and Traefik differ on certificates, config cost per app, websockets and Docker routing.
Nginx vs Caddy vs Traefik: the short answer
Nginx, Caddy and Traefik all do the same job as a reverse proxy: listen on port 443, read the hostname in each request, and pass it to the right service on your VPS. Any of the three will put four self-hosted apps behind one public IP address, and all of them are fast enough that your apps will be the slow part. What differs is how each one obtains a TLS (transport layer security) certificate and how much configuration every extra app costs you. The other difference arrives later, on the day you need something the common tutorials skip.
Pick Caddy if you want HTTPS handled for you and your services are ordinary web apps. Pick Traefik if everything runs in Docker Compose and you add a new service every few weeks. Pick Nginx if you already run it, or if you need response caching, client certificates, raw TCP forwarding, or a large existing config you would rather not rewrite.
How does each one get a TLS certificate?
This axis decides it for most people, so start here. All three end up holding the same certificate from the same authority. The work you do to get there is not the same.
Caddy asks for the certificate because you named a hostname. Write app.example.com as a site address and Caddy requests a certificate over ACME (automatic certificate management environment) from Let's Encrypt, falls back to ZeroSSL if that fails, serves the HTTP to HTTPS redirect on port 80, and renews on its own. There is no second tool and no timer to check. Certificates live in the caddy user's data directory, /var/lib/caddy/.local/share/caddy on a package install, so add that path to your backups or accept a fresh issuance after a rebuild. For a hostname that is not public, tls internal signs with Caddy's own local certificate authority instead. That gets you the same thing as creating a self-signed certificate on Ubuntu, with the renewal handled for you.
Nginx has no ACME client. Certbot obtains the certificate, and its --nginx plugin rewrites your server block to add the 443 listener and the redirect. Renewal runs from a systemd timer the package installs, so there are two moving parts and two things to verify: systemctl list-timers | grep certbot shows the timer exists, and sudo certbot renew --dry-run proves the renewal path still works. The step by step is in Certbot on Ubuntu 24.04 with Nginx, and the same tool covers a wildcard certificate through the DNS-01 challenge when you have more subdomains than you want to list.
Traefik carries its own ACME client. You configure one certificate resolver in the static configuration, and every router can then use it. All of the state, account key and certificates included, sits in a single acme.json file. Traefik refuses to use that file if it is readable by anyone but its owner, and it tells you so before it drops the resolver:
The ACME resolver "le" is skipped from the resolvers list because: unable to get ACME account: permissions 660 for /letsencrypt/acme.json are too open, please use 600Mount a directory and let Traefik create the file itself. Create it first with touch and it inherits your umask, which is how most people meet that line.
One thing is true for all three. The HTTP-01 challenge needs port 80 reachable from the internet, because the certificate authority connects back to it. Open 443 only and issuance fails in a way that reads like a DNS fault.
The same two-app routing job in three configs
The job: app.example.com goes to a service on 127.0.0.1:8080, files.example.com goes to one on 127.0.0.1:8081, both over HTTPS. Here is the whole thing in each proxy, so the verbosity difference is visible instead of asserted.
Nginx
# /etc/nginx/sites-available/app.example.com
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Then link it, test it, reload, and add the certificate.
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d app.example.comnginx -t printing syntax is ok and test is successful is the check to run before every reload. The second app is the same block with the hostname and the port changed. The proxy_set_header lines are not decoration: when proxy_pass names an address, nginx sends Host: 127.0.0.1:8080 upstream by default, so an app that builds absolute URLs from the Host header will send your users to localhost.
Caddy
app.example.com {
reverse_proxy 127.0.0.1:8080
}
files.example.com {
reverse_proxy 127.0.0.1:8081
}sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddyThat is the entire file. reverse_proxy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host itself, and by default it ignores whatever the client sent in those headers, so a request cannot lie to your backend about where it came from. Certificates, the port 80 redirect and renewal all follow from the two site addresses. Nothing else in the file asks for them.
Traefik
Traefik needs a static configuration before it routes anything. As a Compose service, with the image tag current as of August 2026:
services:
traefik:
image: traefik:v3.7
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.le.acme.email=you@example.com"
- "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencryptEach application then carries its own routing, on labels, in its own compose file:
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.example.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=le"
- "traefik.http.services.app.loadbalancer.server.port=8080"loadbalancer.server.port is the port inside the container, not a published port, because Traefik reaches the container over a shared Docker network. The app needs no ports: line at all, and that is the real benefit: only Traefik is published. The full build, including the shared network and the redirect middleware, is in routing multiple apps with Traefik and Docker Compose.
How much config does each extra app cost?
The data behind this chart
[
{
"tool": "Nginx",
"proxy_setup_lines": 0,
"lines_per_app": 11
},
{
"tool": "Caddy",
"proxy_setup_lines": 0,
"lines_per_app": 3
},
{
"tool": "Traefik",
"proxy_setup_lines": 17,
"lines_per_app": 5
}
]Counted from the blocks above. The Nginx server block is 11 non-blank lines, and you write it again for every hostname. The Caddy site block is 3 lines. Traefik wants 17 lines of static configuration before it serves a single request, then 5 labels per app.
Read the trade, not the winner. Traefik costs the most before the first app and the least for each app after it, and the two totals meet at around the third site. Below that, the static configuration is overhead you did not need. Above it, labels pull ahead and keep pulling ahead, because the routing lives next to the service it routes. Delete the service and its route goes with it, which is the thing a central config file is bad at: stale server blocks for apps that stopped existing months ago.
The line count also flatters Nginx. Each of those blocks needs a symlink, an nginx -t, a reload and a certbot run, while the Caddy edit needs one reload and the Traefik edit needs no command at all. All three reload without dropping live connections. The difference is the number of separate steps you have to remember at one in the morning.
Which one knows about your containers?
Traefik watches the Docker socket and builds routers from container labels as containers start and stop. Nothing else here does that. Nginx and Caddy both need a config edit and a reload when a new container appears, and they need an address they can reach: either a port published on loopback, or a shared Docker network with the proxy attached to it.
That feature has a price, and it is worth stating plainly. Traefik reads /var/run/docker.sock. Anyone who can talk to that socket can start a container with the host filesystem mounted inside it, which is root on the host. Mounting it read only reduces the risk without removing it. If that matters for your threat model, put a socket proxy in between that exposes only the container list endpoints Traefik needs.
Caddy can do label based discovery through a community plugin, but Caddy plugins are compiled in, so you build a custom binary or a custom image with xcaddy and then you own that build and its updates. For three or four services, editing a Caddyfile is less work.
Websockets and streaming: what breaks, and why
Nginx is the one that needs help. A WebSocket connection starts as an HTTP request carrying Upgrade: websocket, and nginx does not pass hop-by-hop headers upstream unless you tell it to.
# /etc/nginx/conf.d/upgrade-map.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Then, inside the location block, three lines that must all be present:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;Leave them out and the browser console prints WebSocket connection to 'wss://app.example.com/ws' failed while your backend log shows an ordinary GET. The map exists because a hardcoded Connection: upgrade would be sent on every request, including the plain ones that should say close.
Two more Nginx defaults bite. proxy_read_timeout is 60 seconds and it applies to the tunnel after the upgrade, so a websocket with no traffic for a minute is closed by the proxy. And server-sent events arrive late or in bursts until you set proxy_buffering off; on that location, because nginx holds the response in its buffer while your page waits for it.
Caddy performs the upgrade and switches the connection to a two way tunnel with no directives at all. It also flushes immediately when the response is text/event-stream or has no known length, so streaming works untouched. Traefik passes upgrades through and does not buffer responses unless you add its buffering middleware yourself. If your services include chat, a web terminal, log tails or live dashboards, that is a real difference in how much configuration you will write and debug.
The full Nginx server block, websockets and SSE included
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name app.example.com;
client_max_body_size 64m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_buffering off;
}
}The map belongs in the http context, not inside server, so keep it in its own file under /etc/nginx/conf.d/. Turn proxy_buffering off only on locations that stream, since buffering is what lets nginx release the backend worker early on ordinary responses. Certbot rewrites this block when you run it, so read the file again afterwards.
What happens when you need something unusual?
This is where Nginx earns its extra lines.
- Client certificates, also called mTLS (mutual TLS), where the client must present a certificate as well. Nginx wants
ssl_client_certificate /etc/ssl/ca.pem;andssl_verify_client on;in the server block. Caddy wants aclient_authblock insidetls. Traefik labels cannot express it at all: you define a TLS option in a file provider and point the router at it withtraefik.http.routers.app.tls.options=mtls@file. The everything-in-labels model gets an exception the first time you need this. - Large uploads. Nginx caps request bodies at 1 MB by default. A bigger upload returns
413 Request Entity Too Large, and the error log saysclient intended to send too large body. Raiseclient_max_body_size. Caddy and Traefik set no body limit by default, so the request reaches your app and your app's own limit decides. - Response caching. Nginx has
proxy_cache, and it is mature. Caddy needs a plugin compiled in. Traefik's open source build has no HTTP cache at all, which surprises people who assume every proxy caches. - Raw TCP or UDP, for a database port or a game server. Nginx has the
streammodule. Traefik has TCP and UDP routers on their own entrypoints. Caddy needs another plugin, so another custom build. - A web server already behind the proxy. If the service is a classic PHP application, then a LAMP stack on Ubuntu 24.04 already includes Apache, and stacking a proxy in front gives you two places that set headers and two that can rewrite a URL. Decide which one terminates TLS, then keep the other on plain HTTP bound to loopback.
The firewall trap that follows this choice
The point of a reverse proxy is that only 80 and 443 are open. Docker undoes that quietly. Publishing a port with -p 8080:80 writes a DNAT rule into the nat table, and that rule is evaluated before the INPUT rules ufw manages, so ufw deny 8080 does not block it and your app sits on the public internet next to the proxy you configured so carefully. Bind published ports to loopback with 127.0.0.1:8080:80, or drop ports: entirely and let the proxy reach the container over a Docker network, which is what the Traefik example above does. The mechanism and the fix are in why Docker published ports bypass ufw.
Test it from a machine that is not the VPS, because a check run on the box itself always succeeds:
curl --max-time 5 http://your.server.address:8080Connection refused or a timeout is the result you want. An HTTP response means that app is reachable without going through your proxy, and everything you configured above is decoration.
Which proxy should you pick?
Mostly static sites, plus an app or two: Caddy. Automatic HTTPS removes the largest recurring chore you have. The configuration stays short enough to read in one screen, and a static site is a root line and a file_server line inside the same site block. The cost is a smaller pool of copy-paste answers when something odd breaks.
A docker-compose homelab you keep adding to: Traefik. Past the third service, labels are less work than editing a central file, and a deleted service takes its route with it. Budget an afternoon for the first setup, because entrypoints, routers, services and middlewares are all new vocabulary. A typo in a label usually shows up as a 404 from Traefik rather than a failure to start, so read docker logs traefik for the parse error before you assume the app is broken.
An existing Nginx config, or any requirement from the list above: Nginx. It already has an answer for response caching and for client certificates, and almost every third-party guide assumes it. The cost is that certificates and websocket support are things you configure rather than things you get.
One rule holds whichever you pick. Exactly one process listens on the public interface, and everything else listens on loopback or on a private Docker network.
FAQ
Which reverse proxy is best for a few Docker apps on one VPS?
For three or four services that you add to now and then, Traefik pays for itself, because each app carries its own routing labels and needs no edit to a central file. If the services are stable and you mainly want HTTPS to stop being your problem, Caddy is less to learn and less to break. Choose Nginx when you already know it, or when you need a feature the other two lack, such as response caching or a plain TCP listener.
Does Caddy really need no certificate configuration?
For the normal case, yes. Naming a public hostname as a site address is the whole configuration: Caddy requests the certificate over ACME, serves the redirect from port 80, and renews before expiry. Two things still have to be true. Port 80 must be reachable from the internet for the HTTP-01 challenge, and the hostname's DNS A or AAAA record must already point at the VPS, because the certificate authority resolves the name and connects back to it.
Can I run Nginx and Traefik on the same VPS?
Not on the same ports. Whichever starts second fails to bind, and nginx says bind() to 0.0.0.0:443 failed (98: Address already in use) while Traefik logs a similar bind error and exits. Run one proxy on 80 and 443, and put everything else behind it. If you are migrating, move hostnames one at a time: let the front proxy forward to the old one on a loopback port until the last site has moved.
Why do my websockets drop after 60 seconds behind Nginx?
proxy_read_timeout defaults to 60 seconds and it applies to the tunnel once the upgrade is complete, so a connection with no traffic for a minute is closed by the proxy rather than by your app. Raise it on that location with proxy_read_timeout 3600s;, or have the application send a ping frame every 30 seconds. Caddy and Traefik do not close idle upgraded connections on a one minute timer, which is why the same app can look stable behind them and unstable behind Nginx.