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.
  • curl and, for the automated test, a CI job that can reach a staging server.

The Headers and Why

HeaderValue usedPurpose
Strict-Transport-Securitymax-age=31536000; includeSubDomainsBrowsers use HTTPS only
Content-Security-Policysee Step 2Limits where scripts, styles and other resources load from
X-Content-Type-OptionsnosniffNo MIME sniffing of responses
Referrer-Policystrict-origin-when-cross-originLimits referrer data sent to other sites
Permissions-Policycamera=(), microphone=(), geolocation=()Disables powerful features the site does not use
Cross-Origin-Opener-Policysame-originIsolates the browsing context
X-Frame-Options / frame-ancestorsDENY / 'none'Prevents framing (clickjacking)
-ServerremovedDoes 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.

Response types covered by the Caddy header snippet Two panels. Covered automatically by the snippet in the site block, HTML pages, assets, redirects from the redir directive, and error pages from handle_errors. Needing separate attention, the automatic HTTP to HTTPS redirect on port 80, which Caddy issues before the site block runs, and responses from reverse-proxied paths if the upstream sets its own headers. Where the headers apply Covered by the snippet HTML pages assets and downloads redir responses handle_errors pages one import per site Check separately port 80 → 443 redirect reverse_proxy upstream headers by design or upstream
The HTTP to HTTPS redirect carries no security headers, which is expected; HSTS is only honoured over HTTPS anyway.

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.

Timeline of the staged security header rollout Four stages. Day 1, low-risk headers: nosniff, Referrer-Policy, Permissions-Policy, removing Server. Day 3, framing and opener headers after checking no partner frames the site. Day 4, the CSP in report-only mode with a reporting endpoint. Day 11, the CSP enforced after a week with no reports from site code, and HSTS raised to one year. Rolling out the headers in stages Low-risk headers Framing + COOP CSP report-only CSP enforced Day 1 nosniff, referrer, permissions Day 3 no partner frames found Day 4 reports collected Day 11 HSTS to 1 year
Each stage was a one-line change to the imported snippets and a caddy reload, with no downtime.

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.

Presence of key security headers on each response type A matrix of five response types against four headers. Home page, guide page, asset, 404 page and redirect all carry HSTS, CSP, nosniff and Referrer-Policy after the change. Before the change, none of them carried any of the four. Headers per response type after the change Response HSTS CSP nosniff Referrer Home page yes yes yes yes Guide page yes yes yes yes Hashed asset yes yes yes yes 404 page yes yes yes yes Redirect yes yes yes yes
Results from the header test against staging; Mozilla Observatory moved from F to A+ on the home page.

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

MeasureBeforeAfterTool
Mozilla Observatory grade (home)F (no headers)A+Observatory
Response types with full header set0 of 55 of 5Header test
CSP violations from site code, first week enforced—0Report endpoint
Caddyfile lines added—38Diff
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_errors responses 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 import line 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.

Filed underHow-tosCaddy