It supports these Zeabur extensions:
_headersfile_redirectsfile
docker build -t zeabur/caddy-static .
docker run -p 8080:8080 -v $(pwd)/examples/caddy:/usr/share/caddy -it zeabur/caddy-staticMAJOR=1 MINOR=0 PATCH=0 bash build.shThe server listens on :8080 and serves files from /usr/share/caddy. It auto-detects
whether the site is an SPA or an MPA based on the presence of /404.html in the site root.
Requests are matched in order:
- Sensitive paths are blocked before any file lookup.
- Asset requests are served as real files only.
- Document requests are matched against:
- The path as-is (e.g.
/about.html) - The path with a
.htmlsuffix (e.g./about→/about.html) - A directory index (e.g.
/users/→/users/index.html)
- The path as-is (e.g.
- Document misses fall back according to SPA/MPA mode.
This avoids using file_server status 200 for SPA fallbacks. Caddy's file server
uses http.ServeContent, which can correctly return 304 Not Modified for
If-None-Match and 206 Partial Content for range requests. A forced
status 200 wrapper would overwrite those status codes.
The route decides what to do with unmatched requests:
Asset requests — paths whose final segment ends with a non-.html/.htm extension
(matched by \.[A-Za-z0-9]+$) always receive a plain-text 404 Not Found response.
They never fall back to index.html or 404.html. This prevents broken <script> and
<link> loads from silently serving HTML.
Document requests — everything else:
Site has /404.html? |
Mode | Response |
|---|---|---|
| Yes | MPA | Serve /404.html with HTTP status 404 |
| No | SPA | Serve /index.html with HTTP status 200 |
The SPA status-200 return is intentional: client-side routers need a 200 to bootstrap.
It is produced by serving /index.html normally after an internal rewrite, not by
forcing file_server status 200, so conditional requests can still return 304.
Requests matching /.git/*, /node_modules/*, /vendor/*, or /.venv/* are blocked
and return 404 regardless of whether the files exist on disk.
Responses are compressed with gzip or zstd based on the client's Accept-Encoding header.
-
/api/v1.2(or any path whose final segment ends in a digit after a dot) — the asset regexp\.[A-Za-z0-9]+$matches the trailing.2, so this is classified as an asset miss and returns a plain 404 rather than the SPA/MPA fallback. If your SPA routes contain dotted numeric segments in the final position, avoid this pattern. -
/FILE.HTML(uppercase extension) — Caddy'snot path *.html *.htmmatcher is case-insensitive on case-insensitive filesystems but may not be on Linux. Test your deployment if you serve mixed-case HTML paths.
The E2E tests require the zeabur/caddy-static Docker image. Build it first:
docker build -t zeabur/caddy-static .Then run the full suite without the Go test cache:
go test -count=1 -v ./e2etest| Test | Request | Expected |
|---|---|---|
TestRedirects |
GET / |
302 → /home (via _redirects) |
TestHeader |
GET /test.html |
200, X-Caddy-Test-Passed: true (via _headers) |
TestUnsafePath |
GET /vendor/unsafe_path |
404 |
TestMpaNotFound |
GET /invalid_path |
body contains 404 page not found |
TestRedirectToExternalUrl |
GET /google |
302 → https://google.com |
A — Real file hits
| Test | Request | Expected |
|---|---|---|
| A1 | GET / |
200, body SPA_INDEX |
| A2 | GET /index.html |
200, body SPA_INDEX |
| A3 | GET /about |
200, body ABOUT_PAGE (.html suffix match) |
| A4 | GET /about.html |
200, body ABOUT_PAGE |
| A5 | GET /users/ |
200, body USERS_INDEX (directory index match) |
| A6 | GET /users |
200 or 308→/users/, final body USERS_INDEX |
| A7 | GET /blog/ |
200, body BLOG_INDEX |
| A8 | GET /blog/post-1 |
200, body POST_1 |
| A9 | GET /blog/post-1.html |
200, body POST_1 |
| A10 | GET /data.json |
200, Content-Type: application/json, body {"real":true} |
| A11 | GET /assets/app.js |
200, Content-Type: *javascript*, body REAL_ASSET_JS |
| A12 | GET /assets/style.css |
200, Content-Type: text/css, body REAL_ASSET_CSS |
| A13 | GET /img/logo.png |
200, Content-Type: image/png |
| A14 | GET /.well-known/security.txt |
200, body WELL_KNOWN |
B — Sensitive path blocking
| Test | Request | Expected |
|---|---|---|
| B1 | GET /.git/config |
404, body ≠ SHOULD_NEVER_LEAK_GIT |
| B2 | GET /.git/HEAD |
404 |
| B3 | GET /node_modules/pkg/index.js |
404, body ≠ SHOULD_NEVER_LEAK_NM |
| B4 | GET /vendor/lib.php |
404, body ≠ SHOULD_NEVER_LEAK_VENDOR |
| B5 | GET /.venv/pyvenv.cfg |
404, body ≠ SHOULD_NEVER_LEAK_VENV |
| B6 | GET /.git (no trailing content) |
any status, body ≠ SHOULD_NEVER_LEAK_GIT (design note: path /.git/* does not match bare /.git) |
| B7 | GET /.git/ (trailing slash) |
404 |
| B8 | GET /any/.git/config (mid-path) |
logged only — @forbidden is prefix-anchored so this is not blocked |
C — SPA fallback (missing document → index.html + 200)
| Test | Request | Expected |
|---|---|---|
| C1 | GET /projects |
200, Content-Type: text/html, body SPA_INDEX |
| C2 | GET /projects/ |
200, body SPA_INDEX |
| C3 | GET /projects/123 |
200, body SPA_INDEX |
| C4 | GET /deeply/nested/spa/route |
200, body SPA_INDEX |
| C5 | GET /projects?id=1&filter=foo |
200, body SPA_INDEX |
| C6 | GET /some-page.html (non-existent) |
200, body SPA_INDEX (.html exempt from asset rule) |
| C7 | GET /some-page.htm (non-existent) |
200, body SPA_INDEX |
| C8 | GET /-_~!$&()*+,;=:@ |
200, body SPA_INDEX |
| C9 | HEAD /projects |
200, empty body |
E — Missing asset → plain 404 (not SPA/MPA fallback)
All 20 paths return 404 with plain-text Not Found, Content-Type ≠ text/html, body ≠ SPA_INDEX:
/assets/missing.{js,mjs,css,css.map} · /img/missing.{png,jpg,svg,webp,avif,ico} · /fonts/missing.{woff2,woff,ttf} · /missing.{json,xml,txt,pdf,mp4,wasm,zip}
F — Asset/document classification boundary
| Test | Request | Classification | Expected |
|---|---|---|---|
| F1 | GET /file. (trailing dot, no ext chars) |
document | 200, body SPA_INDEX |
| F2 | GET /article-2024 (no dot) |
document | 200, body SPA_INDEX |
| F3 | GET /api/v1.2 (numeric ext) |
asset ⚠ | 404, body ≠ SPA_INDEX (known: .2 matches [A-Za-z0-9]+$) |
| F4 | GET /v1.0.0/page (dot in non-final segment) |
document | 200, body SPA_INDEX |
| F5 | GET /file.tar.gz (double ext) |
asset | 404, body ≠ SPA_INDEX |
| F6 | GET /file..js (double dot) |
asset | 404, body ≠ SPA_INDEX |
| F7 | GET /FILE.HTML (uppercase) |
logged — case sensitivity is filesystem-dependent | |
| F8 | GET /.hidden (dotfile, no slash) |
asset | 404, body ≠ SPA_INDEX |
| F9 | GET /路徑/中文 (Unicode, percent-encoded) |
document | 200, body SPA_INDEX |
G — URL and path edge cases
| Test | Request | Expected |
|---|---|---|
| G3 | GET /../etc/passwd |
any, body ≠ root: if 200 |
| G4 | GET /foo/../about |
logged (Caddy normalises path) |
| G7 | GET / + 4 KB path |
logged — no crash required |
| G8 | GET / + Range: bytes=0-9 |
206 or 200 |
| G9 | GET / + If-None-Match: <etag> |
304 (skipped if no ETag returned) |
| G10 | GET /projects + If-None-Match: <etag> |
304 for SPA fallback index.html (skipped if no ETag returned) |
H — HTTP methods
| Test | Request | Expected |
|---|---|---|
| H1 | HEAD / |
200, empty body |
| H2 | HEAD /projects |
200, empty body |
| H4 | HEAD /assets/missing.js |
404, empty body |
I — Response headers
| Test | Request | Expected |
|---|---|---|
| I1 | GET /assets/app.js + Accept-Encoding: gzip |
Content-Encoding: gzip, decompressed body REAL_ASSET_JS (skipped if file too small) |
| I3 | GET /assets/app.js (no Accept-Encoding) |
no Content-Encoding header |
| I4 | GET / |
200, Content-Type: text/html, has ETag or Last-Modified |
| I5 | GET /data.json |
200, Content-Type: application/json |
| I6 | GET /img/logo.png |
200, Content-Type: image/png, Accept-Ranges: bytes |
| I8 | GET /assets/missing.js |
404, Content-Type ≠ text/html |
| I9 | GET /projects |
200, Content-Type: text/html |
J — Regression (bugs fixed by the current Caddyfile)
| Test | Request | Expected | Regression |
|---|---|---|---|
| J1 | GET /projects |
200, body SPA_INDEX |
handle_errors used to inherit =404 status |
| G10 | GET /projects + If-None-Match: <etag> |
304 | file_server status 200 used to override conditional 304 on SPA fallback |
| J3 | GET /assets/missing.js |
404, plain Not Found, ≠ HTML |
missing assets used to fall back to index.html at 200 |
A — Real file hits — identical to SPA A1–A14 (existing files always served directly).
B — Sensitive path blocking — identical to SPA B1–B8.
D — MPA fallback (missing document → 404.html + 404)
| Test | Request | Expected |
|---|---|---|
| D1 | GET /projects |
404, Content-Type: text/html, body CUSTOM_404 |
| D2 | GET /projects/123 |
404, body CUSTOM_404 |
| D3 | GET /deeply/nested/missing |
404, body CUSTOM_404 |
| D4 | GET /missing.html (non-existent) |
404, body CUSTOM_404 (.html exempt from asset rule) |
| D5 | GET /missing.htm (non-existent) |
404, body CUSTOM_404 |
| D6 | GET /404.html (direct request) |
200, body CUSTOM_404 (real file hit, not fallback) |
| D8 | HEAD /projects |
404, empty body |
E — Missing asset — identical to SPA E (plain 404, body ≠ CUSTOM_404).
F — Asset/document boundary
Same classification logic as SPA. Document misses show CUSTOM_404 at 404; asset misses return plain 404 without CUSTOM_404.
G — URL and path edge cases
G3, G7, G9 same assertions as SPA. MPA-specific additions:
| Test | Request | Expected |
|---|---|---|
| G8b | GET /projects + Range: bytes=0-9 |
404, full CUSTOM_404 body, no Content-Range header |
| G10 | GET /projects + If-None-Match: <etag> |
304 for cache revalidation (skipped if no ETag returned) |
H — HTTP methods
| Test | Request | Expected |
|---|---|---|
| H1 | HEAD / |
200, empty body |
| H3 | HEAD /projects |
404, empty body |
| H4 | HEAD /assets/missing.js |
404, empty body |
I — Response headers
| Test | Request | Expected |
|---|---|---|
| I4 | GET / |
200, Content-Type: text/html, has ETag or Last-Modified |
| I8 | GET /assets/missing.js |
404, Content-Type ≠ text/html |
| I10 | GET /projects |
404, Content-Type: text/html |
J — Regression
| Test | Request | Expected | Regression |
|---|---|---|---|
| J2 | GET /projects |
404, body CUSTOM_404 |
try_files used to rewrite to 404.html and serve it at 200 |
| J4 | GET /assets/missing.js |
404, plain Not Found, ≠ CUSTOM_404 |
missing assets used to fall back to 404.html |
| G8b | GET /projects + Range: bytes=0-9 |
404, full body, no Content-Range |
file_server { status 404 } used to pass Range through, producing a truncated body and a spurious Content-Range header |
| G10 | GET /projects + If-None-Match: <etag> |
304 | file_server { status 404 } used to override 304 to an empty 404, breaking cache revalidation |