I have been writing nginx configs since 2013. I am good at them. I have strong opinions about the order of try_files directives. And I still just moved my entire homelab ingress off nginx onto Caddy, and I do not miss it.

What broke the camel’s back

It was not nginx itself. nginx is fine. What pushed me was the ceremony around it. For my dozen or so internal services I had:

  • An nginx site file per service.
  • A certbot cronjob to renew Let’s Encrypt certs.
  • A shell script that knew which sites to reload on cert rotation.
  • A separate config for an internal CA because some services only listened over mTLS.
  • A half-finished Ansible role tying it all together.

When I added a new service (say, a local Grafana), the process was: edit Ansible, run the playbook, certbot handshakes out to Let’s Encrypt via DNS-01 because I did not want to open port 80 for it, restart nginx, hope for the best. It worked. It was just annoying.

The Caddyfile for everything

Caddy’s pitch is a short Caddyfile where TLS happens by default. After the switch, my entire homelab ingress config was 42 lines, including comments. A snippet:

{
    email ops@example.net
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

grafana.home.example.net {
    reverse_proxy 192.168.30.22:3000
}

immich.home.example.net {
    reverse_proxy 192.168.30.22:2283
    request_body {
        max_size 20GB
    }
}

nzb.home.example.net {
    reverse_proxy 192.168.30.25:6789
    basicauth {
        merce $2a$14$... bcrypt hash ...
    }
}

TLS from Let’s Encrypt via DNS-01, automatic renewal, no cron, no hooks. When I add a service I add three lines and systemctl reload caddy.

What I had to give up

  • Fine-grained caching. nginx’s proxy_cache_path and friends are genuinely great for serving heavy static content. I did not need them in my homelab, but if you run a news site, stick with nginx.
  • Request rewriting at the level I was used to. Caddy can do rewrites and matchers, and they are fine. But if you are ten years deep into nginx rewrite-fu, there is an adjustment period.
  • Module ecosystem breadth. Caddy’s plugin system works, but some things that are one-liners in nginx are compile-your-own-Caddy in Caddy-land.

How the DNS-01 workflow changed

This is the thing I appreciated most. On nginx I had:

# /etc/cron.d/certbot
0 3 * * * root /usr/bin/certbot renew --deploy-hook '/etc/nginx/reload-affected'

And the reload-affected script was a bash thing that diffed cert timestamps against known sites. Fragile, but worked.

With Caddy, DNS-01 is a config directive. I set CLOUDFLARE_API_TOKEN in an environment file and Caddy handles everything, including staggered renewals, staples, and reload. My cron is empty on this host.

For an internal CA, Caddy supports tls internal which uses a built-in self-signed CA. I decided not to go that route because I liked having real certs issued from Let’s Encrypt for internal services (DNS-01 means I do not need any inbound port open). But if I had, the syntax is:

nas.home.example.net {
    tls internal
    reverse_proxy 192.168.30.12:5000
}

Performance

For my traffic (basically nothing) there is no perceptible difference. I ran wrk against both configs serving the same backend:

wrk -t4 -c100 -d30s https://grafana.home.example.net/api/health
# nginx:  42310 requests, p99 12.4 ms
# caddy:  40187 requests, p99 14.1 ms

Close enough that the 5% is probably noise. For heavy static workloads nginx would pull ahead, but that is not my case.

The one real surprise

Caddy’s default behavior is to redirect HTTP to HTTPS. I like that. But I had one service that was supposed to answer on port 80 for a hardware device that could not do TLS. I had to do a little dance:

:80 {
    @iot host iot-sensor.home.example.net
    handle @iot {
        reverse_proxy 192.168.30.30:8080
    }
    handle {
        redir https://{host}{uri} permanent
    }
}

Not obvious from the docs. Once I figured it out, fine.

Reflection

The switch took two weekends, including moving my mTLS-only internal services to a separate ingress. I kept nginx on one box that does serve heavy static content for a hobby site because it is faster at that. For the rest, Caddy is the right tool because its defaults are what I wanted.

If you are nginx-fluent and your configs are already boring, there is no reason to switch. If you are doing the same things in twenty config files that amount to “reverse proxy with a cert”, try Caddy for a weekend. Related: see my post on DNS-01 challenges with a split-horizon DNS.