GuidesCaddy, Nginx or Traefik

Caddy, Nginx or Traefik in front of your apps

[object Object]

Tested on Updated January 1, 1970

Recommended server for this guide

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy · 2 vCPU · 8 GB Memory · 60 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€8.99/mo

Deploy now
On this page

The short answer

Pick Caddy if you want HTTPS, HTTP/3, websockets and compression with the least configuration. It took the fewest lines, and it is what all our app guides use.

Pick Nginx if memory and static files matter most, or if you already know it: it used an eighth of Caddy's memory and served a static file more than twice as fast. Pick Traefik if your containers run under one user or in Docker and you want routes to follow them by their labels. It used the most memory, and it cannot serve files itself.

What we tested

All three ran on the same Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13, secured as in the security guide and set up as in the rootless Podman guide. All three want ports 80 and 443, so we ran them one at a time and stopped the other two.

  • Caddy 2.11.7, from docker.io/library/caddy:2, rootless as the caddy user, exactly as in the Podman guide.
  • Nginx 1.26.3 from Debian, with certbot 4.0.0 from Debian for the certificates.
  • Traefik 3.7.14, from docker.io/library/traefik:v3.7, rootless as a user of its own, traefik, set up the same way as Caddy.

Behind each, the same two apps ran rootless under one test user: traefik/whoami 1.11.0, a small web app that shows the request it received, and Uptime Kuma 2, which runs its interface over a websocket. Each had its own name, app.example.com and status.example.com in the examples, and its own port on 127.0.0.1.

Each proxy had to get certificates from Let's Encrypt for both names, redirect HTTP to HTTPS, speak HTTP/2 and HTTP/3, pass the visitor's real address to the app, carry websockets, add four security headers (Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options and Referrer-Policy) and compress responses. All three did all of it, with one exception: Nginx compressed with gzip only, because Debian has no zstd module for it.

CaddyNginxTraefik
Version2.11.71.26.3, certbot 4.0.03.7.14
Runs asits own user, rootlessroot, with workers as www-dataits own user, rootless
Installan image of 67 MB16 packages, 46 MB, 16 secondsan image of 195 MB
Lines of config for the test246155
Memory at idle49 MB6 MB99 MB
Memory after the load tests67 MB10 MB129 MB
Static file, requests per second2,7856,194cannot serve files
Static file, median / 99th percentile8.8 / 104 ms6.8 / 25 ms–
Proxied request, requests per second2,1372,7942,093
Proxied request, median / 99th percentile20 / 70 ms16 / 58 ms21 / 71 ms
Certificatesbuilt incertbot, with a timer and a reload hookbuilt in
Config change without a restartneeds changes to our setup (see below)systemctl reload nginxautomatic, from the file
Requests lost in a reload under load0 to 9 of about 26,0000 to 1 of about 38,0000 of about 30,000
New connections refused in a restartfor 0.7 secondsone request failedfor 1.9 seconds

Memory is the proportional set size (PSS) of the proxy's own processes, 90 seconds after a restart and one request to each site, without the apps. Lines are the configuration files without blank lines and comments.

The load came from oha 1.16.0 on the same server, with 50 connections for 20 seconds over HTTPS and HTTP/1.1, asking for gzip. The static file was a 10.6 KB HTML page, compressed on each request; the proxied request was whoami's 400-byte answer. With the load tool sharing the proxy's two vCPUs, the numbers compare the three with each other, not with what your visitors will see. Over HTTP/2, Caddy and Nginx served the static file at the same speed as over HTTP/1.1, and all three were 5 to 20 % slower on the proxied request.

Setup and configuration

Caddy needed 24 lines for everything. Certificates, the redirect to HTTPS, HTTP/2, HTTP/3, websockets and the visitor's address are all on by default, so the file only names the sites, the compression and the headers:

{
    admin off
}

(common) {
    encode zstd gzip
    header {
        Strict-Transport-Security "max-age=31536000"
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
    }
}

