Story 3 · Caddy
The Doorman Who Fetches His Own Keys
“A server that needs you to renew its certificate by hand is a server with a countdown timer you forgot to read.”
12 min read · by Syed Jafer K
1. The Morning the Padlock Turned Red
Karthik ran four little services from a rented VPS: a notes app, a photo gallery, a wiki for his cricket club, and a bell that rang his phone when something broke. Each one lived on its own port. Each one had its own subdomain. And in front of all of them stood Nginx and a tool called Certbot.
One Monday his club captain messaged: "Wiki is showing a scary red warning."
Karthik opened the site. The browser said "Your connection is not private. Certificate expired."
He logged in, found the Certbot cron job, and discovered that it had been failing for weeks. An Nginx config change had broken the challenge path. Nobody had told him, because (as the previous chapters taught) nobody had set up a bell for that.
He fixed it in twenty minutes and spent the next twenty wondering why renewing a certificate was his job at all.
His friend Divya, who runs infrastructure for a small startup, listened to the whole story and said:
"You don't need a better cron job. You need a web server that treats HTTPS as its own responsibility. Use Caddy."
"Caddy? The golf helper?"
"The one who carries your bag and fetches the right club without being asked."
2. The Week the Certificate Authority Left
The red padlock had a second, bigger lesson waiting.
Three weeks before, in October 2026, Karthik had read a headline that did not seem to concern him: eMudhra, an Indian certificate authority (CA), had its TLS signing keys blocked by browsers. A researcher reported that eight of its intermediate CA private keys were so weak they could be recovered in under a second. Chrome and Firefox blocked the six that issued TLS certificates within hours. A few days later eMudhra announced it was leaving the global SSL/TLS certificate business altogether. The company said it had revoked the affected certificates quickly and had not used those keys to issue certificates to websites.
"Not my problem," he had thought. "I use Let's Encrypt."
Then he checked the cricket club's old billing site, which a former treasurer had set up years earlier. It ran on a paid certificate, bought for three years, from a CA that was now leaving. Its certificate was valid on paper. Its issuer was on its way out of the browsers' good books. Every year of that purchase was a promise from a company that had just broken it.
Divya's reaction was not sympathy. It was a question.
"How long did it take you to replace it?"
"I haven't. The vendor will send instructions. Eventually."
"And that is the real lesson. A CA can lose your trust overnight. The only defence is that replacing a certificate must be cheap, automatic and boring. If swapping your CA is a project, you are one headline away from an outage."
Caddy is that defence. Its certificates come from the ACME protocol, which any compliant CA speaks. Certificates last 90 days and are renewed by software, so no certificate is old enough to be stranded for long. If one authority disappears, you change one line.
Keep the two stories apart. The eMudhra incident was about weak CA keys, not an expired certificate. Caddy cannot make a bad CA safe, and it does not check revocation for you. What it gives you is agility: short-lived certificates, automatic renewal, and an easy move to another CA.
3. What Caddy Is
Caddy is a web server and reverse proxy written in Go, shipped as a single static binary. Three ideas make it different:
- Automatic HTTPS. Give Caddy a real domain name and it obtains a certificate from Let's Encrypt (or ZeroSSL), installs it, redirects HTTP to HTTPS, and renews it before it expires. No Certbot, no cron, no
ssl_certificatelines. - Small, readable config. A working reverse proxy is three lines.
- Safe defaults. Modern TLS settings, HTTP/2 and HTTP/3 out of the box.
It is open source (Apache 2.0). It is the "doorman" of this book: every other story sits behind it.
4. Before You Start: What Caddy Needs From You
Automatic does not mean magic. For the certificate to be issued, three things must be true:
- A domain name whose DNS
A(andAAAA, if you use IPv6) record points to your server's public IP. - Ports 80 and 443 reachable from the internet. Port 80 is used for the validation challenge; 443 serves your sites. Open them in the cloud firewall and on the machine (
ufw allow 80,443/tcp). - Nothing else listening on 80/443. Stop Nginx or Apache first, or Caddy will fail to bind.
Also allow UDP 443 if you want HTTP/3.
Tip: while experimenting, use Let's Encrypt's staging CA (shown in section 9) so you do not hit rate limits with mistakes.
5. Installing Caddy
On Debian or Ubuntu, use the official package repository (check the Caddy install page for the current commands). The package registers a systemd service that runs as a caddy user and reads /etc/caddy/Caddyfile.
sudo apt install caddy
systemctl status caddy
If you prefer containers, Docker Compose works well. The important part is the volumes: Caddy stores its certificates and account keys in /data, and losing that folder means re-issuing everything.
# docker-compose.yml
services:
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
6. The First Caddyfile: One Line, One Site
Karthik started with the smallest config he could imagine. The wiki already ran on localhost:3000.
wiki.karthik.example {
reverse_proxy localhost:3000
}
He reloaded:
sudo systemctl reload caddy
Then he watched the logs:
journalctl -u caddy -f
Within seconds the log showed Caddy talking to Let's Encrypt, solving the challenge, and storing a certificate. He opened https://wiki.karthik.example. Green padlock. He had written no certificate paths, no redirect rule, no renewal job.
He tried http://wiki.karthik.example. It redirected to HTTPS by itself.
"That's it?" he asked Divya.
"That's it. The site address is the instruction: a real hostname means 'be public, get a certificate'."
7. Many Services, One Doorman
Next he moved the other three services behind the same Caddy. The whole file:
notes.karthik.example {
reverse_proxy localhost:5000
}
photos.karthik.example {
reverse_proxy localhost:2342
}
wiki.karthik.example {
reverse_proxy localhost:3000
}
ntfy.karthik.example {
reverse_proxy localhost:8080
}
Each block is a site. Each gets its own certificate. Only ports 80 and 443 face the internet now; the apps themselves can listen on 127.0.0.1 only, which is safer.
When services run in Docker on the same Compose network as Caddy, use the container name instead of localhost:
photos.karthik.example {
reverse_proxy photoprism:2342
}
6.1 Validate before reloading
A typo should never take four sites down. Always check first:
caddy validate --config /etc/caddy/Caddyfile
caddy fmt --overwrite /etc/caddy/Caddyfile
sudo systemctl reload caddy
reload is graceful: Caddy applies the new config without dropping live connections. If the new config is invalid, it keeps running the old one.
8. Serving Files, Compressing and Hiding Secrets
Caddy is also a plain file server. Karthik's cricket club had a static fixtures page:
club.karthik.example {
root * /var/www/club
encode gzip zstd
file_server
}
root * /var/www/clubsets the folder for all paths.encode gzip zstdcompresses responses.file_serverserves the files. Addbrowseif you want a directory listing.
For a single-page app that handles its own routes:
app.karthik.example {
root * /var/www/app
encode gzip
try_files {path} /index.html
file_server
}
7.1 Matching paths
A matcher narrows a directive to some requests. Here /api/* goes to a backend and everything else is static:
site.karthik.example {
encode gzip
handle /api/* {
reverse_proxy localhost:9000
}
handle {
root * /var/www/site
file_server
}
}
handle blocks are mutually exclusive, which makes the intent easy to read. To strip a prefix before proxying, use handle_path /api/* instead.
9. Locking the Door: Basic Auth and Security Headers
Some of Karthik's tools had no login of their own. Caddy can stand guard.
First create a password hash (never store the plain password):
caddy hash-password
# Enter and confirm the password; copy the $2a$14$... output
tools.karthik.example {
basic_auth {
karthik $2a$14$REPLACE_WITH_YOUR_HASH
}
reverse_proxy localhost:8888
}
(In Caddy 2.8 and later the directive is spelled basic_auth; older versions used basicauth.)
He also added a small snippet of security headers and reused it. A snippet is a named block you import:
(secure) {
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
}
wiki.karthik.example {
import secure
reverse_proxy localhost:3000
}
notes.karthik.example {
import secure
reverse_proxy localhost:5000
}
The -Server line removes the Server header. Only enable HSTS once you are sure every subdomain you serve works over HTTPS.
10. When Certificates Go Wrong
Divya had a short troubleshooting list she gave him.
| Symptom | Likely cause |
|---|---|
| Log says "challenge failed" or "connection refused" | DNS does not point to this server yet, or port 80/443 is blocked by a firewall |
| "address already in use" on start | Nginx/Apache is still running on 80/443 |
| Many failures in a row, then "too many requests" | Let's Encrypt rate limit. Wait, or test with staging |
Works on http, not https, after a restart |
/data volume was lost, so Caddy had to re-issue |
To test without burning limits, add a global block at the top of the Caddyfile:
{
email you@karthik.example
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
Staging certificates show a browser warning, which is expected. Remove the acme_ca line when everything works. The email is used for expiry notices and account recovery.
10.1 Changing your authority in one line
This is the answer to the eMudhra week. By default Caddy talks to Let's Encrypt and falls back to ZeroSSL. To pin a different ACME-compatible CA, name it in the global block:
{
acme_ca https://acme-v02.api.letsencrypt.org/directory
}
Or set the issuer for a single site:
wiki.karthik.example {
tls {
issuer acme {
dir https://acme.zerossl.com/v2/DV90
}
}
reverse_proxy localhost:3000
}
(ZeroSSL needs an account; check its docs for the external account binding details.) Reload, and the next issuance comes from the new CA. No purchase, no emailed bundle, no manual install.
10.2 Auditing the certificates you did not automate
Karthik listed every certificate he served and who issued it:
echo | openssl s_client -connect club.karthik.example:443 -servername club.karthik.example 2>/dev/null \
| openssl x509 -noout -issuer -enddate
Anything issued by a CA you are not sure about, or bought for a long term by hand, goes on the replace-first list. Moving that site behind Caddy turned it into a three-line change.
11. Sites Without a Public Domain
Not every service needs the open internet.
On your laptop, a site called localhost gets a certificate from Caddy's own local CA:
localhost {
reverse_proxy localhost:3000
}
The first time, Caddy may ask to install its root certificate into your trust store so the browser accepts it (caddy trust).
At home, behind a router with no open ports, public validation fails. There are two honest options:
- Use the DNS challenge, where Caddy proves control of the domain by creating a DNS record via your DNS provider's API. This needs a Caddy build that includes your provider's plugin (see the next section).
- Use
tls internaland trust Caddy's local CA on your own devices:
nas.home.karthik.example {
tls internal
reverse_proxy 192.168.1.30:5000
}
12. Plugins and Wildcards
Standard Caddy is the one binary from the download page. To add a DNS provider (for wildcard certificates such as *.karthik.example), build a custom binary with xcaddy:
xcaddy build --with github.com/caddy-dns/cloudflare
Then the config looks like this:
*.karthik.example {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@wiki host wiki.karthik.example
handle @wiki {
reverse_proxy localhost:3000
}
@notes host notes.karthik.example
handle @notes {
reverse_proxy localhost:5000
}
handle {
respond "Unknown service" 404
}
}
One wildcard certificate covers every subdomain, and the API token stays in an environment variable instead of the file. Wildcards also keep the names of your internal services out of public certificate-transparency logs.
13. Logs, Load Balancing and Health
Access logs are off by default per site. Turn them on as JSON files:
wiki.karthik.example {
log {
output file /var/log/caddy/wiki.log {
roll_size 10MB
roll_keep 5
}
}
reverse_proxy localhost:3000
}
Load balancing is just more upstreams:
app.karthik.example {
reverse_proxy app1:8000 app2:8000 {
lb_policy round_robin
health_uri /health
health_interval 10s
}
}
Unhealthy backends are skipped automatically.
Redirects are one line each:
www.karthik.example {
redir https://karthik.example{uri} permanent
}
14. The JSON API and the Quiet Superpower
Under the Caddyfile, Caddy's real configuration is JSON, which it exposes through an admin API on localhost:2019. The Caddyfile is an adapter on top.
# See the running config as JSON
curl localhost:2019/config/
# Convert a Caddyfile to JSON without running it
caddy adapt --config Caddyfile --pretty
This is how tools can change routes without restarting anything. The admin endpoint is bound to localhost by default. Do not expose it publicly.
15. Keeping the Doorman Healthy
- Back up the data folder.
/var/lib/caddy/.local/share/caddyon a package install, or the/datavolume in Docker. It holds your certificates and ACME account. - Update in place.
sudo apt upgrade caddy, ordocker compose pull && docker compose up -d. - Check the config in CI or before deploys.
caddy validateis fast. - Watch the certificate expiry from outside. Caddy renews early, but an external check is still good manners. Karthik wired one to ntfy, completing the loop from chapter 1.
# Days left on a live certificate
echo | openssl s_client -connect wiki.karthik.example:443 -servername wiki.karthik.example 2>/dev/null \
| openssl x509 -noout -enddate
16. Epilogue: The Quiet Renewal
Ninety days passed. Let's Encrypt certificates live that long, and Caddy renews them around the two-thirds mark.
On day sixty-one, at 3:17 a.m., Karthik's phone stayed completely silent. In the log, a single line said that Caddy had renewed four certificates. No warning. No cron job. No red padlock.
The club's old billing site, the one with the paid certificate from the departing CA, now sat behind the same Caddy. Its replacement had taken ten minutes and cost nothing. The next time a headline said a CA had lost the browsers' trust, Karthik read it with interest and no panic.
The club captain never messaged him.
Karthik opened the Caddyfile one more time. It was twenty-six lines long, and it ran his entire front door. He smiled, closed the file, and went back to sleep.
Quick Reference Cheat-Sheet
| I want to… | Do this |
|---|---|
| Proxy a site with HTTPS | site.example.com { reverse_proxy localhost:PORT } |
| Serve static files | root * /path + file_server |
| Compress responses | encode gzip zstd |
| SPA fallback | try_files {path} /index.html |
| Route by path | handle /api/* { reverse_proxy ... } |
| Redirect | redir https://new.example{uri} permanent |
| Password-protect | basic_auth { user HASH } (caddy hash-password) |
| Reuse config | (name) { ... } then import name |
| Local HTTPS | localhost { ... } or tls internal |
| Wildcard cert | *.example.com + tls { dns provider ... } (custom build) |
| Test config | caddy validate --config Caddyfile |
| Reload with no downtime | systemctl reload caddy |
| Format the file | caddy fmt --overwrite Caddyfile |
| Convert to JSON | caddy adapt --config Caddyfile --pretty |
Key takeaways
- A real hostname in the Caddyfile is the whole HTTPS setup. Certificates are issued and renewed for you.
- DNS, ports 80/443 and a free port are the only prerequisites.
- One Caddy fronts many services, each in its own small block.
- Validate, then reload. Reloads are graceful.
- Back up the data folder, or you will re-issue certificates after a rebuild.
- Use the DNS challenge or
tls internalwhen the server is not reachable from the internet. - Plan for your CA to disappear. Short-lived, automatic certificates and a one-line issuer change turn a CA failure into a small edit.
Self-hosting gives your services a home. Caddy makes sure the front door is always locked, lit and trusted.