DEV Community

Libme
Libme

Posted on

Caddy vs Nginx vs Traefik: Which Reverse Proxy for One Small App on One Server?

If you are putting one or two containers behind HTTPS on a single VPS, Caddy gets you there with the fewest moving parts — automatic certificates with a four-line config. Nginx is the right call when you need fine-grained control over caching, buffering, and rewrites and you do not mind running certbot beside it. Traefik only starts paying for itself when containers come and go and you want routing to follow them automatically, and it charges you for that with a Docker socket mount and a labels DSL you will debug at least once.

All three terminate TLS and forward requests. The differences that actually matter in production are where the configuration lives, who renews your certificates, and how each one resolves the upstream address — the last one is the source of the mystery 502 that shows up a week after you deploy.

How does each one handle HTTPS without manual work?

This is the single biggest practical split between them, so it is worth being precise about.

Caddy Nginx (open source) Traefik
Certificates Issued and renewed automatically, on by default Historically external (certbot or similar); native ACME support has been landing, so check the current docs Built-in ACME resolvers (HTTP, TLS-ALPN, DNS challenges)
Config style Caddyfile (declarative, very short) or JSON API Imperative directive blocks Static flags/file + dynamic config from providers (Docker labels, K8s CRDs, files)
Reload on change caddy reload, no dropped connections nginx -s reload Dynamic config reloads itself; static config needs a restart
Upstream DNS Resolved when the connection is dialed Resolved once at startup unless you use a resolver + variable Re-resolved from the provider as containers change
Wildcard certs Needs a DNS-challenge plugin build Whatever certbot plugin you install DNS challenge via provider credentials
Biggest real cost Smaller ecosystem of copy-pasteable recipes for exotic setups You own cert renewal and reload plumbing Docker socket access and a labels syntax with no autocomplete

A working Caddy config for a containerised app is genuinely this:

app.example.com {
    encode zstd gzip
    reverse_proxy api:3000
}
Enter fullscreen mode Exit fullscreen mode

That gets you a valid certificate, HTTP-to-HTTPS redirect, HTTP/2, and correct X-Forwarded-* headers without naming any of those features. If your bottleneck is "I just need HTTPS in front of this container and I don't want to think about renewal again," Caddy is the one that handles issuance, renewal, and redirects with no certificate config at all.

The honest drawback: when you need something unusual — a specific buffering behaviour, an obscure header dance for a legacy client — you will find three Stack Overflow answers for Caddy and three hundred for nginx.

Why does my proxy return 502 after I rebuild the container?

This one is nginx-specific and it bites almost everyone running nginx inside Docker Compose. The config looks correct:

location /api/ {
    proxy_pass http://api:3000/;
}
Enter fullscreen mode Exit fullscreen mode

It works. Then you run docker compose up -d --build api, the container is recreated with a new IP, and every request starts returning:

502 Bad Gateway
Enter fullscreen mode Exit fullscreen mode

with this in the error log:

connect() failed (111: Connection refused) while connecting to upstream,
upstream: "http://172.18.0.4:3000/"
Enter fullscreen mode Exit fullscreen mode

Note the literal IP in the message. Nginx resolved api to an address when it loaded the config and cached it indefinitely. The container at 172.18.0.4 no longer exists. Restarting nginx fixes it, which is exactly why this gets misdiagnosed as a flaky app.

The dead end most people try first is adding a healthcheck or a depends_on ordering tweak. That does not help, because the problem is not startup ordering — it is a stale DNS answer. The fix is to force nginx to re-resolve by using a variable in proxy_pass together with Docker's embedded DNS server:

location /api/ {
    resolver 127.0.0.11 valid=10s ipv6=off;
    set $upstream_api api:3000;

    rewrite ^/api/(.*)$ /$1 break;
    proxy_pass http://$upstream_api;
}
Enter fullscreen mode Exit fullscreen mode

Two things to know about that snippet. 127.0.0.11 is Docker's built-in resolver inside a user-defined network, and valid=10s caps how long nginx trusts the answer. And the moment proxy_pass contains a variable, nginx stops doing its implicit URI rewrite — the trailing-slash trick in proxy_pass http://api:3000/ no longer strips the /api/ prefix, so you have to rewrite explicitly. Forgetting that turns the 502 into a 404, which feels like a regression.

