Caddy, an HTTP server with TLS certificates built in
Caddy is an HTTP server written in Go that obtains and renews TLS certificates on its own. Two lines of configuration are enough to get working HTTPS with background renewal. The current release is 2.11.4, the license is single and permissive, and the project funds itself through sponsorship rather than by selling locked features.
What Caddy does differently from nginx
In a typical nginx setup, the certificate is a separate concern from the server. Certbot or acme.sh talks to the certificate authority, writes files somewhere in a system directory, and then runs a hook telling the server to reload its configuration. Renewal is handled by a systemd timer or a cron entry. That is three independent pieces, each capable of failing on its own: certbot can fetch a certificate but fail to reload the server, the timer can stop working after a system upgrade, and the server can run for three weeks with an expired file because nobody read the logs.
Caddy moves all of that inside the server process. Certificate management is handled by CertMagic, a library extracted from the same project. When Caddy loads a configuration and sees a public domain name in it, it opens an ACME session itself, completes the challenge itself, writes the certificate to the configured storage itself, and watches the renewal deadline in a loop running inside the same process. There is no cron, no reload hook, and no question of whether the timer actually started.
That convenience has a concrete price and it is better known up front. The server process now holds the ACME account key and the certificates' private keys, so it needs a writable and durable data directory. The server needs network access to the certificate authority, which in air-gapped environments requires a separate answer. If certificates arrive from elsewhere anyway, say from a corporate PKI or from TLS termination on a cloud load balancer, Caddy's automation starts getting in the way and has to be switched off explicitly.
The second difference is about defaults. Nginx listens on port 80 unencrypted by default. Caddy serves everything over HTTPS by default, redirects traffic from port 80 to 443, and even for the localhost address it issues a certificate from its own locally trusted certificate authority, installing that root into the system trust store. For some teams that saves a week of work, for others it is a surprise on the first run inside a container.
Version, license and funding model
The current release is 2.11.4. The v2.11.4 tag in the repository carries the date of 1 June 2026 according to the Go module proxy, while the GitHub release itself was published on 3 June 2026. That two-day gap is the normal distance between tagging and building artifacts, but if you automate version checks, be aware that two sources report two dates.
The caddyserver/caddy repository was created in January 2015, has roughly 75.1 thousand stars, 4897 forks and 272 open issues. The last change on the main branch is dated 19 August 2026 and the repository is not archived. For a piece of infrastructure software that is a healthy pace.
The license is a clean case, which during a dependency audit happens less often than it should. I checked three places and all of them say the same thing. The repository root holds a LICENSE file with the full text of the Apache License 2.0. The GitHub API reports the identifier apache-2.0 for this repository. The module archive downloaded from proxy.golang.org for version v2.11.4 contains a LICENSE file of 11358 bytes, the same Apache text. I additionally checked the thing that is easy to forget: the prebuilt binary from the release page. The caddy_2.11.4_linux_amd64.tar.gz archive contains exactly three entries, LICENSE, README.md and the caddy executable. There is no gap of the kind seen in some projects where the repository has a license but the distributed package does not. Releases are signed, with .sig and .pem files next to every artifact plus a component inventory in SBOM format.
The money comes from sponsorship, and that is both the project's strength and its weakness. The sponsorship page states outright that Caddy has no paywalled features, and that every tier buys support and priority rather than access to code. Pricing is quoted monthly: Indie 25 dollars, Indie Pro 50 dollars, Startup 99 dollars, Startup Pro 249 dollars, Business 999 dollars, Business+ 2999 dollars. There is also a tier labelled Enterprise+ for which I found no stated amount, so I will not invent one. The same page compares itself against competitors by quoting prices for Traefik and NGINX Plus, but those are Caddy's own marketing numbers and I did not verify them with those vendors, so treat them as somebody else's claim.
The risk in this model is real. Development depends on a handful of maintainers and on sponsors continuing to pay. You do not buy a support contract together with the software, you buy it separately, and until you do, you have no response deadline for an incident. The Apache 2.0 license grants patent protection and full freedom of commercial use, so legally you are safe, but law does not substitute for maintenance.
The Caddyfile, or configuration in a few lines
Caddy natively reads JSON, and the Caddyfile is a config adapter translating a friendly format into that JSON. The shortest sensible production configuration is two lines.
example.com
reverse_proxy localhost:8080That is all. The first line is the address of the served site and simultaneously the signal to Caddy that it should obtain a certificate for example.com. The second forwards traffic to an application listening locally. The HTTP to HTTPS redirect, the X-Forwarded-* headers, upstream health checking and certificate renewal all work without further entries.
A realistic configuration grows, but it does not explode.
example.com {
encode zstd gzip
handle_path /api/* {
reverse_proxy localhost:3000 localhost:3001 {
lb_policy least_conn
health_uri /healthz
}
}
handle {
root * /srv/www
try_files {path} /index.html
file_server
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
}
log {
output file /var/log/caddy/example.log
format json
}
}One thing in that file breaks the intuition of anyone arriving from nginx. The order of directives in the file does not matter. Caddy sorts them according to its own fixed list, so encode will always run at a specific point in the handler chain regardless of where you wrote it. If you need literal ordering, use the route directive, which treats its contents as one unreordered unit. The global order can also be overridden with the order option in the global block. The handle blocks are mutually exclusive, meaning only the first matching one runs, and that is how they differ from route.
Three commands cover daily work with the file. caddy fmt --overwrite tidies indentation, caddy validate --config Caddyfile checks whether the configuration can be loaded, and caddy adapt --config Caddyfile --pretty prints the resulting JSON. The last one is the best learning tool, because it shows what the Caddyfile actually hides. Note that adaptation is a weaker check than validation: a file can convert to JSON correctly and still fail on load, because the referenced certificate file does not exist.
How automatic HTTPS works
The automation activates when Caddy knows a hostname or IP address it is meant to serve. It learns that from several sources: a site address in the Caddyfile, a top-level host matcher in the JSON configuration, the --domain or --from command line flags, and finally the automate certificate loader.
Equally important is what deactivates it. An http:// prefix on the site address, no name at all in the configuration, listening exclusively on the HTTP port, manually loaded certificates, and explicit disabling via the global option. The auto_https option accepts four values: off, disable_redirects, ignore_loaded_certs and disable_certs. Telling them apart can be decisive, because behind a load balancer you usually want to disable only the redirect, not the whole machinery.
For public DNS names the certificate comes from a public ACME authority, by default Let's Encrypt or ZeroSSL, with automatic fallback to the second when the first fails. That has a practical consequence the documentation warns about directly: Let's Encrypt may email you about an upcoming expiry even though Caddy renewed the certificate at ZeroSSL. For localhost, IP addresses and internal names, Caddy issues a certificate from its own authority and tries to install that root into the system trust store, for which it may ask for a password. Names ending in .ts.net are delegated to a locally running Tailscale instance instead of ACME.
{
email admin@example.com
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
key_type p256
renewal_window_ratio 0.33
storage file_system /var/lib/caddy
admin localhost:2019
servers {
protocols h1 h2 h3
trusted_proxies static private_ranges
trusted_proxies_strict
}
}The storage option deserves attention because it is responsible for the most common production failure. The data directory has to be writable and durable, otherwise every container restart means a new ACME account and a new certificate request, and Let's Encrypt rate limits are strict. The same option solves the cluster problem: several Caddy instances pointing at the same storage coordinate certificate issuance among themselves, sharing keys and OCSP staples, with no additional negotiation. Storage modules backed by external databases exist, including ones for PostgreSQL and Redis, but those are plugins outside the standard release.
Wildcard certificates work only when the asterisk sits in the left-most label of the name. *.example.com qualifies, while sub.*.example.com or foo*.example.com do not, and that is a restriction of the public key infrastructure rather than of Caddy. The DNS challenge, needed for wildcard certificates and for servers without a public port 80, requires a module for your specific DNS provider, and the standard binary ships with none.
On-demand TLS and customer domains
This is the feature for which many companies pick Caddy over anything else. The normal mode fetches certificates at configuration load, so every name must be known up front. On-demand TLS reverses that order: the certificate is created during the first TLS handshake for a name the server does not yet know. The handshake is held for a few seconds, the certificate lands in a cache, and subsequent connections are fast.
The use case is obvious for any product with customer-owned domains. The customer points a CNAME record at your infrastructure and the certificate appears on its own, with no configuration entry and no server reload. Tunnel services such as Portless or Cloudflare solve a similar problem, except there the certificate is part of the provider's offering and you pay for it by subscription.
The mechanism has to be restricted, otherwise anyone pointing an arbitrary domain at your IP address forces the server to attempt an issuance and exhausts the authority's limits. Caddy demands this explicitly and refuses to run on-demand without restrictions. The primary restriction is the ask endpoint.
{
on_demand_tls {
ask http://localhost:9000/tls-permission
}
}
https:// {
tls {
on_demand
}
reverse_proxy localhost:8080
}The contract for that endpoint is simple and well documented. Caddy sends an HTTP request to the given address, appending a ?domain= query string with the domain name from the handshake. A 2xx status code means permission to issue the certificate, anything else aborts the handshake with an error. The endpoint has to answer quickly, because it blocks the client connection, so querying a database on every request without a cache is a bad idea. Restrictions are global and cannot be set per site.
Admin API, xcaddy and the limits of the ecosystem
Caddy exposes an administration interface on localhost:2019. The address can be changed with the admin option or the CADDY_ADMIN environment variable. Through that interface you replace the whole configuration or fragments of it, without restarting the process and without dropping connections.
# inspect the active configuration as JSON
curl -s localhost:2019/config/ | jq
# convert the Caddyfile to JSON and push it into the running process
caddy adapt --config Caddyfile --pretty > caddy.json
curl -X POST localhost:2019/load \
-H "Content-Type: application/json" \
--data-binary @caddy.json
# the same operation as a command line shortcut
caddy reload --config Caddyfile
# start with the last saved configuration after a machine restart
caddy run --resume
# what exactly is compiled into this binary
caddy build-info
# a custom binary with the Cloudflare DNS module
xcaddy build --with github.com/caddy-dns/cloudflareThat last command leads to the project's most serious limitation. Caddy does not load plugins dynamically. Every module is compiled into the binary, so adding DNS challenge support, database-backed storage or custom authentication means building your own executable with xcaddy and installing Go on the build machine. Upgrading Caddy then means rebuilding the image rather than running apt upgrade. In exchange you get a single file with no system dependencies that can be copied anywhere.
The size of the ecosystem is the second limitation and there is no point dressing it up. The official Caddy plugin registry lists 319 registered packages. Nginx and Apache have two decades of modules, blog posts and ready configuration snippets to copy. The numbers from Stack Overflow show it: the caddy tag has 369 questions, traefik has 2266, nginx has 54493 and apache has 91489. When you need something ordinary, Caddy's documentation is better than nginx's and entirely sufficient. When you need something odd, you sit on the project forum and wait instead of finding the answer in two minutes. That is the genuine cost of choosing the smaller tool.
It is worth putting this next to platforms that remove the server layer from your plate anyway. Railway, Fly.io and Coolify terminate TLS for your applications themselves, so configuring Caddy inside the container there often duplicates work.
Caddy versus the alternatives
| Criterion | Caddy | nginx plus certbot | Traefik |
|---|---|---|---|
| TLS certificates | inside the server process, no cron | separate tool, timer and reload hook | inside the server process, no cron |
| Configuration format | Caddyfile or JSON | its own directive language | YAML, TOML or container labels |
| Configuration reload | HTTP API on port 2019 | signal to the process | file watching and provider APIs |
| Plugins | compiled in via xcaddy, 319 in the registry | dynamic and compiled modules, two decades of work | compiled in, smaller set |
| License | Apache 2.0, no paid features | two-clause BSD, commercial Plus variant | MIT, commercial Hub variant |
| Stack Overflow questions | 369 | 54493 | 2266 |
| Paid support | sponsorship from 25 dollars per month | an NGINX Plus contract | a Traefik Hub contract |
The third column deserves an extra sentence, because the difference from Traefik is not in certificates, which both handle the same way, but in where routes come from: Caddy reads them from a file you write yourself, while Traefik discovers services in Docker or Kubernetes and builds routes from their labels. With a static set of services the file wins on readability; in a changing container environment discovery saves you a templating layer. Two Traefik drawbacks are worth knowing though: its certificate store is a plain acme.json file, so with multiple replicas automatic HTTPS will not work without a paid add-on, and the paid product was renamed from Enterprise to Hub, which older guides still get wrong.
Common mistakes
Ephemeral storage in a container is mistake number one. If Caddy's data directory sits in the writable layer of the image, every restart means a new account key and a new certificate request. After a few deployments a day you hit the certificate authority's rate limit and end up with a site without a certificate for several hours. Mount a volume and point at it with storage file_system.
A closed port 80, on the assumption that if you only serve HTTPS then port 80 is redundant. The HTTP-01 challenge needs port 80 reachable from the public side. The alternatives are the TLS-ALPN-01 challenge on port 443 or the DNS challenge, but the latter needs a compiled-in DNS provider module.
Testing against the production certificate authority. While debugging a configuration, point acme_ca at the Let's Encrypt staging directory. Certificates from there are not trusted by browsers, but the limits are far looser and you will not lock yourself out of your own domain for a week.
Expecting directives to run in the order written in the file. Caddy sorts them itself. If your logic depends on ordering, wrap it in route.
Placing Caddy behind another proxy without configuring trust. Without trusted_proxies the client address in logs will be the proxy's address, and the X-Forwarded-For header can be spoofed. If your proxy appends addresses on the right, enable trusted_proxies_strict as well.
Omitting the http:// prefix where you genuinely want plain HTTP, for instance on an internal health endpoint. Without the prefix Caddy treats the name as a certificate candidate and keeps trying to obtain one, filling the logs with ACME errors.
FAQ
Is Caddy suitable for production traffic?
Yes, and it has been for years, including in SaaS products serving tens of thousands of customer domains through on-demand TLS. The bottleneck usually turns out to be the application behind the proxy rather than the server itself. If you measure microseconds on static files, nginx still wins synthetic benchmarks, but that difference is rarely what constrains the system.
Can I use Caddy in a company without paying?
Yes. The Apache 2.0 license covers the entire codebase and every feature, with no stripped-down variant. Sponsorship buys support, issue priority and possibly custom patches, not access to functionality. Without a sponsorship, however, you have no contractual response time for an incident.
What happens when the certificate authority is unavailable?
Caddy tries the next configured issuer, by default switching between Let's Encrypt and ZeroSSL. Because renewal starts well before expiry, governed by the renewal_window_ratio setting, a few hours of downtime at one authority does not turn into downtime for your site.
Will my existing nginx configuration port over one to one?
No. There is no sensible converter, and the two servers' models differ enough that a line-by-line translation produces strange results. The practical path is writing a Caddyfile from scratch for one site, comparing behavior, and only then migrating the rest.
How do I add a plugin without compiling on the production server?
Build the image in your continuous integration pipeline with xcaddy build --with <module> and deploy the finished binary or container image. To check what actually ended up in the file, run caddy build-info.
Where do I get trustworthy version and license information?
You will find the version on the releases page in the repository and the full option reference at caddyserver.com/docs. Sponsorship terms and support pricing are described on the project's sponsorship page.