Try_files is ineffective in my SPA

1. The problem I’m having:

I try to serve a small SPA with Caddy.
The whole Caddyfile is in a later section, but i’m using try_files {path} /index.html.
When I go to an URL that doesn’t match a file, I expect to get the content of index.html. That’s not the case and I get a 404, as if the try_files directive is ignored.

Here’s the proof that files exist and are served (/index.html among others) :

# The commands are ran inside the Caddy container
/app # ls
assets      index.html  vite.svg
/app # ls assets/
index-2c5e5e27.js   index-56a9b901.css



/app # curl -i localhost:3000/index.html
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 423
Content-Type: text/html; charset=utf-8
Etag: "da9jwm7by1p2br"
Last-Modified: Fri, 30 May 2025 14:05:31 GMT
Server: Caddy
Vary: Accept-Encoding
Date: Fri, 30 May 2025 15:36:06 GMT

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <link rel="icon" type="image/svg+xml" href="/vite.svg"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>App</title>
  <script type="module" crossorigin src="/assets/index-2c5e5e27.js"></script>
  <link rel="stylesheet" href="/assets/index-56a9b901.css">
</head>
<body>
<div id="app"></div>

</body>
</html>



/app # curl -i localhost:3000/vite.svg
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 1497
Content-Type: image/svg+xml
Etag: "da9jwm5t74ti15l"
Last-Modified: Fri, 30 May 2025 14:05:30 GMT
Server: Caddy
Vary: Accept-Encoding
Date: Fri, 30 May 2025 15:36:19 GMT

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.64

Here’s the proof that non-existant files raise a 404 instead of using the content of index.html :

/app # curl -i localhost:3000/thisfiledoesnotexist.nothing
HTTP/1.1 404 Not Found
Server: Caddy
Date: Fri, 30 May 2025 15:36:41 GMT
Content-Length: 0

2. Error messages and/or full log output:

