Claude Code with Caddy: Production HTTPS Without the Footguns
Why Caddy without CLAUDE.md ships HTTPS edges that quietly fail
Caddy is the modern web server that does automatic HTTPS, reverse proxying, and static file serving in a single binary with a single configuration file. The pitch is real: a working Caddyfile gets you a TLS-terminated, HSTS-protected, HTTP/3-capable edge in under a dozen lines. The problem is that the same simplicity makes the failure modes hard to spot. Caddy will happily accept a Caddyfile that catches every hostname into one block, terminates TLS for hostnames the operator does not control, sets no security headers, exposes the admin API to the world, and reverse proxies to an upstream with no health check and no timeout. None of these are bugs. They are the defaults Caddy chose to make the happy path fast, and they are the patterns Claude Code reproduces when asked to "set up Caddy in front of my app."
A Claude Code session that drafts a Caddyfile without project rules generates a config that works on day one and breaks on day thirty. The wildcard host block silently grabs traffic for a misconfigured subdomain. The missing header directive ships a site with no Strict-Transport-Security, no X-Content-Type-Options, and no Referrer-Policy. The reverse proxy block has no lb_try_duration, so when the upstream restarts during deploys, in-flight requests hang for the default 30 seconds before Caddy gives up. The admin endpoint sits on 0.0.0.0:2019 because the example in the docs shows it that way and Claude does not know to override it. Each of these is a small mistake. Together they describe a Caddy deployment that an operator has to babysit forever.
This guide covers the CLAUDE.md configuration that locks Claude Code into Caddy patterns that survive production: an explicit host block for every site, hardened headers as a snippet imported into every site, a reverse proxy template with health checks and timeouts, environment-aware imports for dev and prod, an admin API bound to localhost only, and permission hooks that gate the destructive caddy reload and admin API calls. For the reverse proxy alternative that runs in front of a Kubernetes cluster, Claude Code with Traefik covers the cloud-native edge. For the TLS edge at the CDN layer, Claude Code with Cloudflare Workers covers the patterns that complement an origin Caddy install.
The Caddy CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Caddy deployment it needs to declare: the Caddy version, the configuration format (Caddyfile or JSON), the project structure, the snippet conventions, the upstream health-check policy, the security header baseline, the admin API binding, and the hard rules that block the misconfigurations Claude makes most often.
# Caddy rules
## Stack
- caddy 2.x (latest stable release tracked in caddy.version)
- Caddyfile format for human-readable config
- JSON config generated by Caddy CLI when programmatic edits are needed
- ACME issuer: Let's Encrypt by default, ZeroSSL as fallback
- DNS provider plugin compiled in for wildcard certificates
## Project structure
- caddy/Caddyfile , root config, imports per-site files
- caddy/sites/ , one file per host or hostname group
- caddy/snippets/ , reusable directive blocks (headers, proxy template)
- caddy/env/local.caddy , localhost overrides
- caddy/env/staging.caddy , staging overrides
- caddy/env/production.caddy , production overrides
- ops/caddy/ , systemd unit, Dockerfile, k8s manifest
## Host blocks (MANDATORY)
- ONE block per hostname, NEVER a wildcard catch-all in production
- Every site MUST import the security-headers snippet
- Every reverse_proxy block MUST set lb_try_duration AND health_uri
- Every site MUST declare its tls block explicitly (issuer, alpn, ciphers)
- NEVER use auto_https off without an explicit reason documented in the file
## Snippets
- (security-headers) snippet defines: HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy
- (proxy-defaults) snippet defines: timeouts, retries, health check, header_up forwarded headers
- Snippets imported via import directive at the top of every site block
## Automatic HTTPS exceptions
- Internal hostnames (.internal, .lan, .corp) MUST use tls internal
- Hostnames behind a CDN that handles TLS MUST set auto_https off with a comment
- ACME staging issuer used in non-prod environments to avoid rate limits
## Admin API (MANDATORY)
- admin endpoint bound to 127.0.0.1:2019 or unix socket, NEVER 0.0.0.0
- admin disabled entirely in container images that do not need runtime reconfiguration
- admin origin restriction enforced when admin is enabled
## Reverse proxy (MANDATORY)
- Every upstream block specifies health_uri AND health_interval
- lb_try_duration set explicitly (default 5s, never the implicit 30s)
- transport http with read_timeout AND write_timeout set
- Trusted proxies declared when behind a CDN (servers.trusted_proxies)
## Logging
- Access log to stdout in JSON format for log aggregation
- Errors to stderr in JSON format
- log filter sanitizes Authorization and Cookie headers
- NEVER log to a file Caddy rotates itself in containerized deployments
## Hard rules
- NEVER use a wildcard host block (:80, :443) in production Caddyfiles
- NEVER expose the admin API on a non-loopback interface
- NEVER skip the security-headers snippet on a public host
- NEVER reverse_proxy without health_uri AND lb_try_duration
- NEVER commit ACME staging certificates to source control
- NEVER edit running Caddy via the admin API without a config-as-code reconciliation step
- ALWAYS validate Caddyfile with caddy validate before reload
- ALWAYS run caddy fmt --overwrite before committing
Six rules here prevent the majority of incidents Claude generates without them.
The explicit host block rule kills the wildcard catch-all. A Caddyfile that starts with :443 { ... } answers any hostname that resolves to the server, which means any DNS misconfiguration anywhere on the internet can pull traffic into your edge. The right pattern is one block per hostname with an explicit tls directive, so traffic for hostnames you do not control is rejected at the TLS handshake.
The mandatory snippets rule turns hardened headers and proxy defaults into the standard. Without it, Claude generates a site block with no header directive at all, no Strict-Transport-Security, no Permissions-Policy, no header sanitization on the reverse proxy. With it, every new site automatically inherits the baseline by importing the snippet.
The admin API binding rule prevents the worst silent failure mode. Caddy's admin API is powerful: it can rewrite the entire config at runtime. The default binding localhost:2019 is safe. The pattern Claude reaches for, especially when generating Docker or Kubernetes configs, is 0.0.0.0:2019 "to make it accessible." That makes a remote admin endpoint accessible to anyone who can reach the host on port 2019, which on a misconfigured cloud security group is the entire internet.
Install and project structure
Caddy is a single static binary. Install it via the package manager appropriate for your platform or download the release directly.
# macOS
brew install caddy
# Debian/Ubuntu
sudo apt install caddy
# From source (when you need a DNS provider plugin compiled in)
xcaddy build --with github.com/caddy-dns/cloudflare
Initialize the project structure:
mkdir -p caddy/{sites,snippets,env}
mkdir -p ops/caddy
touch caddy/Caddyfile
Create the root Caddyfile that imports everything else:
# caddy/Caddyfile
{
admin 127.0.0.1:2019
log {
output stdout
format json
level INFO
}
servers {
trusted_proxies static 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
}
}
import snippets/*.caddy
import env/${CADDY_ENV:-production}.caddy
import sites/*.caddy
The global options block at the top sets the admin binding to loopback, configures JSON logging, and declares the trusted proxy ranges so X-Forwarded-For parsing only honours headers from internal IPs. The import lines pull in snippets first, then the environment-specific overrides, then each site definition. The order matters: snippets must exist before site blocks reference them, and the environment file must define the variables sites depend on.
Hardened headers as a snippet
Every public site needs the same set of security headers. Put them in a snippet so the only knob each site has is whether to import.
# caddy/snippets/security-headers.caddy
(security-headers) {
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=()"
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Resource-Policy "same-site"
-Server
-X-Powered-By
}
}
The HSTS header is set to two years with includeSubDomains and preload, which is the right shape for a domain you control end-to-end. If the site is on a shared subdomain or a domain you do not fully control, the includeSubDomains directive will lock you out of HTTP on every sibling subdomain for the next two years. Add a CLAUDE.md note that says HSTS preload requires confirmation before shipping.
The Permissions-Policy denies access to features the site does not use. Add or remove entries as the site adds capabilities. The trailing -Server and -X-Powered-By strip the headers Caddy or an upstream might add that leak version information.
A site block with the right defaults
A production site block imports the security headers, declares a reverse proxy to the upstream, and sets the TLS issuer.
# caddy/sites/app.caddy
app.example.com {
import security-headers
encode zstd gzip
log {
output stdout
format json
}
handle /api/* {
reverse_proxy http://app-backend:8080 {
health_uri /healthz
health_interval 10s
health_timeout 2s
lb_try_duration 5s
transport http {
read_timeout 30s
write_timeout 30s
dial_timeout 3s
}
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
handle {
root * /var/www/app
try_files {path} /index.html
file_server
}
tls {
issuer acme {
email ops@example.com
}
protocols tls1.2 tls1.3
}
}
Every block in this site enforces a rule from the CLAUDE.md. The import security-headers brings in the header baseline. The reverse_proxy block has the four health-check parameters and the three transport timeouts that Claude would otherwise skip. The tls block is explicit about the issuer and the minimum protocol version.
The handle /api/* then handle pattern routes API requests to the backend and falls through to the static file server for everything else. This is the pattern for a single-page application served from the same hostname as its API. For deployments where the API runs on a separate hostname, split into two handle_path blocks or two site blocks.
Reverse proxy patterns that survive deploys
The reverse proxy block above is the minimum. Production reverse proxies need more attention to the failure modes that show up under load and during deploys.
reverse_proxy {
to http://app-backend-1:8080 http://app-backend-2:8080
lb_policy least_conn
lb_try_duration 5s
lb_try_interval 250ms
health_uri /healthz
health_interval 10s
health_timeout 2s
health_status 2xx
fail_duration 30s
max_fails 3
transport http {
read_timeout 30s
write_timeout 30s
dial_timeout 3s
keepalive 30s
keepalive_idle_conns 100
}
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_down Server "edge"
}
The lb_policy least_conn routes new requests to the upstream with the fewest in-flight connections, which gives smooth distribution under skewed request durations. The lb_try_duration 5s caps the time Caddy will spend retrying a failed upstream before giving up on the request; without it, the default is 30 seconds, which is long enough that a deploy rolling a single backend out at a time looks like an outage. The fail_duration 30s and max_fails 3 define the circuit breaker: after three failures within 30 seconds, the upstream is removed from rotation until the next health check succeeds.
The transport block sets the four timeouts that matter. dial_timeout 3s caps how long Caddy waits to open a TCP connection to the upstream. read_timeout and write_timeout cap the per-request budget. keepalive and keepalive_idle_conns keep persistent connections to the upstream, which avoids reopening TCP for every request and removes the largest source of tail latency.
The header_up lines forward the request metadata the application needs to know who called it and over what protocol. The trailing header_down Server "edge" overrides whatever the upstream sets as its Server header, so the response always shows the same string.
Environment-aware configuration
Dev, staging, and production differ in two places: the ACME issuer and the upstream URLs. Split those into environment files.
# caddy/env/local.caddy
(env) {
@env_local true
}
(acme) {
tls internal
}
# caddy/env/staging.caddy
(env) {
@env_staging true
}
(acme) {
tls {
issuer acme {
email ops-staging@example.com
ca https://acme-staging-v02.api.letsencrypt.org/directory
}
}
}
# caddy/env/production.caddy
(env) {
@env_production true
}
(acme) {
tls {
issuer acme {
email ops@example.com
}
}
}
In a site block, replace the inline tls directive with import acme:
app.example.com {
import security-headers
import acme
reverse_proxy http://app-backend:8080 {
health_uri /healthz
health_interval 10s
lb_try_duration 5s
}
}
The CADDY_ENV environment variable selects which env file to import. Local development gets self-signed certs via tls internal. Staging hits Let's Encrypt's staging CA so the iteration speed is not capped by the production rate limit. Production hits the real CA. The pattern keeps the production Caddyfile honest because every change has to work in three environments before it ships.
Admin API and runtime reconfiguration
Caddy's admin API can rewrite the entire config at runtime. That power is the right answer for some workloads, the wrong answer for others. The CLAUDE.md rule is that it must be bound to loopback or a Unix socket, and that any change made via the admin API must round-trip through the config-as-code repository.
{
admin 127.0.0.1:2019 {
origins http://127.0.0.1:2019 http://localhost:2019
enforce_origin
}
}
The origins and enforce_origin lines reject API calls with the wrong Origin or Host header. Combined with the loopback binding, an attacker would need code execution on the Caddy host to call the API, at which point the config is the smallest of your problems.
For containerized deployments where you do not need runtime reconfiguration, disable the admin entirely:
{
admin off
}
This removes the attack surface completely. The trade-off is that config changes require a full container restart instead of a hot reload. For most deployments, the hot reload is not worth the operational complexity.
For the reload pattern, the standard is caddy reload --config /etc/caddy/Caddyfile, which validates the new config, swaps it into the running process, and reverts on any error. Wrap this in a script that runs caddy fmt and caddy validate first:
#!/bin/bash
set -euo pipefail
caddy fmt --overwrite caddy/Caddyfile
caddy validate --config caddy/Caddyfile
caddy reload --config caddy/Caddyfile
Permission hooks for Caddy operations
Caddy has commands that can take down the edge or rewrite config without warning. Permission hooks in .claude/settings.local.json gate the destructive ones.
{
"permissions": {
"allow": [
"Bash(caddy fmt*)",
"Bash(caddy validate*)",
"Bash(caddy version*)",
"Bash(caddy list-modules*)",
"Bash(curl http://127.0.0.1:2019/config/*)"
],
"deny": [
"Bash(caddy stop*)",
"Bash(caddy reload --force*)",
"Bash(curl*POST*http://127.0.0.1:2019/load*)",
"Bash(systemctl stop caddy*)",
"Bash(systemctl restart caddy*)"
]
}
}
The allow list permits read-only operations Claude needs for debugging: formatting, validation, version checks, and config inspection via the admin API. The deny list catches the operations that stop the edge or rewrite the running config out-of-band. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can investigate a misbehaving Caddy without being able to take it down.
Common Claude Code mistakes with Caddy
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Wildcard host block
Claude generates: :443 { reverse_proxy http://app:8080 }.
Correct pattern: explicit hostname per block, with import security-headers and an explicit tls directive.
2. Reverse proxy without timeouts
Claude generates: reverse_proxy http://app:8080.
Correct pattern: reverse_proxy http://app:8080 { health_uri /healthz health_interval 10s lb_try_duration 5s transport http { read_timeout 30s write_timeout 30s dial_timeout 3s } }.
3. Admin API on 0.0.0.0
Claude generates: admin 0.0.0.0:2019 "for remote management."
Correct pattern: admin 127.0.0.1:2019 or admin off in containers that do not need it.
4. Missing security headers
Claude generates: a site block with no header directive.
Correct pattern: import security-headers at the top of every site block.
5. ACME production issuer in dev
Claude generates: an env file that hits Let's Encrypt production for staging or local.
Correct pattern: tls internal for local, ACME staging for non-prod, production CA only for production hosts.
6. Implicit HTTP to HTTPS redirect with no host validation
Claude generates: a config that redirects every hostname to HTTPS without checking it is one we serve.
Correct pattern: explicit http:// blocks per hostname when HTTP handling differs, otherwise let Caddy's automatic HTTPS handle the redirect for the explicit https:// host blocks only.
Add each pattern to CLAUDE.md with the corrected form. Combined with CLAUDE.md examples for the general structure, the result is a config Claude generates correctly the first time.
Logging and observability
Caddy ships with structured logging built in. The right pattern is JSON to stdout for access logs, JSON to stderr for errors, and a log filter that sanitizes sensitive headers.
{
log default {
output stdout
format json {
time_format iso8601
}
level INFO
}
log access {
output stdout
format json {
time_format iso8601
}
include http.log.access
}
}
(log-access) {
log {
output stdout
format json
format filter {
wrap json
fields {
request>headers>Authorization delete
request>headers>Cookie delete
request>headers>Set-Cookie delete
}
}
}
}
The filter wraps the JSON formatter and deletes the Authorization, Cookie, and Set-Cookie headers before the log line is emitted. Without this, every authenticated request logs its bearer token to whatever sink is consuming Caddy's stdout, which is a leak waiting to be searched.
For metrics, Caddy exposes a Prometheus endpoint on the admin API. Pair this with Claude Code with Grafana for dashboards and alerting on the metrics that matter: request rate, error rate, p99 latency per upstream, certificate expiry days remaining, and admin API call count.
Caddy in containers
The standard pattern is a multi-stage Dockerfile that builds a Caddy binary with the DNS provider plugins you need, then copies it into a minimal runtime image.
FROM caddy:2-builder AS builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY caddy/Caddyfile /etc/caddy/Caddyfile
COPY caddy/snippets /etc/caddy/snippets
COPY caddy/sites /etc/caddy/sites
COPY caddy/env /etc/caddy/env
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
The builder stage uses xcaddy to compile a custom binary with the Cloudflare DNS provider, needed for wildcard ACME challenges on domains hosted at Cloudflare. The runtime stage is the official Caddy image with the custom binary copied in.
Set the CADDY_ENV environment variable in the container manifest to select the env file. Mount the certificate storage volume at /data/caddy so certs survive container restarts. For a Kubernetes deployment, use a PersistentVolumeClaim for /data; for ECS, an EFS volume.
For the Caddy admin endpoint in containers, bind it to a Unix socket instead of TCP:
{
admin unix//tmp/caddy-admin.sock
}
This removes the loopback port entirely and gives the operator a way to mount the socket into a sidecar for management without exposing it on any network.
Testing Caddy configuration
The two checks every CI run should perform are caddy fmt --diff (fails if the config is not formatted) and caddy validate (fails if the config has syntax or semantic errors). For deeper integration tests, run Caddy against a fixture upstream and assert on the response shape.
#!/bin/bash
set -euo pipefail
caddy fmt --diff caddy/Caddyfile
caddy validate --config caddy/Caddyfile --adapter caddyfile
caddy start --config tests/Caddyfile --adapter caddyfile
curl -sf https://localhost/healthz --insecure | grep -q "ok"
curl -sf -I https://localhost/ --insecure | grep -q "strict-transport-security"
curl -sf -I https://localhost/ --insecure | grep -q "x-content-type-options"
caddy stop
The caddy fmt --diff returns non-zero if there are unformatted directives. The caddy validate checks the config parses and every referenced upstream resolves. The integration test then starts Caddy with a test config, makes requests against the local edge, and asserts on the headers and health endpoint. This catches the entire class of "config compiles but does the wrong thing" bugs before the deploy.
Building Caddy that does not need babysitting
The Caddy CLAUDE.md in this guide produces a config where every site has an explicit hostname and security headers, every reverse proxy has health checks and timeouts, the admin API is bound to loopback or disabled, ACME issuers are environment-aware, logging sanitizes sensitive headers, and the deploy pipeline runs caddy fmt and caddy validate before reload. The result is an HTTPS edge that operators can trust to do what it says and fail loudly when something goes wrong.
The underlying principle is the same as any infrastructure with Claude Code. Caddy without a CLAUDE.md produces a working config that conceals the misconfigurations that show up under load, during deploys, or when an attacker probes the surface. The CLAUDE.md is what makes the production-safe defaults the default for Claude.
For the cloud-native reverse proxy alternative that runs inside Kubernetes, Claude Code with Traefik covers the equivalent patterns. For the edge that sits in front of Caddy at the CDN layer, Claude Code with Cloudflare Workers covers the patterns that complement an origin Caddy install. For the observability stack that watches Caddy in production, Claude Code with Datadog covers the dashboards and alerts that matter.
Get Claudify. Ship a Caddy edge that survives production traffic.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify