# 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.

Updated 2026-09-25 · Other platforms · HTML version: https://getreport.app/guides/caddy-the-two-line-https-server

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](https://getreport.app/tools/ssl-check), then the [security headers checker](https://getreport.app/tools/security-headers).

## 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](https://getreport.app/guides/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

> **Free tool:** [SSL / TLS checker](https://getreport.app/tools/ssl-check): Certificate expiry and chain, TLS versions, HTTPS enforcement, HSTS and mixed content for any site — the checks that stand between your visitors and a browser warning.

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](https://getreport.app/guides/img/caddy-the-two-line-https-server/tls.webp "Before Caddy: one failed finding worth 15 points. With a domain pointed at Caddy, the certificate, redirect and TLS findings pass without configuration.")

> **Check: The site is served over HTTPS.** Browsers label HTTP pages "Not secure" and Google uses HTTPS as a small ranking signal. Anyone on the network can read or alter what visitors see.
>
> 1. Get a certificate (Let's Encrypt is free) and enable HTTPS on your host.
> 2. Redirect every http:// URL to https:// with a 301.

> **Check: Certificate expiry.** When the certificate expires, every browser shows a full-page warning and most visitors leave. Automatic renewals fail silently more often than you would expect.
>
> 1. Renew the certificate now; with Let's Encrypt, run certbot renew and check the auto-renew job's logs.
> 2. Add a monitor so you get an email 14 days before the next expiry.

> **Check: The server only accepts TLS 1.2 or newer.** TLS 1.0 and 1.1 have known weaknesses and every major browser dropped them in 2020. A server that still accepts them lets an attacker downgrade a connection to one they can break.
>
> 1. Set the minimum protocol to TLS 1.2 (nginx: ssl_protocols TLSv1.2 TLSv1.3; Apache: SSLProtocol -all +TLSv1.2 +TLSv1.3).
> 2. Behind Cloudflare or another CDN, set "Minimum TLS Version" to 1.2 in its dashboard.

> **Check: Strict-Transport-Security header is set.** HSTS tells browsers to always use HTTPS for your site, so after the first visit a typed address or an old http:// link never starts on an unencrypted connection. Only the preload list covers the very first visit.
>
> 1. Send the header: Strict-Transport-Security: max-age=31536000; includeSubDomains.
> 2. Start with a shorter max-age (e.g. 86400) if you are not sure every subdomain supports HTTPS.

The expiry finding warns below 30 days and fails below 7 ([TLS certificates](https://getreport.app/learn/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](https://getreport.app/guides/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`:

```caddy
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:

```caddy
(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](https://getreport.app/guides/security-headers-from-zero) 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](https://hstspreload.org/). Removal takes months.

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

```caddy
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

```caddy
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

```caddy
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

```bash
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.