Caddy and Traefik do not have this failure mode: Caddy resolves the upstream name when it dials, and Traefik learns the new container from the Docker provider. If your containers get recreated often and you are running nginx, assume stale upstream DNS before you suspect your application.

When is Traefik's dynamic routing worth the complexity?

Traefik's pitch is that routing config lives on the service, not in the proxy. A minimal Compose setup:

services:
  traefik:
    image: traefik:v3
    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

  api:
    image: example/api:latest
    labels:
      - traefik.enable=true
      - traefik.http.routers.api.rule=Host(`app.example.com`)
      - traefik.http.routers.api.entrypoints=websecure
      - traefik.http.routers.api.tls.certresolver=le
      - traefik.http.services.api.loadbalancer.server.port=3000
Enter fullscreen mode Exit fullscreen mode

Add a second service and it routes itself. That is the payoff, and it is real once you have more than a handful of services or you spin up per-branch preview containers.

The failure you will hit on day one is Traefik answering with a bare:

404 page not found
Enter fullscreen mode Exit fullscreen mode

while docker ps shows the app healthy. In my experience it is almost always one of four things: exposedByDefault=false is set but the service is missing traefik.enable=true; the service is not on the same Docker network as Traefik; loadbalancer.server.port is absent and the container exposes several ports; or the router references an entrypoint name that does not exist, in which case the router is simply never attached. Turn on the API and read the live view instead of guessing — --api.insecure=true on a local-only port, then check which routers Traefik actually built.

Traefik is the right choice when service topology changes faster than you want to edit proxy config; it is the wrong choice for one static app, where you are paying socket exposure and a labels DSL for automation you never use. And be clear-eyed about that mount: read-only access to the Docker socket still lets a compromised Traefik enumerate and influence your container environment, so treat it as a privileged component.

What about WebSockets, streaming, and timeouts?

A proxy that works for JSON requests can still break long-lived connections, and this is where the defaults diverge.

Nginx needs explicit opt-in for WebSocket upgrades and will otherwise return 400 or close the connection:

location /ws {
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 300s;
}
Enter fullscreen mode Exit fullscreen mode

Nginx also buffers proxied responses by default, which is what silently breaks server-sent events and token-by-token LLM output — the client sees nothing, then everything at once. For those routes you need proxy_buffering off; and usually X-Accel-Buffering: no from the app.

Caddy and Traefik pass upgrades through without configuration and do not buffer streaming responses the same way, which is one fewer production surprise. All three still have an idle-timeout default that will cut an idle WebSocket, so send heartbeats regardless of which you pick. Test one streaming endpoint through the proxy before you ship, because buffering bugs look like application bugs.

Which one fits which situation?

Situation Pick
One or two containers, one domain, no ops appetite Caddy
Need caching, rate limiting, complex rewrites, or a config your team already knows Nginx
Containers created and destroyed frequently; per-branch environments Traefik
Already on Kubernetes Neither as a side install — use an ingress controller (Traefik ships one)
Serving large static assets from disk Nginx

The decision is less about throughput than about which kind of maintenance you want to own. For a single small app, all three will saturate your upstream bandwidth long before the proxy is the bottleneck.

FAQ

Is Caddy slower than nginx?
For typical small-app workloads the difference is not what limits you — both will handle far more traffic than a single modest VPS app generates. Nginx retains an edge on serving large volumes of static files from disk, so if that is your main workload, measure it rather than assuming parity.

Do I need Traefik if I use Docker Compose?
No. Compose with a fixed set of services works fine behind Caddy or nginx. Traefik earns its place when containers change identity frequently and you want routing to follow them without editing proxy config.

Why does nginx work after a restart but 502 later?
Nginx resolves upstream hostnames once when it loads its configuration and caches the result. If your upstream container is recreated with a new IP, nginx keeps dialing the old address until you reload it. Use a resolver directive with a variable in proxy_pass so the name is re-resolved.

Bottom line

For a single small app on one server, start with Caddy — automatic HTTPS removes the most common operational chore, and the config fits in a tweet. Reach for nginx when you need precise control over caching, buffering, and rewrites, or when your team already debugs nginx fluently, and accept that you own certificate renewal and upstream DNS behaviour. Choose Traefik when containers are ephemeral enough that hand-maintained routing becomes the bottleneck, and go in knowing you are granting it Docker socket access. Whichever you pick, test one WebSocket or streaming endpoint through it before you call the setup done.

Related reading

Top comments (0)