04 December 2025

Opt-In Directory Browsing in Caddy Using a Hidden Sentinel File

Sometimes you want Caddy to behave like a normal Single Page Application (SPA) host, but still have the option to enable directory listings only in specific folders without turning browsing on globally.

In my case, I wanted:

  • No directory listing at the site root (/)
  • No browsing by default in any directory
  • Opt-in browsing in a directory only if a special hidden file exists
  • Real 404s for missing static files (e.g. .css, .txt)
  • SPA fallback for “virtual” routes (no file extension)

The solution: use a hidden sentinel file (I chose .browse) and Caddy’s matchers to decide:

  • When to show a directory listing
  • When to serve static files or return 404
  • When to fall back to /index.html for SPA routing

The Final Caddyfile Snippet

.net:443 {
    root * /srv/static

    encode zstd gzip

    # Browsable directories: any non-root path whose directory
    # contains a file named `.browse`
    @browsable_dir {
        not path /      # never browse the site root
        file {
            # Handle both `/dir` and `/dir/` request forms:
            try_files {path}/.browse {path}.browse
        }
    }

    # Requests that look like "static files" (have an extension),
    # e.g. /foo.css, /img/logo.png, /docs/file.pdf
    @static_with_ext path_regexp static_ext \.[^/]+$

    route {
        # 1) Browsable directories (sentinel present)
        handle @browsable_dir {
            file_server browse {
                hide .browse
            }
        }

        # 2) Static-looking requests (with an extension):
        #    serve file if it exists, otherwise 404
        handle @static_with_ext {
            try_files {path} =404

            file_server {
                hide .browse
            }
        }

        # 3) Everything else → SPA fallback
        #    - try to serve real files/dirs
        #    - if none match, fall back to /index.html
        handle {
            try_files {path} {path}/ /index.html
            file_server {
                hide .browse
            }
        }
    }

    # Security headers
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        X-Frame-Options "SAMEORIGIN"
    }

    # Simple text error responses (e.g., "404 Not Found")
    handle_errors {
        respond "{err.status_code} {err.status_text}"
    }
}

How the Matchers Work

1. Browsable directories (@browsable_dir)

@browsable_dir {
    not path /
    file {
        try_files {path}/.browse {path}.browse
    }
}

This does two key things:

  1. not path /
    Ensures the matcher never triggers on the site root. The root path / will never show a directory listing, even if a .browse file exists there.
  2. file { try_files {path}/.browse {path}.browse }
    Checks for a hidden sentinel file named .browse in the directory corresponding to the request path. It works with both /dir and /dir/ request forms.

    For example:
    • Request: /bar/
    • Checks: /srv/static/bar/.browse
    • If that file exists, @browsable_dir matches and browsing is enabled.

2. Static files with extensions (@static_with_ext)

@static_with_ext path_regexp static_ext \.[^/]+$

This matcher catches paths that look like real files because they end with an extension:

  • /foo.css
  • /img/logo.png
  • /docs/file.pdf
  • /foo/thing1.txt

Those requests are handled here:

handle @static_with_ext {
    try_files {path} =404

    file_server {
        hide .browse
    }
}

If the file exists, it’s served normally. If it does not exist, the =404 causes Caddy to emit a real 404, which then flows through handle_errors and becomes a simple text response like:

404 Not Found

Because we use hide .browse in file_server, any direct request to a .browse file will also result in a 404, even if the file exists. The sentinel is never exposed directly, which is exactly what we want.

3. SPA fallback for everything else

handle {
    try_files {path} {path}/ /index.html
    file_server {
        hide .browse
    }
}

Any request that is not:

  • a “browsable directory” (@browsable_dir), and
  • not a “static file with extension” (@static_with_ext)

falls into this final handler. The logic is:

  • Try the request as a file: {path}
  • Then as a directory: {path}/
  • If neither exists, rewrite to /index.html (SPA entrypoint)

That means “virtual” SPA routes like /app/settings or /docs/getting-started will still end up at /index.html, and your JS router can take over from there.


Error Handling

For errors, this simple handler is used:

handle_errors {
    respond "{err.status_code} {err.status_text}"
}

So a missing static file (like /foo/missing.txt) results in a plain-text:

404 Not Found

This keeps error responses easy to see and debug.


Example Directory Structure and Request Behavior

Assume the following directory layout under /srv/static (plus a normal SPA entrypoint at /srv/static/index.html):

/
├── .browse
├── index.html
├── foo
│   └── thing1.txt
└── bar
    ├── .browse
    ├── thing2.txt
    ├── a
    │   └── thing3.txt
    └── b
        ├── .browse
        └── thing4.txt

Here’s how various requests behave with this configuration:

Request URL Matched Handler Result
/ Final handle (SPA) Serves /index.html (SPA entrypoint).
Root never shows a directory listing, even though .browse exists at the root, because of not path / in @browsable_dir.
/.browse @static_with_extfile_server with hide .browse The file exists at /srv/static/.browse, but it is hidden by hide .browse.
Result: 404 Not Found — the sentinel is not directly accessible.
/foo/ Final handle (SPA branch) /foo/ exists as a directory, but there is no .browse inside it and no index.html in /foo/.
Result: 404 Not Found (directory listing is disabled, and there's no index file).
/foo/thing1.txt @static_with_ext Looks like a static file (has .txt extension).
File exists at /srv/static/foo/thing1.txt.
Result: serves thing1.txt (200 OK).
/bar/ @browsable_dir /bar/.browse exists, so @browsable_dir matches.
Result: directory listing for /bar/ via file_server browse, with .browse hidden.
You’ll see entries like thing2.txt, a/, and b/.
/bar/thing2.txt @static_with_ext Static file with extension, exists on disk.
Result: serves thing2.txt (200 OK).
/bar/a/ Final handle /bar/a/ is a real directory, but:
- It does not contain .browse → no directory listing.
- It has no index.html.
Result: 404 Not Found.
/bar/a/thing3.txt @static_with_ext Static file with extension, exists at /srv/static/bar/a/thing3.txt.
Result: serves thing3.txt (200 OK).
/bar/b/ @browsable_dir /bar/b/.browse exists, so this directory is explicitly marked as browsable.
Result: directory listing for /bar/b/, showing thing4.txt (with .browse hidden).
/bar/b/thing4.txt @static_with_ext Normal static file.
Result: serves thing4.txt (200 OK).
/bar/.browse @static_with_extfile_server with hide .browse Sentinel file exists at /srv/static/bar/.browse, but hide .browse makes it behave like a hidden file.
Result: 404 Not Found — again, the sentinel is not directly accessible.
/foo/missing.txt (not in tree) @static_with_ext Matches the static-file matcher, but the file does not exist.
try_files {path} =404 falls through to =404.
Result: 404 Not Found (plain text via handle_errors).

Why Use a Hidden Sentinel File?

This pattern has a few nice properties:

  • Safe by default: Browsing is off everywhere unless you explicitly opt a directory in with .browse.
  • Per-directory control: You can toggle listing for any directory by simply adding or removing a tiny hidden file.
  • Works with SPAs: Non-extension routes still fall back to /index.html, so your front-end router behaves as expected.
  • Real 404s for static files: Missing .css, .js, .txt, etc. return a genuine 404 Not Found.
  • Sentinel stays private: .browse is hidden from directory listings and returns 404 when requested directly.

If you’re hosting a SPA with Caddy and want fine-grained, opt-in directory listings plus correct 404 behavior for static assets, this hidden .browse sentinel pattern is a clean and flexible way to do it.

No comments:

Post a Comment