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.htmlfor 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:
-
not path /
Ensures the matcher never triggers on the site root. The root path/will never show a directory listing, even if a.browsefile exists there. -
file { try_files {path}/.browse {path}.browse }
Checks for a hidden sentinel file named.browsein the directory corresponding to the request path. It works with both/dirand/dir/request forms.
For example:- Request:
/bar/ - Checks:
/srv/static/bar/.browse - If that file exists,
@browsable_dirmatches and browsing is enabled.
- Request:
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_ext → file_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_ext → file_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 genuine404 Not Found. -
Sentinel stays private:
.browseis 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.















