Serving a Static Site with Caddy
Caddy is the web server that does the right thing by default. Point it at a directory and a domain name, and it fetches a TLS certificate, redirects HTTP to HTTPS, serves HTTP/2 and HTTP/3, and renews the certificate before it expires — all without a line of TLS configuration. For teams self-hosting a static site on a VM, that removes the two most common sources of self-hosting incidents: expired certificates and misconfigured TLS.
This guide builds a complete Caddyfile for a 900-page Eleventy documentation site, including clean URLs, precompressed assets, cache tiers, security headers, redirects and wildcard preview subdomains. It is part of Self-Hosting Static Sites on S3, Nginx and Caddy, and the nginx equivalent is in Serving a Static Site with Nginx.
Prerequisites
- A Linux VM with Caddy 2.8 or newer installed from the official package repository.
- DNS for the domain pointing at the VM, and ports 80 and 443 open.
- The site deployed to
/srv/docs/current— for example via symlinked release directories, as in Zero-Downtime Deploys with Symlink Swaps.
The Complete Caddyfile
{
email ops@example.com
servers { protocols h1 h2 h3 }
}
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
X-Frame-Options "DENY"
Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'"
-Server
}
}
docs.example.com {
root * /srv/docs/current
import security_headers
encode zstd gzip
@hashed path_regexp \.[0-9a-f]{8,}\.(css|js|woff2|avif|webp)$
header @hashed Cache-Control "public, max-age=31536000, immutable"
header -@hashed Cache-Control "public, max-age=0, must-revalidate"
import /srv/docs/current/redirects.caddy
@dotfiles path */.*
respond @dotfiles 404
try_files {path} {path}/index.html {path}.html
file_server {
precompressed br zstd gzip
}
handle_errors {
@404 expression {err.status_code} == 404
rewrite @404 /404.html
file_server
}
}
That is the whole server: about thirty lines, of which the certificate handling is zero. The -Server header removal drops Caddy's version banner. The header -@hashed form applies to every response not matching the hashed-asset matcher, which makes the two cache tiers explicit.
Redirects From the Build
The Caddyfile imports redirects.caddy from the current release, so redirects ship with the content that needs them. Generate it at build time from the same redirect source used for other hosts:
// eleventy.config.js (excerpt)
eleventyConfig.on('eleventy.after', async () => {
const rules = JSON.parse(await fs.readFile('redirects.json', 'utf8'));
const caddy = rules.map(({ from, to, status = 301 }) => `redir ${from} ${to} ${status}`).join('\n');
await fs.writeFile('_site/redirects.caddy', caddy + '\n');
});
Because the file is inside the release directory, switching the current symlink changes redirects atomically with the pages. Caddy re-reads imports only on config reload, so the deploy script runs caddy reload after the swap — a graceful operation that does not drop connections.
Precompressed Files
precompressed br zstd gzip tells file_server to look for page.html.br, then .zst, then .gz next to the requested file, choosing the best one the browser accepts. Generate them after the build as described in the nginx guide; encode zstd gzip remains as a fallback for anything not precompressed. On this site, brotli level 11 produced HTML 17% smaller than Caddy's on-the-fly zstd at its default level, with no per-request CPU.
Preview Subdomains With a Wildcard Certificate
Caddy can serve every pull-request preview from one block. With a wildcard DNS record (*.preview.example.com) and a DNS provider module for the ACME DNS challenge, one wildcard certificate covers every preview, and a placeholder maps the subdomain to a directory:
*.preview.example.com {
tls { dns cloudflare {env.CF_API_TOKEN} }
root * /srv/previews/{labels.2}
import security_headers
header X-Robots-Tag "noindex, nofollow"
basic_auth { reviewer $2a$14$Zm9vYmFyYmF6cXV4… }
try_files {path} {path}/index.html {path}.html
file_server { precompressed br gzip }
}
{labels.2} is the third label from the right — pr-482 in pr-482.preview.example.com. CI rsyncs each pull request's build into /srv/previews/pr-482/, and the preview is live immediately with no Caddy reload. Basic auth and noindex keep previews private, as covered in Password-Protecting Preview Deployments.
A request for a preview that does not exist — a closed pull request or a typo — would otherwise fall through to a 404 on an empty root. Add a @missing not file {labels.2} style check, or simply let the 404 page render, and make sure the cleanup job removes directories when pull requests close so disk use stays bounded; on this VM each preview was about 60 MB, and thirty open pull requests fit comfortably.
Measured Impact
The documentation site moved from nginx with certbot to Caddy on the same 2 vCPU VM.
| Measure | nginx + certbot | Caddy |
|---|---|---|
| Configuration lines (server + TLS) | 84 + certbot timer | 34 |
| Certificate renewal incidents, prior 12 months | 2 (timer disabled after OS upgrade) | 0 since migration |
| Protocols | HTTP/1.1, HTTP/2 | HTTP/1.1, HTTP/2, HTTP/3 |
| Security headers on 404 responses | missing until fixed | present by default |
Requests/s, cached HTML (wrk, 64 conns) | 14,200 | 12,900 |
| TTFB p75 behind CDN (RUM) | 41 ms | 40 ms |
nginx remained about 9% faster in raw throughput on local benchmarks, which never mattered: behind a CDN, the VM served under 50 requests per second at peak. The two certificate incidents in the previous year — both caused by a renewal timer that stopped running after an OS upgrade, each producing about an hour of browser security warnings — were the actual cost of the old setup.
Running Caddy Well
A few operational habits keep a Caddy host healthy. Store Caddy's data directory (/var/lib/caddy) on persistent disk and back it up; it holds certificates and account keys, and losing it means re-issuing every certificate, which can hit rate limits for large numbers of subdomains. Validate config before reloading with caddy validate --config /etc/caddy/Caddyfile in the deploy script, so a typo fails the deploy instead of the reload. Enable the structured JSON access log with log { output file /var/log/caddy/docs.log { roll_size 50mb roll_keep 10 } } and ship it to the same place as your other logs, so the 404 and cache reports described in Logging 404s at the Edge work for self-hosted sites too. And monitor certificate expiry externally anyway: automatic renewal is reliable, but an external check is what tells you if the DNS or firewall change that would break it has already happened.
Pitfalls & Rollback
- Ports blocked by a firewall. Automatic certificates need port 80 or 443 reachable for the ACME challenge; use the DNS challenge if they cannot be.
- Ephemeral data directory. Certificates live in Caddy's data directory; a container without a persistent volume re-issues on every restart and can hit rate limits.
- Forgetting
caddy reloadafter a symlink swap. File contents update immediately, but imported redirect files are read only on reload. - Wildcard previews without auth. Every preview is publicly reachable by guessable names unless protected.
- Matcher order assumptions. Caddy sorts directives by its own fixed order, not by position in the file. When
redirandtry_filesinteract unexpectedly, wrap related directives in arouteblock to force the written order. - Staging certificates left on. Testing with the ACME staging endpoint is sensible, but a config that still points at staging in production produces untrusted certificates; check the global options before go-live.
- Rollback: keep the Caddyfile in Git;
caddy reloadwith the previous file restores the old behaviour without dropping connections.
Conclusion
Caddy turns self-hosting a static site into a short, readable file: automatic HTTPS, clean URLs, precompressed assets, two cache tiers, headers that apply to every response, and wildcard preview subdomains. Moving a 900-page docs site from nginx and certbot to Caddy cut configuration by 60%, added HTTP/3, and ended the certificate-renewal incidents that had caused two outages in a year — at a raw-throughput cost that a CDN made invisible.
FAQ
Does Caddy really handle TLS certificates automatically?
Yes. For any site address with a public domain name, Caddy obtains a certificate from Let's Encrypt or ZeroSSL on first request, renews it before expiry, and redirects HTTP to HTTPS, with no extra configuration. It needs ports 80 and 443 reachable and DNS pointing at the server.
Can Caddy serve precompressed brotli files?
Yes. The file_server directive's precompressed option serves .br, .zst or .gz siblings when the browser accepts them, falling back to the original file or to on-the-fly compression from the encode directive.
How do I add security headers to every response in Caddy?
Use the header directive in a snippet and import it into each site block. Unlike nginx, Caddy's header directives do not disappear in nested matchers, so one import at the site level covers every response.
Is Caddy suitable for high-traffic sites?
For static files it serves many thousands of requests per second on a small VM. Very high-traffic sites usually put a CDN in front anyway for latency, which leaves Caddy serving mostly cache revalidations.
Related
- Parent: Self-Hosting Static Sites on S3, Nginx and Caddy — choosing a self-hosting stack.
- Serving a Static Site with Nginx — the same setup with more control.
- Zero-Downtime Deploys with Symlink Swaps — the release layout Caddy serves.
- Enabling HSTS and Preload Safely — ramping the HSTS header in the snippet.
- Cleaning Up Stale Preview Deployments — removing old preview directories.