Cool Caddy config tricks for your self-hosted domain

Very recently I switched from running a couple side-projects that were hosted on AWS EC2 to a single Hetzner Cloud VM. During that transition, this page (jarv.org) was moved to it from GitHub pages. Static generation using Hugo stayed the same, but in the process of switching VMs I checked out Caddy as a new webserver. After making the switch, this ended up being a great NGINX replacement for all the sites hosted on what is now a single VM. The ergonomics of the config language was particularly impressive so I wanted share how simple and flexible it can be!

HTTPs made easy

First to state the obvious, Caddy is incredible easy to configure when it comes to HTTPs. It supports automatic HTTPs out of the box with Let’s Encrypt or Zero SSL. With only a few lines of config you can setup a large number of sites without having to go through the certificate issuer dance, or use a CDN like CloudFlare or CloudFront to handle certificates for you.

For example, here is how simple the configuration is for this site:

jarv.org {
  @cache path /font/*
  header @cache Cache-Control max-age=604800

  handle {
          root * /var/opt/www/jarv.org
          file_server {
            precompressed gzip

This does the following:

This will automatically redirect HTTP requests to HTTPs, which is likely what you want most of the time. If you want to have a plain HTTP version and HTTPs version that is possible too, for that see the next example.

Creating a site that echos an IP address

There are some sites that I sometimes use to echo my public IP like curl ifconfig.io. With Caddy, it’s very simple to replicate this with a Caddy configuration, so I replicated it with curl ip.jarv.org.

Caddy has a simple respond directive, here is how ip.jarv.org echos your IP address:

Note: This uses the new heredoc syntax in version 2.7.0

(echoIP) {
  header Content-Type text/plain

  respond <<EOF

EOF 200

  encode zstd gzip
  import logging ip.jarv.org

ip.jarv.org {
  import echoIP
http://ip.jarv.org {
  import echoIP

Creating a subdomain that echos a HTTP status code

Finally, I was thinking that it would be handy to have a way to echo back an HTTP status code, using curl <http status code>.resp.jarv.org. This requires a wildcard certificate, which is a bit more involved since it requires a DNS provider module to be compiled into Caddy (only required here for wildcard certs).

I run Caddy on a Debian server in Hetzner Cloud, with Cloudflare provisioning DNS. Caddy is installed using the Caddy debian package, and xcaddy to install the Cloudflare DNS provider module. The module is compiled using xcaddy and there is great documentation for how to properly incorporate your custom build using the Debian package.

Here is the configuration for responding with any specified HTTP status code:

(echoResp) {
  header Content-Type text/plain

  respond <<EOF

EOF {http.request.host.labels.3}
  encode zstd gzip
  import logging resp.jarv.org

*.resp.jarv.org {
  import echoResp
  tls {
    dns cloudflare REDACTED
http://*.resp.jarv.org {
  import echoResp

Now, if I have want to generate a 404 I can request 404.resp.jarv.org, or for a 500 500.resp.jarv.org or any other status code as a subdomain!

Update: Below is the more complicated configuration running jarv.org that responds with emojis, short description, and also catches strings that are not valid HTTP response codes. Here are some example responses:

$ curl 200.resp.jarv.org
200 😃 OK

$ curl 201.resp.jarv.org
201 🎉 Created

$ curl 499.resp.jarv.org
499 http status code

$ curl 418.resp.jarv.org
418 🍵 I'm a teapot

$ curl herpderp.resp.jarv.org
💥 herpderp doesn't look like a valid HTTP status code!

And this is the Caddy configuration, that utilizes the map directive to look up the short descriptions for status codes:

(echoResp) {
  header Content-Type "text/html; charset=utf-8"

  @valid header_regexp host Host ^([1-5]\d{2}|599)\..*

  map {http.request.host.labels.3} {status_desc} {
    100 "Continue"
    101 "Switching Protocols"
    102 "Processing"
    103 "Early Hints"
    200 "😃 OK"
    201 "🎉 Created"
    202 "Accepted"
    203 "Non-Authoritative Information"
    204 "🙅 No Content"
    205 "Reset Content"
    206 "Partial Content"
    207 "Multi-Status"
    208 "Already Reported"
    226 "IM Used"
    300 "Multiple Choices"
    301 "Moved Permanently"
    302 "Found"
    303 "See Other"
    304 "Not Modified"
    305 "Use Proxy"
    306 "Switch Proxy"
    307 "Temporary Redirect"
    308 "Permanent Redirect"
    400 "❌ Bad Request"
    401 "🔒 Unauthorized"
    402 "Payment Required"
    403 "🚫 Forbidden"
    404 "🕳️ Not Found"
    405 "Method Not Allowed"
    406 "Not Acceptable"
    407 "Proxy Authentication Required"
    408 "Request Timeout"
    409 "Conflict"
    410 "Gone"
    411 "Length Required"
    412 "Precondition Failed"
    413 "Payload Too Large"
    414 "URI Too Long"
    415 "Unsupported Media Type"
    416 "Range Not Satisfiable"
    417 "Expectation Failed"
    418 "🍵 I'm a teapot"
    421 "Misdirected Request"
    422 "Unprocessable Entity"
    423 "Locked"
    424 "Failed Dependency"
    425 "Too Early"
    426 "Upgrade Required"
    428 "Precondition Required"
    429 "Too Many Requests"
    431 "Request Header Fields Too Large"
    451 "Unavailable For Legal Reasons"
    500 "🤯 Internal Server Error"
    501 "Not Implemented"
    502 "Bad Gateway"
    503 "🚧 Service Unavailable"
    504 "Gateway Timeout"
    505 "HTTP Version Not Supported"
    506 "Variant Also Negotiates"
    507 "Insufficient Storage"
    508 "Loop Detected"
    510 "Not Extended"
    511 "Network Authentication Required"
    default "http status code"

  handle @valid {
    respond <<EOF
{http.request.host.labels.3} {status_desc}

EOF {http.request.host.labels.3}

  handle {
    respond <<EOF
💥 {http.request.host.labels.3} doesn't look like a valid HTTP status code!

EOF 400
  import logging resp.jarv.org

*.resp.jarv.org {
  import echoResp
  tls {
    dns cloudflare REDACTED

http://*.resp.jarv.org {
  import echoResp

Forcing plain HTTP

This is something similar to neverssl.com, or example.com, I think both of these are still used frequently when connecting to wifi when you want to get to the wifi login which required a plain http connection. This couldn’t be simpler with Caddy, below is the configuration I use for nossl.jarv.org.

nossl.jarv.org {
  redir http://nossl.jarv.org permanent

http://nossl.jarv.org {
  header Content-Type "text/html; charset=utf-8"
  header Cache-Control "no-cache, no-store, must-revalidate"
  respond <<NOSSL
<!DOCTYPE html>
<html lang="en">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔓</text></svg>">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
pre {
  font-family: "Courier New", Courier, monospace;

div {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;