Serve static html only for specific domain + path

1. The problem I’m having:

I have a service that can serve for various domains (fronted by Caddy). For one particular domain (the “main” one) I want to serve a static blog. The problem is I can’t seem to get Caddy to serve it.

2. Error messages and/or full log output:

There is no error. The request returns a 404 and the application log (the backend that Caddy is reverse proxy for) does not receive the request. So Caddy appears to be attempting to serve it but the response is a 404 (see log entry below)

2024/09/30 23:19:59.765 INFO    http.log.access.log0    handled request {"request": {"remote_ip": "186.77.197.105", "remote_port": "20503", "client_ip": "186.77.197.105", "proto": "HTTP/2.0", "method": "GET", "host": "linktaco.com", "uri": "/blog/index.html", "headers": {"Accept-Encoding": ["gzip, deflate, br, zstd"], "Upgrade-Insecure-Requests": ["1"], "Sec-Fetch-Mode": ["navigate"], "Sec-Fetch-Site": ["cross-site"], "Te": ["trailers"], "User-Agent": ["Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"], "Accept-Language": ["en-US,en;q=0.5"], "Dnt": ["1"], "Cookie": ["REDACTED"], "Sec-Fetch-Dest": ["document"], "Priority": ["u=0, i"], "Accept": ["text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8"]}, "tls": {"resumed": false, "version": 772, "cipher_suite": 4865, "proto": "h2", "server_name": "linktaco.com"}}, "bytes_read": 0, "user_id": "", "duration": 0.000150039, "size": 0, "status": 404, "resp_headers": {"Server": ["Caddy"], "X-Frame-Options": ["DENY"], "Permissions-Policy": ["interest-cohort=()"], "Referrer-Policy": ["no-referrer-when-downgrade"], "Alt-Svc": ["h3=\":443\"; ma=2592000"], "X-Xss-Protection": ["1; mode=block"], "Content-Security-Policy": ["default-src * 'unsafe-inline'; img-src * 'unsafe-inline'; style-src * 'unsafe-inline'"], "Strict-Transport-Security": ["max-age=31536000;"], "X-Content-Type-Options": ["nosniff"]}}

The directory permissions are permissive (0755) all the way so there should be no issue reading. I verified here:

# su -m www -c 'ls /usr/home/links/blog'
index.html

The only caddy specific changes in the /etc/rc.conf file are:

caddy_enable="YES"
caddy_user="www"
caddy_group="www"

3. Caddy version:

v2.8.4

4. How I installed and ran Caddy:

pkg install caddy

a. System environment:

FreeBSD ah-web01 14.1-RELEASE-p4 FreeBSD 14.1-RELEASE-p4 GENERIC amd64

b. Command:

service caddy start

c. Service/unit/compose file:

Default FreeBSD service file

#!/bin/sh
. /etc/rc.subr

name=caddy
rcvar=caddy_enable
desc="Powerful, enterprise-ready, open source web server with automatic HTTPS written in Go"

load_rc_config $name

# Defaults
: ${caddy_enable:=NO}
: ${caddy_adapter:=caddyfile}
: ${caddy_config:="/usr/local/etc/caddy/Caddyfile"}
: ${caddy_admin:="unix//var/run/${name}/${name}.sock"}
: ${caddy_command:="/usr/local/bin/${name}"}
: ${caddy_directory:=/var/db/caddy}
: ${caddy_extra_flags:=""}
: ${caddy_logdir:="/var/log/${name}"}
: ${caddy_logfile:="${caddy_logdir}/${name}.log"}
: ${caddy_user:="root"}
: ${caddy_group:="wheel"}

# Config and base directories
: ${XDG_CONFIG_HOME:="${caddy_directory}/config"}
: ${XDG_DATA_HOME:="${caddy_directory}/data"}
export XDG_CONFIG_HOME XDG_DATA_HOME

# Default admin interface
export CADDY_ADMIN="${caddy_admin}"

command="${caddy_command}"
pidfile="/var/run/${name}/${name}.pid"

required_files="${caddy_config} ${caddy_command}"

start_precmd="caddy_precmd"
start_cmd="caddy_start"
stop_precmd="caddy_prestop"

# JSON is the native format, so there is no "adapter" for it
if [ "${caddy_adapter}" = "json" ]; then
    caddy_flags="--config ${caddy_config}"
else
    caddy_flags="--config ${caddy_config} --adapter ${caddy_adapter}"
fi

# Extra Commands
extra_commands="configtest reload reloadssl"
configtest_cmd="caddy_execute validate ${caddy_flags}"
reload_cmd="caddy_execute reload ${caddy_flags}"
reloadssl_cmd="caddy_execute reload --force ${caddy_flags}"

