SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-13

Nginx vs Caddy vs Traefik: Which Proxy Fit You?

One VPS, one public IP: see how Nginx, Caddy and Traefik handle TLS certificates, per-app config, websockets and Docker routing before you choose.

Nginx vs Caddy vs Traefik: di short answer

Nginx, Caddy and Traefik all dey do the same work as reverse proxy: dem 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 reach make your apps be the slow part. The difference na how each one dey obtain TLS (transport layer security) certificate, and how much configuration every extra app go require. 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 big existing config wey you no wan rewrite.

How each one dey get TLS certificate?

This na the thing wey decide am for most people, so make we start here. All three go end up with the same certificate from the same authority. But the work to get am no be the same.

Caddy dey request the certificate because you name one hostname. Write app.example.com as site address and Caddy go request certificate through ACME (automatic certificate management environment) from Let's Encrypt. If that one fail, e go use 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 am. Certificates dey inside caddy user data directory, /var/lib/caddy/.local/share/caddy for package install. So add that path to your backups, or accept fresh issuance after rebuild. If the hostname no be public, tls internal go sign am with Caddy own local certificate authority instead. That one give you the same result as creating self-signed certificate for Ubuntu, while Caddy handle the renewal for you.

Nginx no get ACME client. Certbot go obtain the certificate, and its --nginx plugin go rewrite your server block to add the 443 listener and the redirect. Renewal go run from a systemd timer wey the package install. So two things dey move, and you need verify two things: systemctl list-timers | grep certbot go show say the timer dey exist, while sudo certbot renew --dry-run go 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 fit handle wildcard certificate through 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, then every router fit use am. 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 drop 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 600

Mount one directory and allow Traefik create the file by itself. First create the directory with touch. E go inherit your umask, and na that be how most people satisfy that requirement.

One thing apply to all three. 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, issuance go fail in a way wey look like DNS problem.

Di same two-app routing work for three configs

The work be say: app.example.com go to service for 127.0.0.1:8080, files.example.com go to another one for 127.0.0.1:8081, and both go over HTTPS. Na the complete config for each proxy be this, so you fit see the difference for how much configuration each one needs.

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.com

When nginx -t print syntax is ok and test is successful, na the check you must run 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 sends Host: 127.0.0.1:8080 upstream by default. So, if an app dey build absolute URLs from the Host header, e go send your users go 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 caddy

Na the complete file be this. reverse_proxy set X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host by itself. By default, e ignore wetin the client send for those headers, so request no fit lie to your backend about where e come from. The two site addresses handle certificates, the port 80 redirect, and renewal automatically. Nothing else for the file needs to request dem.

Traefik

Traefik need static configuration before e fit route any request. 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:/letsencrypt

Each application carry its own routing inside labels, for 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 reaches the container through a shared Docker network. The app no need any ports: line, and na this be the main benefit: na only Traefik dey published. The complete setup, including the shared network and redirect middleware, dey for routing plenty apps with Traefik and Docker Compose.

How much config each extra app dey cost?

ChartNon-blank config lines for the same two-app routing job
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
  }
]

We count am from the blocks 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.

Look 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 am, labels begin lead and keep increasing the gap, because the routing configuration dey beside the service wey e route. If you delete the service, its route go delete with am. Na this kind stale server block for apps wey stop existing months ago central config file no handle well.

The line count still make Nginx look better than e be. Each block need a symlink, an nginx -t, a reload, and a certbot run. Caddy edit need 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 for one morning when sleep never catch you.

Wetin know about your containers?

Traefik dey watch Docker socket and dey build routers from container labels as containers dey start and stop. Nothing else for here dey do that. Nginx and Caddy both need config edit and reload anytime new container show, and dem need address wey dem fit reach: either port wey publish for loopback, or shared Docker network wey proxy attach to.

That feature get price, and e good make we talk am plainly. 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 reduce the risk but e no remove am. If this matter for your threat model, put socket proxy for middle. The proxy suppose expose only container list endpoints wey Traefik need.

Caddy fit do label based discovery through community plugin, but Caddy plugins dey compile inside, so you need build custom binary or custom image with xcaddy. After that, na you go maintain the build and its updates. For three or four services, editing Caddyfile na less work.

Websockets and streaming: wetin fit break, and why

Na Nginx need small help. 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, these three lines must dey there:

        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 for send on every request, including the normal ones wey suppose talk close.

Two other Nginx defaults fit cause problem. proxy_read_timeout na 60 seconds, and e apply to the tunnel after upgrade. So, proxy go close websocket wey get no 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 inside buffer while your page dey wait for am.

