DEV Community

Russell Jones
Russell Jones

Posted on • Originally published at jonesrussell.github.io

Caddy Hardening: Security Headers and Rate Limiting

Ahnii!

This is part 5 of the Production Linux series. Previous: Docker Security on a Shared VPS.

Caddy handles TLS automatically, but it doesn't add security headers by default. This post adds them, along with rate limiting and server identity removal.

Security Headers as a Snippet

Caddy supports reusable snippets, which lets you define headers once and apply them across every site block. Add this near the top of your Caddyfile:

(security-headers) {
    header / {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        Referrer-Policy strict-origin-when-cross-origin
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        -Server
        -X-Powered-By
    }
}
Enter fullscreen mode Exit fullscreen mode

Import the snippet in each site block:

yourdomain.com {
    import security-headers
    reverse_proxy localhost:8000
}
Enter fullscreen mode Exit fullscreen mode

The import directive inlines the snippet at that point in the config. What each header does:

  • HSTS — tells browsers to always use HTTPS for the next year, across all subdomains.
  • X-Content-Type-Options — prevents browsers from MIME-sniffing responses away from the declared content type.
  • X-Frame-Options — blocks your site from being embedded in an iframe on another origin, reducing clickjacking risk.
  • Referrer-Policy — controls how much of the URL is sent in the Referer header when navigating away from your site.
  • Permissions-Policy — opts out of browser APIs your site doesn't need; an attacker can't use them even if they inject script.

Content Security Policy

CSP is powerful but complex enough to warrant its own section. Add it inside the snippet or directly in the site block:

header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"
Enter fullscreen mode Exit fullscreen mode

This is a reasonable starting point for a Laravel/Inertia SPA. A few common gotchas:

  • Inline scripts — Inertia's initial page payload is injected inline. You'll need 'unsafe-inline' in script-src or generate per-request nonces.
  • Vite dev server — During local development, Vite serves assets from http://localhost:5173. Add that URL to script-src in dev; never ship it to production.
  • Third-party fonts or CDN assets — Add those origins explicitly to font-src or script-src as needed.

Start permissive and tighten over time. Use your browser's DevTools console: CSP violations appear there and tell you exactly what to allow.

Remove the Server Header

The -Server and -X-Powered-By lines in the snippet above suppress the headers that advertise your software stack. Reducing information disclosure makes automated scanners work harder to fingerprint your server.

If you have an existing config without those lines, add them inside any header block:

header {
    -Server
    -X-Powered-By
}
Enter fullscreen mode Exit fullscreen mode

Rate Limiting

Caddy doesn't include rate limiting in the standard build. Install the mholt/caddy-ratelimit module using xcaddy:

xcaddy build --with github.com/mholt/caddy-ratelimit
Enter fullscreen mode Exit fullscreen mode

Replace your system Caddy binary with the custom build, then configure per-IP limits on your API routes:

yourdomain.com {
    import security-headers

    route /api/* {
        rate_limit {
            zone api_zone {
                key {remote_ip}
                events 100
                window 1s
            }
        }
        reverse_proxy localhost:8000
    }

    reverse_proxy localhost:8000
}
Enter fullscreen mode Exit fullscreen mode

When a client exceeds 100 requests per second, Caddy returns a 429 Too Many Requests response. Legitimate traffic stays under the limit; brute-force and scraping attempts are cut off automatically.

Structured JSON Logs for fail2ban

Caddy's structured logging is what makes fail2ban filtering work. If you followed Post 3, your jail already expects JSON. Confirm your log directive outputs JSON:

yourdomain.com {
    import security-headers

    log {
        output file /var/log/caddy/access.log
        format json
    }

    reverse_proxy localhost:8000
}
Enter fullscreen mode Exit fullscreen mode

The format json line ensures each access log entry is a single JSON object with fields like request.remote_ip and status. fail2ban's regex filter parses those fields to identify ban candidates.

See UFW, fail2ban, and Banning Repeat Offenders for the fail2ban jail configuration that reads this log.

Keep Caddy Updated

Caddy ships an upgrade command that replaces the binary in place:

sudo caddy upgrade
Enter fullscreen mode Exit fullscreen mode

Run this regularly. Multiple high-severity vulnerabilities were disclosed in March 2026 affecting Caddy v2.10.0 through v2.11.1. Staying on the current release is the single most effective hardening step — no config change compensates for a vulnerable binary.

Subscribe to Caddy's GitHub releases to get notified of new versions.

Verify Your Headers

After reloading Caddy (sudo systemctl reload caddy), confirm the headers are present:

curl -I https://yourdomain.com
Enter fullscreen mode Exit fullscreen mode

The response should include strict-transport-security, x-content-type-options, x-frame-options, and your CSP header. Check that server is absent from the output.

For a scored report, paste your domain into securityheaders.com. It grades each header and flags anything missing or misconfigured.

Baamaapii

Top comments (0)