Dia Ḋuit, Gꞃéagóiꞃ is ainm dom agus fáilte go dtí mo ṡuíoṁ gꞃéasáin

Updating to Caddy 2

I've finally bit the bullet and made the long-overdue update to the latest version of Caddy. Read ahead to find out how I made my changes.


Caddy, I should prefix, is an open-source web-server. It posits itself as a straightforward and flexible server for the average joe. In a market dominated by heavy-weight behemoths, it's a breath of fresh air to have a real alternative.

And opposed to the likes of Apache and NGINX, Caddy is above all, effortless. As complex as web servers can grow to become, this is particularly helpful.

I've been hosting on Caddy for some years now without a hitch. I've even discussed it before in a previous article. That was quite some time ago, and quite a few versions behind too. And since then, I've found myself with a growing need to update to the latest version.

The latest version being Caddy 2. Version 2 is a complete overhaul of Caddy and incompatible with the first. With an entirely new code base written from scratch, it seeks to further enhance.

Besides the differences under the hood, the user-friendly configuration remains the same. Perhaps a little more syntactic sugar and emphasis on the daemon, but still as easy as I remember.


One of the primary reasons I chose Caddy at first was the ease of installation. Likewise, this equally makes it especially easy to reinstall. Caddy simply comes as a binary, meaning it's just a matter of swapping out the old for the new.

Following the instructions, I download using:

echo "deb [trusted=yes] https://apt.fury.io/caddy/ /" \
    | sudo tee -a /etc/apt/sources.list.d/caddy-fury.list
sudo apt update
sudo apt install caddy

Like before, I place my Caddy binary under /usr/local/bin and ensure ownership belongs to root. This prevents other accounts from being able to modify the executable. Whilst root owns the binary, I choose to only execute it using non-root accounts by setting the permissions to 755.

755 gives root read/write/execute permissions for Caddy, but every other user only read and execute.

Caddy Setup

My config has mostly remained the same, but it's worthwhile running over my setup. For starters, I keep my entire Caddy configuration under a etc/caddy directory. Following common practice, I ensure this directory also belongs to a www-data group, with root as owner.

This enables Caddy with read and write access to the configuration, via the www-data group. On Ubuntu, www-data is the default user and group for web servers.

gregory@octavius:/etc/caddy$ ll
total 32
drwxr-xr-x   2 root www-data  4096 Jul 31 17:03 ./
drwxr-xr-x 108 root root     12288 Jul 31 11:58 ../
-rw-r--r--   1 root www-data  1069 Jul 31 23:05 Caddyfile
-rw-r--r--   1 root www-data  1369 Dec 19  2018 Caddyfile.v1
-rw-r--r--   1 root www-data  1904 Jul 31 23:06 common.conf
-rw-r--r--   1 root www-data  1950 Jul 30 22:19 common.conf.v1

Above, you can see how I've held onto my previous Caddyfile.v1 and common.conf.v1 by appending a .v1 suffix.

Common Configuration

My sites share the same settings, so it made sense for me to create a common configuration they could inherit. I call this common.conf and import it into the Caddyfile for each domain.

For Caddy 2 I've had to make some syntactic changes, but otherwise, it's identical.

The first portion is self-explanatory as Caddy syntax typifies:

# compression
encode gzip

# port setup for php
php_fastcgi localhost:9000

# serve files to client

# return denied on 403 status
respond /forbidden 403

fastcgi has changed to php_fastcgi for Caddy 2. Similarly respond has replaced the status keyword.

I've also added file server to spin a static file server when required.

For the header portion, I've kept my customised responses. The comments give enough explanation of their purpose:

