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
}
}
Import the snippet in each site block:
yourdomain.com {
import security-headers
reverse_proxy localhost:8000
}
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
Refererheader 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'"
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'inscript-srcor generate per-request nonces. -
Vite dev server — During local development, Vite serves assets from
http://localhost:5173. Add that URL toscript-srcin dev; never ship it to production. -
Third-party fonts or CDN assets — Add those origins explicitly to
font-srcorscript-srcas 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
}
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
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
}
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
}
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
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
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)