{"level":"info","ts":1748619710.6373203,"msg":"maxprocs: Leaving GOMAXPROCS=8: CPU quota undefined"}
2025-05-30T15:41:50.637701486Z {"level":"info","ts":1748619710.6375475,"msg":"GOMEMLIMIT is updated","package":"github.com/KimMachineGun/automemlimit/memlimit","GOMEMLIMIT":7364999577,"previous":9223372036854775807}
2025-05-30T15:41:50.637710083Z {"level":"info","ts":1748619710.6376445,"msg":"using config from file","file":"/etc/caddy/Caddyfile"}
2025-05-30T15:41:50.639074524Z {"level":"info","ts":1748619710.6388533,"msg":"adapted config to JSON","adapter":"caddyfile"}
2025-05-30T15:41:50.639102817Z {"level":"warn","ts":1748619710.6388907,"msg":"Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies","adapter":"caddyfile","file":"/etc/caddy/Caddyfile","line":2}
2025-05-30T15:41:50.644173181Z {"level":"info","ts":1748619710.6439486,"logger":"admin","msg":"admin endpoint started","address":"localhost:2019","enforce_origin":false,"origins":["//[::1]:2019","//127.0.0.1:2019","//localhost:2019"]}
2025-05-30T15:41:50.644388124Z {"level":"info","ts":1748619710.6442666,"logger":"tls.cache.maintenance","msg":"started background certificate maintenance","cache":"0xc0001b9380"}
2025-05-30T15:41:50.644431013Z {"level":"debug","ts":1748619710.6441653,"logger":"http.auto_https","msg":"adjusted config","tls":{"automation":{"policies":[{}]}},"http":{"servers":{"srv0":{"listen":[":3000"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"rewrite","uri":"{http.matchers.file.relative}"}],"match":[{"file":{"try_files":["{http.request.uri.path}","/index.html"]}}]},{"handle":[{"handler":"file_server","hide":["/etc/caddy/Caddyfile"],"root":"/app"}]}]}],"terminal":true}],"automatic_https":{"skip":["localhost"]}}}}}
2025-05-30T15:41:50.644742931Z {"level":"debug","ts":1748619710.6446288,"logger":"http","msg":"starting server loop","address":"[::]:3000","tls":false,"http3":false}
2025-05-30T15:41:50.644751728Z {"level":"warn","ts":1748619710.6446705,"logger":"http","msg":"HTTP/2 skipped because it requires TLS","network":"tcp","addr":":3000"}
2025-05-30T15:41:50.644755227Z {"level":"warn","ts":1748619710.644674,"logger":"http","msg":"HTTP/3 skipped because it requires TLS","network":"tcp","addr":":3000"}
2025-05-30T15:41:50.644758627Z {"level":"info","ts":1748619710.6446757,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]}
2025-05-30T15:41:50.644761926Z {"level":"debug","ts":1748619710.6447,"logger":"events","msg":"event","name":"started","id":"2d326692-ecbb-4976-910b-e60869e94688","origin":"","data":null}
2025-05-30T15:41:50.645668787Z {"level":"info","ts":1748619710.645531,"msg":"autosaved config (load with --resume flag)","file":"/config/caddy/autosave.json"}
2025-05-30T15:41:50.645677085Z {"level":"info","ts":1748619710.6455574,"msg":"serving initial configuration"}
2025-05-30T15:41:50.650671769Z {"level":"info","ts":1748619710.6504593,"logger":"tls","msg":"cleaning storage unit","storage":"FileStorage:/data/caddy"}
2025-05-30T15:41:50.654228332Z {"level":"info","ts":1748619710.6540816,"logger":"tls","msg":"finished cleaning storage units"}
2025-05-30T15:42:29.522335888Z {"level":"debug","ts":1748619749.5221741,"logger":"http.handlers.file_server","msg":"sanitized path join","site_root":"/app","fs":"","request_path":"/index.html","result":"/app/index.html"}
2025-05-30T15:42:29.522387457Z {"level":"debug","ts":1748619749.5222752,"logger":"http.handlers.file_server","msg":"opening file","filename":"/app/index.html"}
2025-05-30T15:42:44.037507791Z {"level":"debug","ts":1748619764.037378,"logger":"http.handlers.file_server","msg":"sanitized path join","site_root":"/app","fs":"","request_path":"/vite.svg","result":"/app/vite.svg"}
2025-05-30T15:42:44.037572814Z {"level":"debug","ts":1748619764.0374234,"logger":"http.handlers.file_server","msg":"opening file","filename":"/app/vite.svg"}
2025-05-30T15:43:01.728065721Z {"level":"debug","ts":1748619781.727925,"logger":"http.handlers.file_server","msg":"sanitized path join","site_root":"/app","fs":"","request_path":"/thisfiledoesnotexist.nothing","result":"/app/thisfiledoesnotexist.nothing"}
2025-05-30T15:43:01.728128383Z {"level":"debug","ts":1748619781.7280235,"logger":"http.log.error","msg":"{id=8timnj6ws} fileserver.(*FileServer).notFound (staticfiles.go:705): HTTP 404","request":{"remote_ip":"::1","remote_port":"44790","client_ip":"::1","proto":"HTTP/1.1","method":"GET","host":"localhost:3000","uri":"/thisfiledoesnotexist.nothing","headers":{"Accept":["*/*"],"User-Agent":["curl/8.12.1"]}},"duration":0.000157304,"status":404,"err_id":"8timnj6ws","err_trace":"fileserver.(*FileServer).notFound (staticfiles.go:705)"}

3. Caddy version:

v2.10.0 h1:fonubSaQKF1YANl8TXqGcn4IbIRUDdfAkpcsfI/vX5U=

4. How I installed and ran Caddy:

a. System environment:

I use the Docker image caddy:2.10.0-alpine.

b. Command:

# Dockerfile
FROM caddy:2.10.0-alpine

COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=builder /app/dist /app

EXPOSE 3000

c. Service/unit/compose file:

# docker-compose.yml
services:
  front:
    build: .
    ports:
      - "3000:3000"

d. My complete Caddy config:

{
        debug
}

http://localhost:3000 {
        try_files {path} /index.html
        file_server {
                root /app
        }
}

5. Links to relevant resources:

try_files documentation : try_files (Caddyfile directive) — Caddy Documentation

Thanks for your time.

Maybe it can’t find index.html? Where is it?

Unlike the documentation example (Common Caddyfile Patterns — Caddy Documentation) you haven’t specified an overall root directory.

2 Likes

That’s right. Without the root on the outside, try_files assumed the working directory as root, which is /srv.

3 Likes

That’s it ! Thanks a lot, it’s crystal clear.