Skip to content

Platforms

Caddy: the two-line https server and the headers to add to it

Caddy gets certificates, redirects to HTTPS and serves HTTP/2 and HTTP/3 with no configuration. Here is what it does by default, what it does not, and the Caddyfile that passes every report check.

getReport teamUpdated 25 Sept 202610 min read

Caddy is a web server that treats HTTPS as the default rather than a project. Point a domain at the server, write two lines, and it fetches a certificate, renews it, redirects HTTP to HTTPS and speaks HTTP/2 and HTTP/3. That covers most of a report's TLS section on day one. It does not cover security headers, compression or cache lifetimes, which are opt-in. This guide shows exactly what the defaults give you, then adds the rest, with Caddyfiles for a static site, a Node or PHP app and WordPress. Allow 30 minutes.

Quick answer

  • Two lines, example.com and reverse_proxy localhost:3000 (or file_server), give you a trusted certificate with automatic renewal, a redirect from HTTP to HTTPS, TLS 1.2 and 1.3 only, HTTP/2 and HTTP/3.
  • Not included: HSTS and the other security headers, compression, cache lifetimes, a page cache, rate limiting.
  • Add a header block with HSTS, CSP (report-only first), X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, and -Server.
  • Add encode zstd gzip for compression and header @matcher Cache-Control … for lifetimes.
  • Behind Cloudflare, use SSL mode Full (strict), never Flexible.
  • Check with the SSL/TLS checker, then the security headers checker.

Why Caddy is worth a look

Most TLS findings in a website report come from the moving parts around a certificate: a renewal job that stopped running, an HTTP port that serves the site instead of redirecting, a server that still accepts TLS 1.0, an intermediate certificate that was never installed. Caddy removes those parts. It gets certificates from Let's Encrypt, with ZeroSSL as a fallback, stores them, renews them well before expiry and installs the full chain, all without a cron job or a second tool.

That makes it a good fit for a VPS running one site or a handful: a small business site, an app behind a reverse proxy, a Docker host. If the server already runs nginx and you would rather keep it, Nginx configuration for an A grade reaches the same result by hand. getReport runs on it: the production Compose stack puts Caddy in front of the web app and the API, with Cloudflare in front of Caddy in Full (strict) mode, and the Caddyfile adds HSTS, removes the Server header and turns on compression. The snippets below follow the same pattern.

What Caddy does not do is guess your security policy or cache rules, and it has no built-in page cache or rate limiter. Those are the lines you add.

How getReport checks it

The SSL/TLS checker opens a TLS connection to your host, offering TLS 1.2 or newer, and records the protocol, the certificate's expiry date and whether the chain validates. It then tries a second handshake limited to TLS 1.0 and 1.1 and expects the server to refuse. The http-to-https finding requests the http:// address and checks that it lands on https://. A site behind default Caddy passes all of these; the screenshot shows what the same panel looks like before, on a server still answering over plain HTTP.

The SSL/TLS checker on a site served over plain HTTP: a security score of 63, the failed finding "The site is served over HTTP, not HTTPS" costing 15 points, and an info note that HSTS is not ready for the preload list
Before Caddy: one failed finding worth 15 points. With a domain pointed at Caddy, the certificate, redirect and TLS findings pass without configuration.

The expiry finding warns below 30 days and fails below 7 (TLS certificates explains the chain and the dates). Caddy renews with about a third of the lifetime left, around 30 days for a 90-day Let's Encrypt certificate, so a Caddy site that shows well under 30 days has a renewal that keeps failing. Certificate expiry: automate it, then monitor it covers what to watch.

Step by step

1. The two lines, and what they switch on

Point the domain's A and AAAA records at the server, open TCP ports 80 and 443 and UDP port 443, install Caddy from your distribution or the official packages, and write /etc/caddy/Caddyfile:

Caddyfile
example.com
reverse_proxy localhost:3000

For a folder of HTML files, replace the second line with root * /var/www/example.com and file_server. Reload with sudo systemctl reload caddy. Caddy now:

  • obtains a certificate for example.com and renews it automatically;
  • answers every http:// request with a 308 Permanent Redirect to https://, keeping the path and query;
  • negotiates TLS 1.2 or 1.3 only, with modern ciphers;
  • speaks HTTP/2, and advertises HTTP/3 with an Alt-Svc: h3=":443" header, which the HTTP/2 test reports as "HTTP/3 advertised";
  • sends Server: Caddy without a version number, so the version-leak finding passes.

It does not send HSTS, any other security header, compressed responses or Cache-Control lifetimes. On the report, that shows up as warnings in the security and best practices modules.

2. A security header snippet

Define the headers once as a snippet and import it into every site block:

Caddyfile
(security) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
		-Server
		-X-Powered-By
	}
}

The CSP starts as report-only, which the check counts as present; switch the name to Content-Security-Policy once the browser console stays quiet. Security headers from zero to A explains each value.

The two lines starting with - delete headers, and they matter for a second reason. Caddy normally sets headers as the request comes in, before the app answers. Behind reverse_proxy, the app's own headers are then added next to yours: in a test, an upstream that sent X-Frame-Options: ALLOWALL produced a response with both DENY and ALLOWALL, plus the app's own Server and X-Powered-By lines. When a header block deletes anything, Caddy applies the whole block as the response is written, after the upstream, so your values replace the app's. In a block without a deletion, add the defer subdirective to get the same effect.

For HSTS preload, raise the value to max-age=63072000; includeSubDomains; preload once every subdomain serves HTTPS, and submit the domain at hstspreload.org. Removal takes months.

