Websites & apps

Caddy redirects, headers, gzip/zstd compression and caching

Practical Caddyfile recipes for www redirects, moved pages, gzip and zstd compression, security headers and browser cache rules, organised cleanly with handle blocks.

IntermediateLinux server5 min read

Once a site runs on Caddy, the next jobs are usually the same: pick www or the bare domain, redirect old URLs, compress responses, add security headers and tell browsers how long to cache files. Each of these is one or two lines in a Caddyfile. This guide collects working recipes you can paste into your site blocks.

Before you start

  • A Linux VPS with Caddy running as a service: Install Caddy web server with automatic HTTPS.
  • A working site block in /etc/caddy/Caddyfile, either serving files or proxying an app.
  • DNS records for every hostname you redirect from, including www, so Caddy can get certificates for them.

After every change in this guide, validate and reload:

Bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

A failed reload leaves the old config running, so a typo will not take your site down.

Redirect www to the bare domain (or the reverse)

Give the hostname you do not want its own small site block that only redirects:

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

example.com {
	root * /var/www/example.com
	file_server
}

{uri} keeps the path and query string, so www.example.com/blog?page=2 goes to example.com/blog?page=2. permanent sends a 301. To prefer www, swap the two hostnames.

Caddy already redirects HTTP to HTTPS for every site with a real hostname, so you do not need a separate rule for that.

Redirect single pages and old paths

redir inside a site block takes an optional path matcher first:

example.com {
	redir /old-page /new-page permanent
	redir /shop /store/ 301
	redir /docs/* /help/{http.request.uri.path.1} permanent

	root * /var/www/example.com
	file_server
}
  • The first two lines move one URL each. You can use permanent or an explicit status code.
  • In the third line, {http.request.uri.path.1} is the second segment of the path, so /docs/install goes to /help/install.

For many redirects, keep them in their own file and import it into the site block, so the main config stays short:

example.com {
	import /etc/caddy/redirects/example.com.caddy

	root * /var/www/example.com
	file_server
}

The imported file holds one redir line per old URL, in the same format as above. Exact paths are cheap to match, so a few hundred lines is fine.

Move a whole old domain

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

Keep this block for at least several months so search engines and old links follow the move.

Compression with gzip and zstd

example.com {
	encode zstd gzip
	root * /var/www/example.com
	file_server
}

Caddy picks the best encoding the browser accepts, in the order you list. It only compresses text-like types and skips responses that are already compressed, such as JPEG or MP4. To serve pre-compressed files you built ahead of time, such as app.js.gz next to app.js, add precompressed to file_server:

file_server {
	precompressed zstd br gzip
}

Security headers

The header directive sets response headers. Group them in one block:

example.com {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		Permissions-Policy "camera=(), microphone=(), geolocation=()"
		-Server
	}
	root * /var/www/example.com
	file_server
}

The -Server line removes the Server: Caddy header.

Warning: HSTS tells browsers to refuse plain HTTP for the whole max-age. Only use includeSubDomains if every subdomain has working HTTPS. Start with a short value such as max-age=300 while you test.

A Content-Security-Policy header gives strong protection but depends heavily on your site's scripts. Build it in report-only mode first so it does not break pages.

Browser cache rules

Use matchers to send different Cache-Control values for different files. Versioned assets, whose file names change when they change, can be cached for a long time. HTML should be checked on every visit.

example.com {
	@static path *.css *.js *.woff2 *.png *.jpg *.jpeg *.webp *.svg *.ico
	header @static Cache-Control "public, max-age=31536000, immutable"

	@html path / *.html
	header @html Cache-Control "no-cache"

	root * /var/www/example.com
	file_server
}

Note: Only use immutable and a one-year max-age for files whose names include a hash or version. If you overwrite style.css in place, use a shorter time such as max-age=86400.

Keep larger configs tidy with handle

handle blocks are mutually exclusive: the first one that matches wins, and the rest are skipped. This keeps API and static rules apart:

example.com {
	encode zstd gzip

	handle /api/* {
		header Cache-Control "no-store"
		reverse_proxy 127.0.0.1:8000
	}

	handle /downloads/* {
		root * /srv/files
		file_server browse
	}

	handle {
		root * /var/www/example.com
		try_files {path} /index.html
		file_server
	}
}

The last handle has no matcher, so it catches everything else. Here it also sends unknown paths to index.html, which is what single-page apps need.

For snippets you repeat on every site, define them once at the top of the file and import them:

(security) {
	header {
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
}

example.com {
	import security
	root * /var/www/example.com
	file_server
}

Check it works

Test a redirect without following it:

Bash
curl -I https://www.example.com/blog

Expect 301 and location: https://example.com/blog. Check compression and headers:

Bash
curl -sI -H "Accept-Encoding: zstd, gzip" https://example.com/ | grep -iE "content-encoding|strict-transport|cache-control"

You should see content-encoding: zstd (or gzip) and the headers you set.

Troubleshooting

  • Redirect loop: a redir sends a path to a URL that matches the same rule, for example redir /blog* /blog/. Make the matcher exact, or put the rule inside a handle that excludes the target.
  • Headers missing on proxied responses: the backend may set its own value and Caddy's header is applied first. Use header >Name value to defer the change until the response comes back, or header_down inside reverse_proxy.
  • No content-encoding header: the response is too small or not a text type, or your test sent no Accept-Encoding header.
  • Certificate errors for www: the www record is missing in DNS, so Caddy cannot validate it. See Fix Caddy certificate errors and ACME failures.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.