app.example.com {
    import common
    handle /static/* {
        root * /srv
        file_server
    }
    reverse_proxy 127.0.0.1:8156
}

status.example.com {
    import common
    reverse_proxy 127.0.0.1:8157
}

The /static/ files came from a directory mounted into the container with one more Volume= line in caddy.container.

Nginx installs with apt install nginx certbot python3-certbot-nginx, and runs as root from a systemd service that Debian sets up, so ports 80 and 443 need nothing more. We first put a server block for port 80 in /etc/nginx/sites-available/apps, linked it into sites-enabled, removed the default site, and asked for one certificate per name:

certbot certonly --nginx --cert-name app -d app.example.com
certbot certonly --nginx --cert-name status -d status.example.com

Then came the rest: 52 lines in the site file and 9 in a shared snippet, /etc/nginx/snippets/common.conf:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      "";
}

gzip_proxied any;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

upstream kuma {
    server 127.0.0.1:8157;
    keepalive 16;
}

upstream whoami {
    server 127.0.0.1:8156;
    keepalive 16;
}

server {
    listen 80;
    listen [::]:80;
    server_name app.example.com status.example.com;
    return 301 https://$host$request_uri;
}

server {
    server_name app.example.com;
    include snippets/common.conf;
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;
    ssl_certificate /etc/letsencrypt/live/app/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app/privkey.pem;

    location /static/ {
        root /var/www;
    }
    location / {
        proxy_pass http://whoami;
    }
}

server {
    server_name status.example.com;
    include snippets/common.conf;
    listen 443 quic;
    listen [::]:443 quic;
    ssl_certificate /etc/letsencrypt/live/status/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/status/privkey.pem;

    location / {
        proxy_pass http://kuma;
    }
}
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
proxy_hide_header X-Frame-Options;

Most of those lines switch on what Caddy and Traefik do by default. Nginx's documentation explains each:

  • Websockets need the Upgrade and Connection headers and HTTP/1.1 towards the app; Nginx does not pass them on by itself.
  • Without proxy_set_header Host, the app is asked for 127.0.0.1:8156 instead of its name.
  • Nginx compresses only HTML until you add gzip_types, and none of the apps' answers until gzip_proxied any.
  • Without the upstream blocks and keepalive, Nginx opens a new connection to the app for every request. In our first run without them, proxied requests ran at 1,464 a second, slower than both others.
  • HTTP/3 needs its own listen ... quic lines, reuseport on only one of them, and the Alt-Svc header that tells browsers about it.
  • add_header adds a header even when the app sends its own, so Uptime Kuma's answers had X-Frame-Options twice until proxy_hide_header removed the app's. A location with an add_header of its own also drops all the ones above it.

Traefik ran from a Quadlet file like Caddy's, with Network=host, the configuration directory mounted read-only, and a volume for its certificates. Like Caddy, it binds ports 80 and 443 as an ordinary user thanks to step 2 of the Podman guide. Its configuration is in two parts: traefik.yml, read at start, with the ports, Let's Encrypt and where to find the routes (24 lines), and a file of routes in dynamic/, read whenever it changes (31 lines):

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
  websecure:
    address: ":443"
    http3: {}
    http:
      tls:
        certResolver: letsencrypt
      middlewares:
        - secure@file
        - compress@file

certificatesResolvers:
  letsencrypt:
    acme:
      storage: /acme/acme.json
      httpChallenge:
        entryPoint: web

providers:
  file:
    directory: /etc/traefik/dynamic
    watch: true
http:
  routers:
    app:
      rule: Host(`app.example.com`)
      entryPoints: [websecure]
      service: app
    status:
      rule: Host(`status.example.com`)
      entryPoints: [websecure]
      service: status

  services:
    app:
      loadBalancer:
        servers:
          - url: http://127.0.0.1:8156
    status:
      loadBalancer:
        servers:
          - url: http://127.0.0.1:8157

  middlewares:
    secure:
      headers:
        stsSeconds: 31536000
        contentTypeNosniff: true
        customFrameOptionsValue: SAMEORIGIN
        referrerPolicy: strict-origin-when-cross-origin
    compress:
      compress:
        encodings: [zstd, gzip]

Websockets, the visitor's address and HTTP/2 needed nothing. Traefik's documentation lists an email address as required for Let's Encrypt; version 3.7.14 got both certificates without one. Its compression skips answers under 1,024 bytes, so whoami's short answer went out uncompressed, while Uptime Kuma's scripts came back with zstd or gzip.

Traefik cannot serve files from disk. For static files, run a small web server, such as Nginx or Caddy, in a container behind it, which is why the table has no static numbers for it.

The visitor's address

All three passed the visitor's real address to the app in X-Forwarded-For: whoami showed our own IPv4 address when we called it from outside, and the server's IPv6 address when we called it from the server. All three also threw away an X-Forwarded-For: 6.6.6.6 header that we sent ourselves, so a visitor cannot pretend to come from somewhere else:

  • Caddy and Traefik do that by default, and only keep such a header from proxies you list (trusted_proxies in Caddy, forwardedHeaders.trustedIPs in Traefik).
  • Nginx does it because the config sets X-Forwarded-For $remote_addr. Many examples use $proxy_add_x_forwarded_for instead, which adds the visitor's address to whatever the visitor sent.

If you put a CDN in front, each needs to be told its addresses: the same Caddy and Traefik settings, or set_real_ip_from in Nginx. We did not test that.

As the apps run rootless, the connection itself reaches them from the server's own IPv4 address, as explained in step 7 of the Podman guide. That is the address an app should trust as its proxy, whichever of the three you use.

Certificates and renewal

Caddy and Traefik got their certificates by themselves within seconds of starting, and keep them in their own volume. Caddy renews when about a third of a certificate's lifetime is left, or earlier if Let's Encrypt says so through its renewal information (ARI), and its documentation says it tries ZeroSSL if Let's Encrypt fails. Traefik's documentation says it renews 90-day certificates 30 days before they expire, with certificatesDuration for certificates of other lengths.

Nginx relies on certbot. Debian's package installs certbot.timer, which tries twice a day and renews 30 days before expiry. certbot renew --dry-run succeeded for both names, in 105 seconds. Two things are yours to do:

  • Nginx only uses a renewed certificate after a reload. Certbot's --nginx installer does that for you, but we used certonly to write our own config, so we added a hook:
    echo 'systemctl reload nginx' > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx
    chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx
    
  • A new site means running certbot again before its HTTPS block can work.

certbot --nginx without certonly writes the certificate lines into your config itself. nginx.org also has an ACME module of its own, nginx-module-acme, in its own packages but not in Debian's; we did not test it.

Reloads and restarts

We changed one header while 20 connections kept loading the proxied page, and counted failed requests:

  • Nginx: systemctl reload nginx lost one request of about 38,000 once, and none the other three times.
  • Traefik: it noticed the changed route file by itself, with nothing lost in three tries. Changes to traefik.yml need a restart, which refused new connections for 1.9 seconds.
  • Caddy, as set up in our Podman guide, has its admin interface switched off, so changes take a restart, which refused new connections for 0.7 seconds. To reload in place, we gave the admin interface a socket inside the container, admin unix//run/caddy-admin.sock, and ran podman exec caddy caddy reload --config /etc/caddy/Caddyfile --address unix//run/caddy-admin.sock. That lost 5 to 9 requests of about 26,000, on connections Caddy closed while reloading.

One catch with Caddy: our Podman guide mounts the Caddyfile as a single file. Most editors save by replacing the file, and the container then keeps seeing the old one, so a reload changes nothing. Mounting a directory instead, Volume=%h/caddy:/etc/caddy:ro, fixed that. A restart always reads the new file, which is why our guides restart Caddy.

How each finds your apps

Caddy and Nginx read static files: each new app means a new block and a restart or reload. Our app guides add one Caddy block each.

Traefik can also read containers' labels. We let it use the traefik user's Podman socket in place of Docker's, with useBindPortIP: true so that it sends traffic to the port the container publishes on 127.0.0.1, as a rootless container's own address cannot be reached from outside it. A whoami container with three labels got its route and certificate when it started, and the route disappeared when it stopped:

Label=traefik.enable=true
Label=traefik.http.routers.labelled.rule=Host(`app.example.com`)
Label=traefik.http.routers.labelled.entrypoints=websecure

That only sees the containers of the user whose socket Traefik has, and that socket gives Traefik full control of that user's containers. With our guides' layout, one user per app, labels do not fit, and Traefik's file of routes does the same job as Caddy's blocks. Labels make sense where all containers run under one user or in Docker, as in Coolify and Dokploy, which both put Traefik in front.

Resources and updates

At idle, Nginx used 6 MB in three processes, Caddy 49 MB and Traefik 99 MB. Under load, Caddy rose to 67 MB, Traefik to 129 MB and Nginx to 10 MB. On an Alloy, none of it matters; on a server with 1 GB, Nginx leaves the most for your apps.

Caddy and Traefik update like any of our containers, through AutoUpdate=registry and Podman's daily timer. Nginx and certbot come from Debian, so the automatic security updates from the security guide cover them, and they stay on the versions in Debian 13: Nginx 1.26, without zstd.

When something breaks, Caddy and Traefik log to the journal of their user, and Nginx to /var/log/nginx/. nginx -t checks a config before you reload, and caddy validate does the same for Caddy.

Licence and cost

All three are open source, according to their repositories on GitHub when we checked on 14 October 2026:

  • Caddy: Apache License 2.0.
  • Traefik: MIT. Traefik Labs also sells commercial products built on it, such as Traefik Hub.
  • Nginx: the 2-clause BSD licence. F5, which owns Nginx, sells NGINX Plus, a commercial version with more features and support; the Nginx in Debian is the open-source one.

Two forks of Nginx exist: Angie, by former Nginx developers, under a BSD-like licence and with an ACME client of its own, and freenginx, started in February 2024 by Maxim Dounin, a long-time Nginx developer. We tested neither.

None of the three costs anything for what we did here.

Which one to pick

  • Caddy, for most servers that run our guides. It is the shortest config, certificates need no attention, and every app guide adds its block to it. It is set up in Run containers rootless with Podman, on a VC-P Alloy like the one we used. Its weak spots: more memory than Nginx, the slowest static files of the two that serve them here, and a restart for every change unless you set up reloads as above.
  • Nginx, when memory is tight, you serve many static files, or you already have Nginx configs. Expect more lines, certbot to look after, and the pitfalls listed above. It runs as root from Debian; we tested it on an Alloy too.
  • Traefik, when your containers run under one user or in Docker and you want routes to follow their labels, or when software you use, such as Coolify or Dokploy, brings it. It used the most memory and serves no files itself, and with one user per app, as in our guides, it is a file of routes like the others. An Alloy suits it.

Only one of them can have ports 80 and 443, so pick one per server.

Run it on your own server

SSD RAID 1 · ECC DDR4 · 10 Gbit

VC-P Alloy

€8.99/mo

vCPU
2
Memory
8 GB
Storage
60 GB
Transfer
10 TB
Performance
SSD · RAID 1
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides