I currently use Caddy for the statically generated Hugo Blog, since Caddy seems like a robust webserver written in go (so memory safe) and rather fast. Also it handles https automatically, requesting certificates etc. so no longer having to run anything manually. Also its pretty easy to configure for this use case

#
Minimal Caddy Config

catinashell.de {
    root * /var/www/blog/public
    file_server
}

this is a pretty basic working example, you generate the content via hugo inside /var/www/blog, move ownership to caddy chown -R caddy:caddy /var/www/blog and done.

#
Improving the Config

Well but this isn’t enough, like I also want HSTS to be enabled (preventing dropping the connection from https to http), have a content-security-policy, allowing only resources from my own server. Redirecting invalid routes to the 404.html site, etc.

#
Security Headers (& their Explanations)

This is probably the most important part, adding a few security Headers for:

  • HSTS
  • Content-Security-Policy
  • Referrer Policy
  • Removing some Information Headers in the Response
  • Disabling the site to be embeded as an iframe.
catinashell.de {
    ...

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"

        Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-src 'none'; base-uri 'self'; frame-ancestors 'none'; upgrade-insecure-requests"


        Referrer-Policy strict-origin-when-cross-origin
        X-Content-Type-Options nosniff
        X-Frame-Options DENY

        -X-Powered-By
        -Server
    }
}

Strict-Transport-Security: This is the HSTS header, telling the browser, that it should only connect via https in the future, and don’t use http ever. This prevents protcol downgrading attacks (https -> http)

Content-Security-Policy: Tells the browser to only load resources from the specified origins, lets say an evil attacker manages to embed a malicious <script> tag inside the site, or load a script via <script src="https://evil.org/evil.js">. The browser won’t run these scripts with the shown policy, as it only loads js from my own page. This also blocks the site to be embeded as an iframe via frame-ancestors 'none', tells the browser to upgrade connections & blocks some legacy plugins via onject-src 'none'

Referrer-Policy: strict-origin-when-cross-origin, this means, that if you click on an external link the opened site will only see that you came from https://catinashell.de, not that you came from https://catinashell.de/posts/caddy-security/.

X-Content-Type-Options: nosniff tells the browser to not try guessing the mime type of resources found, as this could in certain cases be exploited, e.g. you have a .txt file on your server, containing js code, this should only be treatened as a txt file, but the browser might guess from the content its js and execute it. This is mostly relevant if you let users upload content, still best practice.

X-Frame-Options: DENY tells browsers here again, to not allow this site to be embedded as an iframe, protecting against click jacking attacks.

-X-Powered-By & -Server: This ommits these Headers in the response, giving a potential attacker less information about the server. Well for this blog it’s kinda irrelevant, as my whole stack can be easily figured out by reading the About Section + this post xD. But it might hinder automatic vuln scans a bit at least. Its clearly not a complete shield against any analysis of your stack. There are tons of ways to figure this out (just analyse the link structures, which scripts run etc. lets you easily figure out, that this site was generated with Hugo, potentially due to changes over time also the version (or at least version range)).

#
Disable Admin Endpoint

The first easy fix is disabling the admin endpoint, reachable on localhost:2019, this initself, doesn’t seem like a big security risk. But lets say an attacker manages to trick the server into making a request to itself, they could access the endpoint. Loading a different config, maybe redirecting all visitors to an evil host (well this wouldn’t happen so easily on a pure caddy fileserver and no other services running, but still, best practice is best practice).

{
  admin off
}

#
Encodings

This allows compressing the transmitted pages, increasing loading speed. An easy gain. zstd should be the standard in modern browsers, and for older ones gzip works as a fallback.

catinashell.de {
    ...

    encode zstd gzip
}

#
Not Found should display the 404.html page

Achieved by:

catinashell.de {
    ...

    handle_errors {
        @404 expression {http.error.status_code} == 404
        rewrite @404 /404.html
        file_server
    }

}

Note: you can also easily handle other errors by changing the error code, e.g. 403, 401, 500 etc. (But for now 404 is enough for me, as I don’t have any locked parts, its just a public blog …, and I don’t expect an internal server error in a static webpage)

#
Cache Assets

You can cache in Caddy Assets easily, first specify with the @static label what are cachable assets (fonts, imaages, js, css). Then set a Cache-Control header for these assets, in my case set to 30 days (in seconds). So if a visitor revists your site, the images won’t need to be loaded every time again.

catinashell.de {
  ...

  @static {
    file
    path *.ico *.css *.js *.gif *.webp *.avif *.jpg *.jpeg *.png *.svg *.woff *.woff2
  }
  header @static Cache-Control max-age=2592000
}

#
Have www.domain redirect to domain

I don’t see a point in including the www. part, it just makes the domain longer for no reason … So I like redirecting to the base domain. This can be done by:

www.catinashell.de {
    redir https://catinashell.de{uri} permanent
}

#
The full config

Caddy /etc/caddy/Caddyfile
{
  admin off
}

www.catinashell.de {
    redir https://catinashell.de{uri} permanent
}

catinashell.de {
    root * /var/www/blog/public
    file_server

    encode zstd gzip

    handle_errors {
        @404 expression {http.error.status_code} == 404
        rewrite @404 /404.html
        file_server
    }

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"

        Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-src 'none'; base-uri 'self'; frame-ancestors 'none'; upgrade-insecure-requests"


        Referrer-Policy strict-origin-when-cross-origin
        X-Content-Type-Options nosniff
        X-Frame-Options DENY

        -X-Powered-By
        -Server
    }

    @static {
      file
      path *.ico *.css *.js *.gif *.webp *.avif *.jpg *.jpeg *.png *.svg *.woff *.woff2
    }
    header @static Cache-Control max-age=2592000
}