Security Headers for Static Sites Served by Caddy
Caddy is a popular choice for self-hosting static sites because it handles TLS certificates automatically and has a short, readable configuration file. Security headers are not automatic: Caddy will serve your site over HTTPS with a valid certificate and still send no Strict-Transport-Security, no Content-Security-Policy, no X-Content-Type-Options. Adding them is a few lines of Caddyfile — and a few details about how Caddy applies header directives decide whether those lines cover every response or only some.
This guide adds a complete set of security headers to a Hugo documentation site served by Caddy 2.8, using a reusable snippet, path-specific additions for assets and the search index, and a test that checks every response type. It belongs to Security Headers and Hardening for Static Sites; the Caddy server setup is in Serving a Static Site With Caddy and Automatic HTTPS.
Prerequisites
- Caddy 2.6 or later serving the site from a Caddyfile.
- A list of third-party origins the site loads from (analytics, fonts, embeds), needed for the CSP.
curland, for the automated test, a CI job that can reach a staging server.
The Headers and Why
| Header | Value used | Purpose |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains | Browsers use HTTPS only |
Content-Security-Policy | see Step 2 | Limits where scripts, styles and other resources load from |
X-Content-Type-Options | nosniff | No MIME sniffing of responses |
Referrer-Policy | strict-origin-when-cross-origin | Limits referrer data sent to other sites |
Permissions-Policy | camera=(), microphone=(), geolocation=() | Disables powerful features the site does not use |
Cross-Origin-Opener-Policy | same-origin | Isolates the browsing context |
X-Frame-Options / frame-ancestors | DENY / 'none' | Prevents framing (clickjacking) |
-Server | removed | Does not advertise the server software |
Step 1: A Reusable Snippet
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()"
Cross-Origin-Opener-Policy "same-origin"
X-Frame-Options "DENY"
-Server
}
}
docs.example.com {
root * /srv/docs/current
encode zstd gzip
import security_headers
import csp
file_server
handle_errors {
rewrite * /{err.status_code}.html
file_server
}
}
The snippet is imported into every site block on the server. Caddy's header directive applies to the response however it is produced, including file_server responses, redirects and error pages handled in handle_errors — unlike Nginx's add_header, there is no inheritance trap where a nested block drops headers, a difference measured in Nginx vs Caddy for Serving Static Sites.
Step 2: The Content Security Policy
The CSP lives in its own snippet because it changes more often and differs between sites:
(csp) {
header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-Qm8f…' https://plausible.io; style-src 'self'; img-src 'self' data: https://images.example-cdn.com; font-src 'self'; connect-src 'self' https://plausible.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests; report-to csp"
header Reporting-Endpoints `csp="https://docs.example.com/csp-report"`
}
The sha256-… value is the hash of the site's one inline script (the theme initialiser), generated at build time and written into an included file by the deploy script, as described in Fixing Content Security Policy Violations From Inline Scripts. The policy was run as Content-Security-Policy-Report-Only for a week first.
Step 3: Path-Specific Additions
Assets get long caching and a cross-origin resource policy; the Pagefind search index needs a WebAssembly allowance for its own worker:
@assets path /css/* /js/* /fonts/* /images/*
header @assets {
Cache-Control "public, max-age=31536000, immutable"
Cross-Origin-Resource-Policy "same-origin"
}
@pagefind path /pagefind/*
header @pagefind Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'"
When several header directives match a request, Caddy applies all of them; for the Pagefind path, the later Content-Security-Policy replaces the site-wide one. 'wasm-unsafe-eval' is scoped to the search files only, so the rest of the site keeps the stricter policy. Pages that load search reference Pagefind's script, which runs under the page's policy — so the site-wide script-src also needed 'wasm-unsafe-eval'; it was added to the main policy after testing showed the search broke without it. The path-specific block covers the worker's own responses.
Rolling the Headers Out Safely
Turning on every header at once on a production site risks breaking something nobody tested — an embed that needed framing, a font from a forgotten origin, a payment widget that needs payment=. The site rolled them out in three stages over two weeks, one Caddy reload per stage, with the header test and a manual check of the ten most visited pages after each.
The framing stage needed one check outside the server: whether any partner or internal tool displayed the docs in an iframe. Server logs showed Sec-Fetch-Dest: iframe requests from one internal support tool, which was given its own frame-ancestors allowance on the paths it embedded. Without that check, X-Frame-Options: DENY would have broken the support team's knowledge base panel on day three.
Step 4: Test Every Response Type
Online scanners check one URL. A script checks the response types the site actually serves:
check() { # url, then expected header names
local h; h=$(curl -sI "$1")
for name in "${@:2}"; do echo "$h" | grep -qi "^$name:" || { echo "MISSING $name on $1"; fail=1; }; done
}
S="https://staging.docs.example.com"
for u in "$S/" "$S/guides/install/" "$S/css/main.4f8c.css" "$S/does-not-exist/" "$S/old-path/"; do
check "$u" strict-transport-security content-security-policy x-content-type-options referrer-policy
done
exit ${fail:-0}
It runs after every Caddyfile change against the staging server and in the nightly checks against production. It also asserts that Server is absent.
Several Sites on One Server
The same Caddy server hosts four static sites: docs, the marketing site, a status page and a small changelog microsite. All four import security_headers; each has its own CSP snippet, because their third-party origins differ — the marketing site loads a video player and a form provider, the status page none at all. Keeping the shared headers in one snippet means a change such as adding Cross-Origin-Opener-Policy reaches every site in one edit, and caddy validate in CI checks the combined configuration before it is deployed with caddy reload, which applies changes without dropping connections.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| Mozilla Observatory grade (home) | F (no headers) | A+ | Observatory |
| Response types with full header set | 0 of 5 | 5 of 5 | Header test |
| CSP violations from site code, first week enforced | — | 0 | Report endpoint |
| Caddyfile lines added | — | 38 | Diff |
| Response time overhead | — | not measurable (<0.1 ms) | Caddy metrics |
The header test now runs nightly against production as well, so a change made outside the repository is caught within a day.
Pitfalls & Rollback
- Assuming automatic HTTPS includes HSTS. It does not; add it.
- Path-specific CSPs that unintentionally replace the site-wide one. Check the order and test the affected paths.
- Forgetting error pages.
handle_errorsresponses get the headers only if the snippet is in the site block. - HSTS preload too early. Roll out in stages.
- Rollback: headers are in snippets; removing an
importline and reloading (caddy reload) reverts them without downtime.
Conclusion
Caddy makes HTTPS automatic but leaves security headers to the Caddyfile. A reusable snippet with HSTS, nosniff, referrer and permissions policies, a separate CSP snippet with a build-time hash for the one inline script, path-specific additions for assets and search, and a test that checks every response type took this site from an F to an A+ on Mozilla Observatory in 38 lines of configuration.
FAQ
Does Caddy add security headers by default?
Caddy serves HTTPS automatically and redirects HTTP to HTTPS, but it does not add HSTS, Content-Security-Policy, X-Content-Type-Options or other security headers on its own. They are added with the header directive in the Caddyfile, usually in a snippet imported into each site.
How do I set different headers for different paths in Caddy?
Use named matchers or path matchers with the header directive, for example a header block that applies only to paths under slash assets. When several header directives match the same request, all of them apply; a later one can replace a field set earlier, and a minus prefix removes a field.
Should HSTS include preload from the start?
No. Start with a short max-age without includeSubDomains or preload, confirm every subdomain serves HTTPS, then raise max-age and add includeSubDomains, and only submit to the preload list when you are sure, because removal takes months. The HSTS guide in this section describes the staged rollout.
How do I check the headers Caddy sends?
With curl -I against each kind of path — an HTML page, an asset, a 404 and a redirect — or an automated test that asserts the expected headers on each. Online scanners such as Mozilla Observatory give an overall grade but check only the home page.
Related
- Parent: Security Headers and Hardening for Static Sites — hardening a static site.
- Serving a Static Site With Caddy and Automatic HTTPS — the server this configures.
- Enabling HSTS and Preload Safely — the staged HSTS rollout.
- Permissions-Policy and Cross-Origin Headers — the less familiar headers explained.
- Security Headers on Vercel for Static Sites — the same set on a managed host.