./ahmedhashim

A Sensible Caddyfile

After a decade of nginx modules and Apache configurations, I’ve moved my personal infrastructure to Caddy. It’s a single Go binary that handles TLS automatically and ships with sensible defaults.

Here’s the Caddyfile I use for web apps that don’t load anything from third parties:

example.com {
        @static path *.css *.js *.ico *.txt /sitemap.xml
        handle @static {
                file_server {
                        root /opt/app/public
                }
        }
        reverse_proxy localhost:8080
        encode {
                zstd
                gzip
        }
        header {
                -Server
                Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
                Cross-Origin-Opener-Policy "same-origin"
                Origin-Agent-Cluster "?1"
                Permissions-Policy "accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
                Referrer-Policy "no-referrer"
                Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
                X-Content-Type-Options "nosniff"
        }
}

www.example.com {
        redir https://example.com{uri} permanent
}

The philosophy is to deny by default and only enable what the application genuinely needs. If it doesn’t need something, turn it off.

Automatic HTTPS and static assets

Caddy provisions and renews Let’s Encrypt TLS certificates over the ACME protocol on its own. Specifying a domain name in the Caddyfile is enough to get HTTPS, with no manual certificate management.

Requests matching @static are served straight from disk, and everything else gets proxied to the application server. Responses get compressed with zstd for clients that support it and gzip for everyone else.

Content security policy

The CSP allows resources only from the same origin ('self') and blocks inline scripts and external resources that are common XSS vectors. Keep in mind that inline styles get blocked too, which catches a lot of people off guard.

Everything from scripts to iframes falls back to default-src, so there’s no need to repeat it per resource type. base-uri, form-action and frame-ancestors don’t fall back, so they’re set explicitly. frame-ancestors 'self' stops other sites from framing the page and replaces X-Frame-Options.

You can extend this policy with nonces for inline content or additional domains. Doing that usually means moving the CSP header into the application so it can set per-request nonces.

Process isolation

Cross-Origin-Opener-Policy: same-origin stops cross-origin windows from getting a handle on your page through window.opener, which shuts down reverse tabnabbing and some cross-site leaks.

Origin-Agent-Cluster: ?1 asks the browser to isolate this origin from other subdomains on the same site, which lets it run your page in its own process. It also disables document.domain, an old trick that let sibling subdomains treat each other as the same origin.

Permissions

The permissions policy blocks browser APIs that read from the user’s device or move money, like the camera or payments. Fullscreen and autoplay only change presentation and already need a user gesture, so I leave them out.

The CSP already keeps third-party iframes off the page, so this mostly guards against your own origin serving a script it shouldn’t. Only Chromium-based browsers honor the header today.

If a feature genuinely needs one of these (for example, geolocation for a store finder), flip it from () to self. Everything else stays blocked, which is the principle of least privilege applied to browser APIs.

Privacy and transport security

Referrer-Policy: no-referrer prevents data leakage through referrer headers. It strips the referrer on same-origin requests too, so if you rely on Referer-based CSRF checks or internal analytics, use strict-origin-when-cross-origin instead.

Strict-Transport-Security enforces HTTPS with preload support in browsers, though HSTS preload submission requires a max-age of at least a year along with includeSubDomains. Removal from the preload list takes months to reach users’ browsers, so only submit domains where you control every subdomain and expect to keep them for a while.

Legacy headers

X-Content-Type-Options: nosniff prevents MIME-type confusion attacks and is the only legacy X- header still worth sending. The rest that usually show up in these lists aren’t needed anymore:

  • X-Download-Options was for IE8.
  • X-Permitted-Cross-Domain-Policies was for Flash.
  • X-XSS-Protection controls an XSS filter that no current browser ships.
  • X-DNS-Prefetch-Control doesn’t buy much, since browsers don’t prefetch link hostnames on HTTPS pages by default.

You could set Server "teapot" in honor of RFC 2324 and leave scanners wondering why your web server won’t brew coffee, but removing the header keeps Caddy from announcing itself on every response.

This is a reasonable baseline for a new web application. When a feature comes along that needs something more permissive, change one policy at a time. That way you’ll know what you’ve enabled and why.