# http custom response header
header {

    # Enable cross-site filter (XSS) and tell browser to block detected attacks
    X-XSS-Protection "1; mode=block"

    # Prevent some browsers from MIME-sniffing a response away from the declared Content-Type
    X-Content-Type-Options "nosniff"

    # Disallow the site to be rendered within a frame (clickjacking protection)
    X-Frame-Options "DENY"

    # Referrer Policy
    Referrer-Policy "no-referrer"

    # Remove Server field

    # Disable selective browser features and APIs
    Feature-Policy "camera 'none'; geolocation 'none'; microphone 'none'; payment 'none'; speaker 'none'; usb 'none'; battery 'none'"

    # Content Security Policy to force https
    Content-Security-Policy "default-src https: 'unsafe-inline' 'unsafe-eval'"

    # Certificate Transparency Policy Reporting
    Expect-CT "max-age=0, report-uri=\"https://gregory@gregorykelleher.com/.well-known/ct_report\""

    # Caching
    Cache-Control "max-age=604800, immutable"

There's a new header on the block since my last configuration. Feature Policy was introduced recently to provide a mechanism to allow/deny browser features in a document.

In the latest working draft, this experimental header has been renamed to Permissions-Policy

I don't have much purpose in setting this header but it's at least somewhat worthwhile doing in order to maintain a reputable score on securityheaders.com.

The syntax follows the following form:

Feature-Policy: <directive> <allowlist>

I'd prefer to blacklist everything by default and whitelist as needed. But at this time of writing, there's no means of defaulting to none for every directive, similar to the Content-Security-Policy header, i.e.

Feature-Policy: default "none"

There's an open issue listed but no fix delivered yet. The only alternative then is to specify each directive in turn, setting each to none. It's unnecessarily verbose and the list of directives is likely to grow in time too.

For that reason, I only specify a handful of the most important directives in my configuration.

Moving on, my TLS configuration is the same again. The default Caddy spec is already perfect, so no need to prescribe cipher-suites.

# tls config
tls gregory@gregorykelleher.com {

    protocols tls1.2 tls1.3

    curves secp521r1 secp384r1 secp256r1 x25519

Lastly and importantly, I have some exhaustive security configuration settings too. As spelled out below:

# Security config

# deny all access to these folders
@denied_folders {
    path_regexp /(.git|cache|bin|logs|backups|tests)/.*$
rewrite @denied_folders /forbidden

# deny running scripts inside core system folders
@denied_system_scripts {
    path_regexp /(system|vendor)/.*\.(txt|xml|md|html|yaml|php|pl|py|cgi|twig|sh|bat)$
rewrite @denied_system_scripts /forbidden

# deny running scripts inside user folder
@denied_user_folder {
    path_regexp /user/.*\.(txt|md|yaml|php|pl|py|cgi|twig|sh|bat)$
rewrite @denied_user_folder /forbidden

# deny access to specific files in the root folder
@denied_root_folder {
    path_regexp /(LICENSE.txt|composer.lock|composer.json|nginx.conf|web.config|htaccess.txt|\.htaccess)
rewrite @denied_root_folder /forbidden

# Global rewrite
try_files {path} {path}/ /index.php?_url={uri}


In my Caddyfile below, note how I inject the common.conf for my domain:

gregorykelleher.com {
    root * /var/www/gregorykelleher

    # Enable HTTP Strict Transport Security (HSTS) to force clients to always use https
    header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"

    # lolz
    header X-Custom-Header "No one expects the Spanish Inquisition!"

    #import common configurations
    import common.conf

www.gregorykelleher.com {
    # redirect from www
    redir https://gregorykelleher.com

Cutting down on verbosity aids readability. It also means I get to share the same configuration across all my domains.

Re-enabling Git integration

Unfortunately with Caddy 2, the excellent plugin ecosystem is no longer compatible. In-built Caddy modules now cover what plugins used to do. That said, there's no git integration yet.

In my previous setup, I had a line in my Caddyfile that would synchronise to my remote repository:

# git repository sync
git https://github.com/gregorykelleher/gregorykelleher_website.git

Periodically, Caddy would fetch any new changes and reload my live website.

There's work happening to deliver a new git module for Caddy 2, but it's still in development.

I felt the best interim solution would be to write a simple script that would essentially do the same thing for me. Inside my var/www/ directory, I created a cron_git_pull.sh script like so:


# For every git repository in this directory
# update all branches for remote and prune
# only fast forward merge upstream changes


function git_update()
    git remote update -p; git merge --ff-only @{u}

for REPO in `ls "$REPOSITORIES/"`; do
    if [ -d "$REPOSITORIES/$REPO/.git" ]
        echo "Updating $REPOSITORIES/$REPO at `date`"
        (cd "${REPO}" && git_update)

Instead of doing a regular olde git pull I opted to be a little smarter with my git_update() function.

The git remote update fetches any updates from the remote. The -p flag is the --prune option. I use git remote update over git pull for several reasons.

My biggest grievance is that git pull tends to fall over unless you're always dealing with fast-forward merges. It could run into a merge conflict and then by default, git pull will create a merge commit, which I prefer to avoid.

Using git remote update -p will pull all the latest commits from upstream (pruning too) and then git merge --ff-only @{u} will fast-forward the local branch to the latest commit on upstream.

If there's no local commits, there's no worry of merge conflicts and it'll return successful. If there is any local work then unlike git pull it's not immediately going to drop into a prompt to fix the merge conflict.

Set up Cron job

Lastly, I'd like to schedule this script with cron to periodically fetch any new changes. Inside my root crontab I've added a line to execute the script every hour of the day:

 # git update repositories every hour
 0 * * * * cd /var/www/ && /bin/bash cron_git_pull.sh


That about wraps it up. Not much to conclude on, but that at least indicates the update went smoothly. I feel that's down to the inherent simplicity of Caddy. All it took was a swap in of the new binary.

There were a few minor syntactic changes naturally, but nothing huge. Hopefully the plugin support should mature with time and maybe I'll even get my favourite git plugin back!

Date: August 2nd at 5:16pm