caddy_execute()
{
    /usr/bin/su -m "${caddy_user}" -c "${caddy_command} $*"
}

caddy_precmd()
{
    # Create required directories and set permissions
    /usr/bin/install -d -m 755 -o "${caddy_user}" -g "${caddy_group}" ${caddy_directory}
    /usr/bin/install -d -m 700 -o "${caddy_user}" -g "${caddy_group}" ${caddy_directory}/config
    /usr/bin/install -d -m 700 -o "${caddy_user}" -g "${caddy_group}" ${caddy_directory}/data
    /usr/bin/install -d -m 755 -o "${caddy_user}" -g "${caddy_group}" ${caddy_logdir}
    /usr/bin/install -d -m 700 -o "${caddy_user}" -g "${caddy_group}" /var/run/caddy
    if [ -e ${caddy_logfile} ]; then
        /bin/chmod 644 ${caddy_logfile}
        /usr/sbin/chown "${caddy_user}:${caddy_group}" ${caddy_logfile}
    else
        /usr/bin/install -m 644 -o "${caddy_user}" -g "${caddy_group}" /dev/null ${caddy_logfile}
    fi
}

caddy_start()
{
    echo -n "Starting caddy... "
    /usr/bin/su -m ${caddy_user} -c "${caddy_command} start ${caddy_flags} \
        ${caddy_extra_flags} --pidfile ${pidfile}" >> ${caddy_logfile} 2>&1
    if [ $? -eq 0 ] && ps -ax -o pid | grep -q "$(cat ${pidfile})"; then
        echo "done"
        echo "Log: ${caddy_logfile}"
    else
        echo "Error: Caddy failed to start"
        echo "Check the caddy log: ${caddy_logfile}"
    fi
}

caddy_prestop()
{
    local result

    echo -n "Stopping caddy... "

    result="$(caddy_execute stop ${caddy_flags} 2>&1)"
    if [ ${?} -eq 0 ]; then
        echo "done"
        exit 0
    else
        if echo "${result}" | grep -q -e "connection refused" \
            -e "connect: no such file or directory"; then

            echo "admin interface unavailable; using pidfile"
            return 0
        else
            echo "Error: Unable to stop caddy"
            echo "Check the caddy log: ${caddy_logfile}"
            return 1
        fi
    fi
}

run_rc_command "$1"

d. My complete Caddy config:

{
        on_demand_tls {
                ask http://localhost:5004/_check/domain
        }
        debug
}

https:// {
        bind 174.136.99.171
        tls {
                on_demand
        }

        header {
                # disable FLoC tracking
                Permissions-Policy interest-cohort=()

                # enable HSTS
                Strict-Transport-Security max-age=31536000;

                # disable clients from sniffing the media type
                X-Content-Type-Options nosniff

                # clickjacking protection
                X-Frame-Options DENY

                # keep referrer data off of HTTP connections
                Referrer-Policy no-referrer-when-downgrade

                Content-Security-Policy "default-src * 'unsafe-inline'; img-src * 'unsafe-inline'; style-src * 'unsafe-inline'"

                X-XSS-Protection "1; mode=block"
        }

        @ltblog {
                host linktaco.com
                path /blog/*
        }
        handle @ltblog {
                root * /usr/home/links/blog
                file_server
        }

        log {
                output file /var/log/caddy/links-access.log
                format console
        }

        reverse_proxy localhost:5000
        encode zstd gzip
}

5. Links to relevant resources:

You probably don’t need any of this. Only add these if you know you need them. Blindly adding security headers can break or downgrade your security if your upstream app tries to set its own headers.

Are you sure that the user Caddy runs as can read files in this directory? Try moving the files to somewhere else like /srv.

You just showed your access logs. Please take a look at your Caddy process logs, that’s where the debug logs get written, which might tell you why Caddy can’t access the files.

1 Like

Thank you! I didn’t realize there was a different process log file (I’m fairly new to Caddy)

The issue was that caddy was looking for /usr/home/links/blog/blog/index.html so simply changing the config to root * /usr/home/links did the trick.

I don’t like the idea of this config so I will definitely move the path somewhere else just as a precaution but it appears to be resolved.

Thanks for the help!

Ah, in that case the alternate fix is to use handle_path instead of handle but it doesn’t support named matchers so you would need to use uri strip_prefix /blog instead (see the docs for handle_path for the explanation).

2 Likes

Excellent, I will! Thank you!

That was exactly what I needed. Just putting the full path back to the original and using url strip_prefix /blog worked perfectly. Thank you for the help!

1 Like