Nginx is the web server most of the internet runs on, and it will do anything you ask it to. Caddy does a narrower set of things and does one of them — getting and renewing TLS certificates — without you configuring anything at all.

The short verdict. If you are putting a handful of services behind HTTPS on your own server, use Caddy. The config is a fifth the size, certificates are obtained and renewed automatically with nothing to schedule, and there is no certbot to break at renewal time eighteen months from now.

Keep nginx when you already have working nginx config you understand, when you need something only nginx does (fine-grained caching, complex rewrite chains, mail proxying, third-party modules), when your team’s existing knowledge is nginx, or when you are following documentation — nginx is what nearly every tutorial assumes.

Side by side

nginxCaddy
TLS certificatescertbot, plus a renewal timer you set upAutomatic, built in, nothing to configure
Config size for one HTTPS site~20 lines plus a certbot run2 lines
Config formatIts own syntaxCaddyfile, with JSON underneath
Reload without dropping connectionsYesYes
HTTP/3Supported, needs enablingOn by default
Adding functionalityModules, often needing a rebuildPlugins, via a rebuild with xcaddy
Written inCGo — one static binary
Raw throughputHigher at extreme volumesAmple below that
Documentation you will find onlineEnormousGood official docs, far fewer blog posts

The throughput row is worth being honest about. Nginx wins benchmarks, and for the overwhelming majority of servers that difference is irrelevant — the bottleneck is the application behind the proxy, not the proxy. If you are at the scale where it matters, you already know.

Installing

# Debian and Ubuntu, from Caddy's own repository
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
# then add the repo per caddyserver.com/docs/install, and:
sudo apt install caddy

# Fedora, RHEL and friends
sudo dnf install caddy

# Arch
sudo pacman -S caddy

The package installs a systemd unit and a config at /etc/caddy/Caddyfile. Because Caddy is a single static Go binary, dropping the release binary somewhere and writing your own unit also works fine — useful on a distribution with no package.

The naming trap: plugins are not installed at runtime in the normal sense. Caddy is compiled with the plugins it has, so adding one means building a new binary with xcaddy, or using the official download page to generate a build with your chosen plugins included. There are caddy add-package and caddy upgrade commands that automate this by fetching a fresh build, but they are still marked experimental — for a production box, build deliberately rather than upgrading in place.

The same site, both ways

A reverse proxy for one application, with HTTPS. In nginx, after running certbot:

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

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    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;
    }
}

In Caddy, the entire equivalent:

app.example.com {
    reverse_proxy 127.0.0.1:8080
}

That is not a simplified example. The certificate is obtained on first request and renewed thereafter, HTTP is redirected to HTTPS, and the forwarded headers are set — all because those are the defaults rather than things you asked for.

What automatic HTTPS actually does

Caddy serves everything over HTTPS by default, and it handles two cases differently.

For a public DNS name, it requests a certificate from a public ACME certificate authority, keeps it renewed, and redirects port 80 to port 443. You configure nothing; the requirement is simply that the name resolves to the machine and that ports 80 and 443 are reachable.

For a local or internal namelocalhost, anything under .localhost, .local, .internal or .home.arpa, and bare IP addresses — it cannot use a public CA, so it generates its own local certificate authority and signs a certificate with that. On first use it tries to install its root into the system trust store, which is why running Caddy locally sometimes prompts for a password. The explicit form is tls internal.

Automatic HTTPS switches itself off when there are no hostnames to work with, when you disable it, or when the server is listening only on the HTTP port. That last one is the usual accidental cause — a site block written as http://app.example.com gets no certificate, by design.

What that removes is worth naming precisely, because it is the step that actually breaks elsewhere. With certbot, the renewal and the web server are separate programs: certbot writes new files and something else has to reload nginx to pick them up. When that something else is missing, the renewal keeps succeeding, the logs stay clean, and the expired certificate keeps being served. Caddy renews inside its own process and swaps the certificate in memory, so there is no reload to forget. The verification is the same either way — compare the serial on the wire with the serial on disk — and The Life of a Certificate walks the six stages behind all of it, including the ones Caddy is doing silently.