Caddy dey perform the upgrade and switch the connection to two-way tunnel without any directive. E also dey flush immediately when response be text/event-stream or e no get known length, so streaming go work without extra setup. Traefik dey pass upgrades through and e no dey buffer responses unless you add its buffering middleware yourself. If your services include chat, web terminal, log tails, or live dashboards, this difference go affect how much configuration you go write and debug.

The complete 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 allow nginx release the backend worker early for normal responses. Certbot go rewrite this block when you run am, so read the file again afterwards.

Wetin go happen when you need something unusual?

Na here Nginx show why e get extra lines.

  • Client certificates, wey dem still dey call mTLS (mutual TLS), where the client must present certificate too. Nginx need ssl_client_certificate /etc/ssl/ca.pem; and ssl_verify_client on; inside the server block. Caddy need one client_auth block inside tls. Traefik labels no fit express this at all: you go define TLS option for file provider, then point the router to am with traefik.http.routers.app.tls.options=mtls@file. The model wey put everything for labels get exception the first time you need this.
  • Large uploads. Nginx limit request bodies to 1 MB by default. Bigger upload go return 413 Request Entity Too Large, and error log go talk client intended to send too large body. Increase client_max_body_size. Caddy and Traefik no set body limit by default, so request go reach your app and na the app own limit go decide.
  • Response caching. Nginx get proxy_cache, and e don mature well. Caddy need plugin wey dem compile inside. Traefik open source build no get HTTP cache at all, and this dey surprise people wey assume say every proxy dey cache.
  • Raw TCP or UDP, for database port or game server. Nginx get stream module. Traefik get TCP and UDP routers for their own entrypoints. Caddy need another plugin, so you go need another custom build.
  • Web server wey dey already behind the proxy. If the service na classic PHP application, then LAMP stack for Ubuntu 24.04 already include Apache, and putting proxy for front go give you two places wey dey set headers and two wey fit rewrite URL. Decide which one go terminate TLS, then leave the other one for plain HTTP wey bind to loopback.

The firewall wahala wey this choice dey cause

The main reason for reverse proxy na make only 80 and 443 dey open. Docker fit quietly cancel this protection. When you publish port with -p 8080:80, e write DNAT rule inside the nat table. This rule dey evaluate before the INPUT rules wey ufw dey manage. So ufw deny 8080 no fit block am, and your app dey 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, then make the proxy reach the container through Docker network. Na this method the Traefik example above dey use. You fit see the mechanism and the fix for why Docker published ports dey bypass ufw.

Test am from another machine wey no be the VPS. If you run the test for the VPS itself, e go always succeed:

curl --max-time 5 http://your.server.address:8080

Connection refused or timeout na the result wey you want. If you receive HTTP response, e mean say that app dey reachable without passing through your proxy. This means say everything wey you configure above no dey protect am.

Which proxy you suppose pick?

Na sites wey mostly static, plus one or two apps: Caddy. Automatic HTTPS go remove the biggest regular work wey you get. The configuration short enough to read for one screen, and static site na a root line plus a file_server line inside the same site block. The cost be say, if strange problem happen, fewer copy-paste answers dey available.

A docker-compose homelab wey you dey add services to: Traefik. After the third service, labels require less work than editing one central file, and when you delete service, e route go delete with am. Plan one afternoon for the first setup, because entrypoints, routers, services, and middlewares na new terms. Typo for label normally go show as 404 from Traefik instead of failure to start, so read docker logs traefik for the parse error before you conclude say na the app dey broken.

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 assume say you dey use am. The cost be say you go configure certificates and websocket support yourself instead of getting dem automatically.

One rule apply no matter which one you pick. Na exactly one process suppose listen on the public interface, while everything else listens on loopback or private Docker network.

FAQ

Which reverse proxy dey best for small Docker apps on one VPS?

For three or four services wey you dey add once in a while, Traefik dey worth am, because each app get im own routing labels and you no need edit one central file. If the services stable and na mainly HTTPS you want make e stop dey worry you, Caddy easier to learn and less likely to cause wahala. 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 whole configuration: Caddy go request the certificate through ACME, serve the redirect from port 80, and renew am before e expire. But 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 the certificate authority go resolve the name and connect back to am.

I fit run Nginx and Traefik on the same VPS?

No be for the same ports. The one wey 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 for ports 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 through a loopback port until you move the last site.

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, if connection get no traffic for one minute, proxy go close am instead of your app. Increase the value for that location with proxy_read_timeout 3600s;, or make the application send ping frame every 30 seconds. Caddy and Traefik no dey close idle upgraded connections with one-minute timer, na why the same app fit look stable behind dem but unstable behind Nginx.