Nginx vs Caddy vs Traefik: Which Proxy Fit You?
One VPS, one public IP: see how Nginx, Caddy and Traefik handle TLS certificates, Docker routing, websockets and config cost for each app.
Nginx vs Caddy vs Traefik: di short answer
Nginx, Caddy and Traefik all dey do the same work as reverse proxy: dem dey listen on port 443, read hostname wey dey inside each request, then pass am go the correct service for your VPS. Any one of the three fit put four self-hosted apps behind one public IP address, and all of dem fast enough say na your apps go be the slow part. Wetin differ na how each one dey obtain TLS (transport layer security) certificate and how much configuration each extra app go cost you. The other difference go show later, for the day you need something wey common tutorials no cover.
Choose Caddy if you want make e handle HTTPS for you and your services na ordinary web apps. Choose Traefik if everything dey run inside Docker Compose and you dey add new service every few weeks. Choose Nginx if you already dey run am, or if you need response caching, client certificates, raw TCP forwarding, or one big existing config wey you no wan rewrite.
How each one dey get TLS certificate?
This one na wetin decide am for most people, so start from here. All three go end up with the same certificate from the same authority. But the work wey you do to get there no be the same.
Caddy dey request the certificate because you name a hostname. Write app.example.com as site address, and Caddy request certificate through ACME (automatic certificate management environment) from Let's Encrypt. If that one fail, e go fall back to ZeroSSL. E go serve HTTP-to-HTTPS redirect for port 80 and renew the certificate by itself. You no need second tool or timer to check. Certificates dey inside caddy user data directory, /var/lib/caddy/.local/share/caddy for package install. Add that path to your backups, or accept say new certificate go issue after rebuild. If the hostname no dey public, tls internal sign am with Caddy own local certificate authority instead. The result be the same as creating self-signed certificate for Ubuntu, but Caddy go handle renewal for you.
Nginx no get ACME client. Certbot dey obtain the certificate, and its --nginx plugin rewrite your server block to add the 443 listener and the redirect. Renewal dey run from a systemd timer wey the package install, so two moving parts dey and two things need verification: systemctl list-timers | grep certbot show say the timer dey exist, while sudo certbot renew --dry-run prove say the renewal path still dey work. The step-by-step guide dey for Certbot for Ubuntu 24.04 with Nginx. The same tool also cover wildcard certificate through the DNS-01 challenge when you get more subdomains than you want list.
Traefik carry its own ACME client. You configure one certificate resolver for the static configuration, and every router fit use am after that. All the state, including account key and certificates, dey inside one acme.json file. Traefik no go use that file if anybody apart from the owner fit read am. E go tell you before e remove 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 make Traefik create the file by itself. Create the file first with touch. E go inherit your umask, and na so most people dey satisfy that requirement.
One thing apply to all three. The HTTP-01 challenge need port 80 to dey reachable from internet, because the certificate authority go connect back to am. If you open only 443, certificate issuance go fail in a way wey fit look like DNS problem.
The same two-app routing job for three configs
The job: app.example.com dey go to one service for 127.0.0.1:8080, while files.example.com dey go to another one for 127.0.0.1:8081, both through HTTPS. Na the complete setup for each proxy be this, so you fit see the difference for length instead of just hearing say e dey different.
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 am, test am, reload am, 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.comTo run nginx -t wey dey print syntax is ok and test is successful na the check wey you suppose do before every reload. The second app use the same block, but change the hostname and port. The proxy_set_header lines no be decoration: when proxy_pass name an address, nginx dey send Host: 127.0.0.1:8080 upstream by default. So, if app dey build absolute URLs from the Host header, e go send your users go localhost. The purpose of each of those four headers, plus why trailing slash for proxy_pass quietly change the path wey your app receive, dey explain directive by directive for this walkthrough of an nginx server block.
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 caddyNa the whole file be that. reverse_proxy set X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host by itself. By default, e ignore wetin client send for those headers, so request no fit lie to your backend about where e come from. The two site addresses automatically provide certificates, the port 80 redirect, and renewal. Nothing else for the file request dem.
Traefik
Traefik need static configuration before e fit route anything. As a Compose service, with the image tag wey 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 get its own routing for labels, inside 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 na the port inside the container, no be published port, because Traefik dey reach the container through shared Docker network. The app no need any ports: line, and na that be the main benefit: na only Traefik dey published. The complete setup, including the shared network and the redirect middleware, dey for routing multiple apps with Traefik and Docker Compose.
How much configuration each extra app dey 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
}
]From the blocks we count above. The Nginx server block get 11 non-blank lines, and you go write am again for every hostname. The Caddy site block get 3 lines. Traefik need 17 lines of static configuration before e fit serve even one request, then 5 labels for each app.
Read the trade-off, no be only who win. Traefik cost pass before the first app, but e cost least for every app wey you add after that. The two totals meet around the third site. Below that point, the static configuration na extra work wey you no need. Above that point, labels pull ahead and continue to pull ahead because the routing dey close to the service wey e routes. If you delete the service, the route delete with am. This na something central config file no handle well: stale server blocks for apps wey stop to exist months ago.
The line count still make Nginx look better than e be. Each of those blocks need a symlink, an nginx -t, a reload, and a certbot run. Caddy edit need only one reload, while Traefik edit no need any command at all. All three fit reload without dropping live connections. The real difference na how many separate steps you need remember at 1 in the morning.
Wich one sabi about your containers?
Traefik dey monitor Docker socket and dey build routers from container labels as containers start and stop. Nothing else for here dey do that. Nginx and Caddy both need make you edit config and reload am when new container show, and dem need address wey dem fit reach: either port wey you publish for loopback, or shared Docker network wey proxy dey attached to.
That feature get price, and e make sense to talk am plain. Traefik dey read /var/run/docker.sock. Anybody wey fit talk to that socket fit start container with host filesystem mounted inside am, and that one mean root access for host. Mounting am read only go reduce the risk but e no go remove am. If this one matter for your threat model, put socket proxy for middle. The proxy suppose expose only the container list endpoints wey Traefik need.
Caddy fit do label based discovery through community plugin, but Caddy plugins dey compiled inside, so you go build custom binary or custom image with xcaddy, then na you go manage that build and the updates. For three or four services, editing Caddyfile na less work.
Websockets and streaming: wetin fit break, and why
Na Nginx need help here. WebSocket connection dey start as HTTP request wey carry Upgrade: websocket, and nginx no dey pass hop-by-hop headers go upstream unless you tell am.
# /etc/nginx/conf.d/upgrade-map.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Then, inside the location block, three lines wey all must dey present:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;If you leave dem out, browser console go print WebSocket connection to 'wss://app.example.com/ws' failed while your backend log go show ordinary GET. map dey there because hardcoded Connection: upgrade go send for every request, including the normal ones wey suppose talk close.
Two more Nginx defaults fit cause problem. proxy_read_timeout na 60 seconds, and e apply to the tunnel after the upgrade. So, proxy go close websocket wey no get traffic for one minute. Server-sent events go arrive late or in bursts until you set proxy_buffering off; for that location, because nginx dey hold the response for buffer while your page dey wait.
Caddy dey perform the upgrade and switch the connection to two-way tunnel without any directive. E still dey flush immediately when response be text/event-stream or e no get known length, so streaming dey work without extra configuration. Traefik dey pass upgrades through and e no buffer responses unless you add its buffering middleware yourself. If your services include chat, web terminal, log tails, or live dashboards, this na real difference for how much configuration you go 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;
}
}map belong inside http context, no be inside server. So keep am for im own file under /etc/nginx/conf.d/. Turn proxy_buffering off only for locations wey dey stream, because buffering na wetin let nginx release backend worker early for ordinary responses. Certbot go rewrite this block when you run am, so read the file again afterwards.
Wetin happen when you need something wey no common?
Na here Nginx show why e get extra lines.
- Client certificates, wey dem dey also call mTLS (mutual TLS), where client must present certificate too. Nginx need
ssl_client_certificate /etc/ssl/ca.pem;andssl_verify_client on;inside server block. Caddy need oneclient_authblock insidetls. Traefik labels no fit express am at all: you define TLS option for file provider, then point the router to am withtraefik.http.routers.app.tls.options=mtls@file. The model wey put everything for labels get exception the first time you need this. - Large uploads. By default, Nginx limit request body to 1 MB. Bigger upload go return
413 Request Entity Too Large, and error log go showclient intended to send too large body. Increaseclient_max_body_size. Caddy and Traefik no set body limit by default, so request go reach your app and the app own limit go decide. - Response caching. Nginx get
proxy_cache, and e don mature. Caddy need plugin wey you compile inside. Traefik open source build no get HTTP cache at all. This one dey surprise people wey assume say every proxy dey cache. - Raw TCP or UDP, for database port or game server. Nginx get
streammodule. Traefik get TCP and UDP routers for their own entrypoints. Caddy need another plugin, so you go need another custom build. - Web server wey already dey behind the proxy. If the service na classic PHP application, then LAMP stack for Ubuntu 24.04 already include Apache. If you put proxy for front, two places go dey set headers and two places fit rewrite URL. Decide which one go terminate TLS, then keep the other one for plain HTTP and bind am to loopback.
The firewall trap wey follow this choice
The main point of reverse proxy na make only 80 and 443 dey open. Docker fit quietly undo this. When you publish port with -p 8080:80, e write DNAT rule inside nat table. This rule dey evaluated before the INPUT rules wey ufw dey manage. So ufw deny 8080 no go block am, and your app go dey for public internet beside the proxy wey you configure carefully. Bind published ports to loopback with 127.0.0.1:8080:80. Or remove ports: completely and make the proxy reach the container through Docker network, as the Traefik example above dey do. You fit see the mechanism and fix for why Docker published ports bypass ufw.
Test am from another machine wey no be the VPS. A check wey you run directly for the server itself go always succeed:
curl --max-time 5 http://your.server.address:8080Connection refused or timeout na the result wey you want. If HTTP response come back, e mean say people fit reach that app without passing through your proxy. Everything wey you configure above no get practical effect.
Which proxy you go pick?
Mostly static sites, plus one or two apps: Caddy. Automatic HTTPS dey remove the biggest repeat work wey you get. The configuration short enough to read for one screen, and static site na one root line plus one file_server line inside the same site block. The cost na say you get fewer copy-paste answers when something strange spoil.
A docker-compose homelab wey you dey add to: Traefik. After the third service, labels dey require less work than editing one central file, and when you delete service, e dey carry its route comot. Plan one afternoon for the first setup, because entrypoints, routers, services and middlewares na new vocabulary. Typo for label usually go show as 404 from Traefik instead of startup failure, so read docker logs traefik for the parse error before you conclude say the app spoil.
An existing Nginx config, or any requirement from the list above: Nginx. E already get solution for response caching and client certificates, and almost every third-party guide dey assume say you use am. The cost na say you go configure certificates and websocket support yourself instead of getting dem by default.
One rule dey apply whichever one you pick. Exactly one process go listen on the public interface, and everything else go listen on loopback or private Docker network.
FAQ
Which reverse proxy dey best for a few Docker apps on one VPS?
For three or four services wey you go dey add once in a while, Traefik dey worth am, because each app carry im own routing labels and you no need edit central file. If the services stable and wetin you mainly want na make HTTPS stop being your problem, Caddy get less things to learn and less things to spoil. Choose Nginx if you already sabi am, or if you need feature wey the other two no get, like response caching or plain TCP listener.
Caddy really no need certificate configuration?
For the normal case, yes. To name public hostname as site address na the complete configuration: Caddy request the certificate through ACME, serve the redirect from port 80, and renew am before e expire. Two things still need dey correct. Port 80 must dey reachable from internet for the HTTP-01 challenge, and the hostname DNS A or AAAA record must already point to the VPS, because certificate authority go resolve the name and connect back to am.
I fit run Nginx and Traefik for the same VPS?
No be for the same ports. Whichever one start second go fail to bind, and nginx go show bind() to 0.0.0.0:443 failed (98: Address already in use) while Traefik go log similar bind error and exit. Run one proxy on 80 and 443, then put everything else behind am. If you dey migrate, move hostnames one by one: make the front proxy forward to the old one on loopback port until the last site don move.
Why my websockets dey drop after 60 seconds behind Nginx?
proxy_read_timeout default na 60 seconds, and e apply to the tunnel after the upgrade don complete, so proxy go close connection wey get no traffic for one minute instead of your app. Increase am for that location with proxy_read_timeout 3600s;, or make the application send ping frame every 30 seconds. Caddy and Traefik no close idle upgraded connections with one-minute timer, na why the same app fit look stable behind dem but unstable behind Nginx.