Translating what you already know

nginxCaddy
server { server_name x; }x { }
proxy_pass http://...reverse_proxy ...
root /var/www; index index.html;root * /var/www then file_server
location /api { }handle /api/* { }
return 301 https://...Automatic — or redir
gzip on;encode gzip zstd
auth_basicbasic_auth
nginx -tcaddy validate
nginx -s reloadcaddy reload or systemctl reload caddy

The conceptual difference to watch for: nginx picks one location block by a precedence algorithm that catches people out for years. Caddy’s handle blocks are mutually exclusive and matched in order, which is duller and much easier to predict.

If you have a large existing nginx config, Caddy’s nginx config adapter can convert it — treat the output as a first draft to read rather than something to deploy.

Where nginx is still the right answer

  • Serious content caching. proxy_cache and its surrounding controls have no equally mature equivalent.
  • Complicated rewrite and rate-limiting logic that already exists and already works. Rewriting it to prove a point is not progress.
  • Anything mail-related. Nginx can proxy IMAP, POP3 and SMTP; Caddy does not try.
  • You are following someone else’s runbook. Most self-hosting documentation assumes nginx, and translating while also debugging doubles the difficulty.
  • Certificates come from somewhere else entirely — a corporate CA, a load balancer, an existing PKI. Caddy handles this fine, but its main advantage disappears.

Worth knowing for the record: Caddy is Apache-2.0 licensed open source, and is now a project of ZeroSSL, which is part of HID Global. That has not changed the licence or the free binaries, but it is the kind of thing to be aware of before standardising a company on it.

Configuration worth having

# /etc/caddy/Caddyfile
{
    email you@example.com          # for expiry notices from the CA
}

app.example.com {
    reverse_proxy 127.0.0.1:8080
    encode gzip zstd
    log {
        output file /var/log/caddy/app.log
    }
}

files.example.com {
    root * /srv/files
    file_server browse
    basic_auth {
        alice $2a$14$...      # from: caddy hash-password
    }
}

# several apps on one name
example.com {
    handle /api/* {
        reverse_proxy 127.0.0.1:3000
    }
    handle {
        root * /srv/site
        file_server
    }
}
caddy fmt --overwrite /etc/caddy/Caddyfile   # normalise formatting
caddy validate --config /etc/caddy/Caddyfile # check before reloading
sudo systemctl reload caddy                  # no dropped connections

Set that email — but do not treat it as a safety net. Let’s Encrypt switched off expiry notification emails on 4 June 2025 and deleted the stored addresses from its production database, so the warning that older guides promise no longer arrives. The address is still worth setting, because it is your account’s contact record and other certificate authorities still use it; the actual monitoring has to be yours.

Common problems

SymptomCauseFix
No certificate issuedPort 80 blocked, or DNS not pointing here yetOpen 80 and 443; check the name resolves
Browser warns about a self-signed certificateThe name is local, or it is an IP addressExpected — use a real name, or trust the local root
Site serves over HTTP onlySite block written as http://Drop the scheme
Certificate requests being rate-limitedRepeated failed attempts against the live CAUse the staging CA while testing
Application sees every request as coming from 127.0.0.1Not reading the forwarded headersConfigure the app to trust the proxy
A plugin is missingNot compiled into this binaryRebuild with xcaddy
Config edit had no effectNever reloadedcaddy validate, then reload

Quick reference

caddy run --config Caddyfile      # foreground, for debugging
caddy validate --config Caddyfile # syntax and module check
caddy fmt --overwrite Caddyfile   # tidy it
caddy reload --config Caddyfile   # apply with no downtime
caddy hash-password               # for basic_auth
caddy file-server --listen :8080  # serve this directory, right now
xcaddy build --with github.com/caddy-dns/cloudflare

Related reading