Caddy’s default TLS configuration is surprisingly robust, but hardening it for production involves a few deliberate steps beyond just letting it auto-renew.
Let’s see Caddy in action, specifically how it handles TLS and security headers. Imagine we have a simple Caddyfile:
:443
reverse_proxy localhost:8080 {
header_up X-Forwarded-Proto {scheme}
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self';"
}
When a browser requests https://yourdomain.com, Caddy automatically negotiates TLS using ACME (Let’s Encrypt by default). It serves the site and, crucially, injects these headers into the response. For example, the Strict-Transport-Security header tells the browser to only connect to yourdomain.com over HTTPS for the next year, and preload signals it can be baked into browser lists. X-Content-Type-Options: nosniff prevents the browser from trying to guess MIME types, X-Frame-Options: DENY stops clickjacking, and Referrer-Policy controls what referrer information is sent. Content-Security-Policy is your main defense against XSS attacks, defining exactly where content (scripts, styles, etc.) can be loaded from.
The core problem Caddy solves here is simplifying secure web serving. Traditionally, managing TLS certificates, configuring cipher suites, and implementing security headers was a complex, error-prone manual process. Caddy automates certificate acquisition and renewal, and its configuration language makes adding security headers as straightforward as adding a few lines to your Caddyfile. It acts as a reverse proxy, an HTTP server, and a security gatekeeper all in one.
Internally, Caddy uses the go-acme library for certificate management and the tls package for its TLS configuration. When you specify tls.config, Caddy populates a tls.Config struct. For security headers, it simply adds them to the http.ResponseWriter before passing the request down the chain. The reverse_proxy directive is also a key piece, allowing Caddy to forward requests to backend applications while handling TLS termination and security concerns itself.
The most surprising thing about Caddy’s TLS configuration is its aggressive default cipher suite selection. Even without explicit configuration, Caddy prioritizes modern, secure cipher suites and disables older, vulnerable ones like RC4 or weak DHE groups. This means out-of-the-box, Caddy provides strong TLS protection without requiring deep cryptographic knowledge.
When Caddy starts, it attempts to obtain certificates for any domains it’s serving. If it already has a valid certificate, it uses it. Otherwise, it initiates the ACME challenge (typically HTTP-01 or TLS-ALPN-01) to prove domain ownership. Once successful, it stores the certificate and private key securely and sets up automatic renewal. The security headers are added on a per-request basis, evaluated before the request is proxied or served statically.
One aspect that often catches people out is the interaction between header blocks and reverse_proxy directives. If you define security headers in a global header block, they apply to all responses. However, if a specific reverse_proxy directive also includes a header sub-directive, those headers will override or add to the global ones for requests routed by that proxy. It’s essential to be aware of this layering to ensure your security headers are consistently applied as intended across your entire site, especially when you have multiple proxy backends.
The next step in hardening Caddy would be to explore advanced TLS features like OCSP stapling and HTTP/3 support.