3. A static site with compression, lifetimes and a 404 page

Caddyfile
example.com {
	import security
	root * /var/www/example.com
	encode zstd gzip

	@hidden {
		path /.*
		not path /.well-known/*
	}
	error @hidden 404

	@hashed path /assets/* /_astro/*
	header @hashed Cache-Control "public, max-age=31536000, immutable"
	@media path *.jpg *.jpeg *.png *.webp *.avif *.svg *.ico *.woff2
	header @media Cache-Control "public, max-age=604800"
	header ?Cache-Control "no-cache"

	file_server

	handle_errors {
		import security
		@404 expression {err.status_code} == 404
		handle @404 {
			rewrite * /404.html
			file_server
		}
	}
}

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

What each part does:

  • encode zstd gzip compresses text responses with Zstandard for browsers that accept it and gzip for the rest. Caddy has no on-the-fly Brotli encoder, and it does not need one: the report's compression finding counts zstd and gzip as compressed. If your build writes .br files, file_server { precompressed br zstd gzip } serves them.
  • @hidden answers any path that starts with a dot (.env, .git/) with a 404, except /.well-known/. file_server would otherwise serve them, and the report requests both /.env and /.git/HEAD.
  • The two matchers give hashed build output a year with immutable and images and fonts a week. ?Cache-Control sets no-cache only on responses that have no lifetime yet, which leaves HTML revalidated on every visit.
  • handle_errors serves your own 404.html with status 404, which the custom-404 check needs. Error responses run through their own route, so the snippet is imported there too; without it, the 404 page goes out with no security headers.
  • The second site block sends www to the bare domain in one 301. Caddy gets a certificate for www.example.com too, so https://www redirects cleanly.

4. A Node, Python or Go app

Caddyfile
app.example.com {
	import security
	encode zstd gzip
	reverse_proxy localhost:3000
}

Cache lifetimes for the app's static files usually come from the framework (Next.js, for instance, marks /_next/static/ immutable). If the report's cache finding lists them anyway, add a matcher as in step 3.

5. WordPress with PHP-FPM

Caddyfile
example.com {
	import security
	root * /var/www/wordpress
	encode zstd gzip

	@hidden {
		path /.* /wp-config.php /xmlrpc.php
		not path /.well-known/*
	}
	error @hidden 404

	@versioned path *.css *.js *.woff2
	header @versioned Cache-Control "public, max-age=31536000"
	@images path *.jpg *.jpeg *.png *.webp *.avif *.svg *.ico
	header @images Cache-Control "public, max-age=604800"

	php_fastcgi unix//run/php/php8.3-fpm.sock
	file_server
}

php_fastcgi includes the rewrite WordPress needs for pretty permalinks. CSS and JavaScript get a year because WordPress adds ?ver= to their URLs, which changes on every update. Leave /xmlrpc.php out of @hidden if you use Jetpack or the WordPress mobile apps. Match the socket path to your PHP version (ls /run/php/).

Caddy renders every page through PHP on every request. It has no page cache, so time to first byte depends on WordPress; use a caching plugin that writes static files, or a cache layer in front.

6. Validate and reload

Shell
caddy fmt --overwrite /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

validate catches typos and unknown directives before they reach the running server, and a reload that fails leaves the old configuration in place.

Platform notes

Behind Cloudflare

Set Cloudflare's SSL/TLS mode to Full (strict). Caddy's own Let's Encrypt certificate is valid, so strict mode works without an origin certificate. With Flexible, Cloudflare talks plain HTTP to Caddy, Caddy redirects to HTTPS, and visitors get an endless redirect loop. If your app needs visitors' real IP addresses, list Cloudflare's ranges under trusted_proxies in the global options; getReport's own Caddyfile does this.

Docker

Publish ports 80 and 443 (TCP and UDP) on the Caddy container only and keep the app containers internal. Mount a volume on /data, or certificates are requested again on every container rebuild and you can hit Let's Encrypt's rate limits.

Rate limiting

Not built in. The community caddy-ratelimit module needs a custom build with xcaddy; on a small site, a rate-limit rule at a CDN is simpler.

Verify

  1. SSL/TLS checker: served over HTTPS, http:// redirects to https://, TLS 1.0/1.1 refused, certificate chain valid with 30 or more days left, HSTS set.
  2. Security headers checker: every header finding passes, no Server or X-Powered-By line.
  3. HTTP/2 test: h2 negotiated, h3 advertised, compression and cache findings pass.
  4. From a terminal, curl -sI https://example.com/does-not-exist | grep -i -E "^HTTP|strict" shows 404 and the HSTS line.

Common mistakes

  • Cloudflare set to Flexible. Symptom: "too many redirects". Fix: Full (strict).
  • No encode line. Symptom: the compression finding lists your own HTML, CSS and JavaScript. Fix: encode zstd gzip in every site block.
  • Headers without deletion or defer in front of an app. Symptom: two X-Frame-Options values, or the app's Server: gunicorn/… next to Caddy's. Fix: keep -Server in the snippet or add defer.
  • Ports closed or DNS not pointing at the server yet. Symptom: no certificate, and the logs repeat ACME errors. Caddy can validate on port 80 or 443, so at least one must be reachable from the internet and the domain must already resolve to this server.
  • Expecting a page cache. Symptom: WordPress TTFB unchanged after moving to Caddy. Fix: a caching plugin or a cache layer; Caddy serves, it does not cache.
Check your site before and after Check