Doesn't return 404 on nonexisting files
What happened?
If you have an index.php file in the website root, any request to nonexisting files will be executed by index.php and return 200. This will result in incorrect responses serving the actual website content on nonexisting paths with the incorrect status code (200) instead of 404.
If this is the intended behaviour, is it possible to disable it?
Build Type
Docker (Debian Trixie)
Worker Mode
No
Operating System
GNU/Linux
CPU Architecture
x86_64
PHP configuration
System Linux 2e987d8e2f6e 6.12.43+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.43-1 (2025-08-27) x86_64
Build Date Aug 28 2025 18:15:48
Build System Linux - Docker
Build Provider https://github.com/docker-library/php
Configure Command './configure' '--build=x86_64-linux-gnu' '--with-config-file-path=/usr/local/etc/php' '--with-config-file-scan-dir=/usr/local/etc/php/conf.d' '--enable-option-checking=fatal' '--with-mhash' '--with-pic' '--enable-mbstring' '--enable-mysqlnd' '--with-password-argon2' '--with-sodium=shared' '--with-pdo-sqlite=/usr' '--with-sqlite3=/usr' '--with-curl' '--with-iconv' '--with-openssl' '--with-readline' '--with-zlib' '--enable-phpdbg' '--enable-phpdbg-readline' '--with-pear' '--with-libdir=lib/x86_64-linux-gnu' '--enable-embed' '--enable-zts' '--disable-zend-signals' 'build_alias=x86_64-linux-gnu' 'PHP_UNAME=Linux - Docker' 'PHP_BUILD_PROVIDER=https://github.com/docker-library/php'
Server API FrankenPHP
Virtual Directory Support enabled
Configuration File (php.ini) Path /usr/local/etc/php
Loaded Configuration File (none)
Scan this dir for additional .ini files /usr/local/etc/php/conf.d
Additional .ini files parsed /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini, /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini
PHP API 20240924
PHP Extension 20240924
Zend Extension 420240924
Zend Extension Build API420240924,TS
PHP Extension Build API20240924,TS
PHP Integer Size 64 bits
Debug Build no
Thread Safety enabled
Thread API POSIX Threads
Zend Signal Handling disabled
Zend Memory Manager enabled
Zend Multibyte Support provided by mbstring
Zend Max Execution Timers enabled
IPv6 Support enabled
DTrace Support disabled
Registered PHP Streams https, ftps, compress.zlib, php, file, glob, data, http, ftp, phar
Registered Stream Socket Transports tcp, udp, unix, udg, ssl, tls, tlsv1.0, tlsv1.1, tlsv1.2, tlsv1.3
Registered Stream Filters zlib.*, convert.iconv.*, string.rot13, string.toupper, string.tolower, convert.*, consumed, dechunk
Relevant log output
For example, when the browser tries to request /favicon.ico that doesn't exist, index.php will be executed and return the main website content as HTML:
caddy-1 | {"level":"info","ts":1757529977.5878327,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"1.2.3.4","remote_port":"50513","client_ip":"1.2.3.4","proto":"HTTP/2.0","method":"GET","host":"example.com","uri":"/favicon.ico","headers":{"Sec-Fetch-Dest":["image"],"Sec-Fetch-Site":["same-origin"],"Cache-Control":["no-cache"],"Accept":["image/avif,image/jxl,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0"],"Priority":["u=6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["no-cors"],"Te":["trailers"],"Accept-Language":["sl,en-GB;q=0.8,en-US;q=0.5,en;q=0.3"],"Referer":["https://example.com/test"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"example.com"}},"bytes_read":0,"user_id":"","duration":0.003590894,"size":80152,"status":200,"resp_headers":{"Server":["Caddy"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"X-Powered-By":["PHP/8.4.12"],"Content-Type":["text/html; charset=UTF-8"]}}
This is by design. Your index.php should know which routes exist and which ones do not. Thus if you receive a request for a route that does not exist (likely because a file didn't exist), your application should return a 404 error.
If you just return your main website for any url, then if anyone posts a link to your website with a fake url http://example.com and http://example.com/not-the-homepage return the same content, Google will ding you for duplicate content issues as well.
Is it possible to disable this? This doesn't happen with other PHP webservers by default, and it is annoying having to set up additional URL handling for simple file-based websites.
You can modify the configuration however you would like. The default is configured for modern apps using Symfony/Laravel/WordPress which use a router-based system (the inverse of other servers where you have to configure it to use a router-based approach). If you want to use file-based systems, you would have to modify it.
I’m not sure what that would look like, but it is certainly possible.
If I understand that config correctly, just removing the @indexFiles part and setting index.php in the Caddy's file_server instead should be enough, right?
Maybe this could be added to the documentation?
If you don’t mind sharing the configuration after you find one that works, I will be happy to add it to the documentation, or a PR from yourself is welcome as well — it is just Markdown in the docs folder.
I think it would look something like this?
app.example.com {
root /path/to/app/public
route {
# Add trailing slash for directory requests
@canonicalPath {
file {path}/index.php
not path */
}
redir @canonicalPath {path}/ 308
# FrankenPHP!
@phpFiles path *.php
php @phpFiles
file_server
}
}
Though that might mean you have to specify index.php, which may not be what you want? Dunno.
This configuration seems to work fine:
route {
# Add trailing slash for directory requests
@canonicalPath {
file {path}/index.php
not path */
}
redir @canonicalPath {path}/ 308
# Strip index files when directly accessed
@stripIndex {
path_regexp path ^(.*?)/index\.(php|html)$
file
}
redir @stripIndex {re.path.1}/ 308
# Handle PHP and HTML index files
try_files {path} {path}/index.php {path}/index.html =404
# FrankenPHP!
@phpFiles path *.php
php @phpFiles
file_server
}
Edit: @canonicalPath doesn't work properly yet when the directory contains index.html...
I've added an additional @stripIndex rule so it redirects /something/index.php to /something/ to avoid duplicated content. This isn't in the default php_server config, but maybe it would be useful to add it?
The main thing was to add a custom try_files that tries index files first and then returns 404. Without that, index.php won't be served by default. I've also tried setting the index option of Caddy's file_server instead, but that returned the content without the HTML mime type, and I've found the try_files-based approach here.
Also, Caddy's php_fastcgi supports setting try_files that overrides its own handler logic. However, this option doesn't exist for file_server. There is worker.match, but that seems specific to the worker mode. Would it be possible to also add such option to file_server to disable split_path and configure try_files? So the config could be quite shorter without having to handle @canonicalPath and other stuff separately.
I didn't manage to make @canonicalPath work with both index.php and index.html. Appearently adding / to directory requests should normally be handled by Caddy, but try_files disables this.
So, I tried to remove that custom try_files and then add index to file_server:
file_server {
index index.php index.html index.txt
}
This restores the / redirection, but it doesn't actually execute PHP when requested directly. It just returns the source PHP file without executing it and without HTML mime type. Is it possible to make Caddy also execute PHP properly on such requests?
As mentioned above, having index.php in file_server doesn't work, and I don't know why. First problem is probably that @phpFiles doesn't match the URL without .php. I've tried to add another rule that should match and execute index.php when it exists:
@phpIndex {
path */
file {path}/index.php
}
php @phpIndex
However, when requesting such paths, I get this PHP error:
Warning: Unknown: Failed to open stream: Success in Unknown on line 0
Fatal error: Failed opening required '/sites/example' (include_path='.:/usr/local/lib/php') in Unknown on line 0
After some trial-and-error, I managed to create the following config that seems to work:
# Add trailing slash for directory requests
# Handled internally by Caddy for HTML indexes
@canonicalPath {
file {path}/index.php
not path */
}
redir @canonicalPath {path}/ 308
# Strip index files when directly accessed
# This rule is not included in `php_server` by default, but it might also be useful there
@stripIndex {
path_regexp path ^(.*?)/index\.(php|html|txt)$
file
}
redir @stripIndex {re.path.1}/ 308
# Load index on directory request if it exists
@hasIndex file {path}/index.php
rewrite @hasIndex {path}/index.php
@phpFiles {
path *.php
file
}
php @phpFiles
file_server
I had to add a rewrite to serve directories as index.php. However, because of that rewrite, Caddy's canonical URLs don't apply to such paths, so manual @canonicalPath rule is still needed.
This makes the config quite long compared to just the default php_server (even excluding my additional @stripIndex rule), and having to manually duplicate Caddy's logic for canonical paths doesn't seem so nice. So, is there any way to make the config shorter? If serving index.php could be done without rewrite, @canonicalPath could probably be removed as Caddy would internally handle it. Or as another option in the file_server to switch to basic file-based handling?
TBH, when I first read this issue, I was like "that sounds pretty simple" and this looks like the opposite of simple. We have a php_server directive, but maybe we should have a php_file_server directive for old-style systems?
Yeah, such option would be quite nice. Although most modern PHP websites probably use router-based approach, there are still quite a lot of file-based websites, especially if you have a mostly static website and use PHP for some smaller dynamic enhancements.
Hah, I ran into the same thing a few months ago when I moved a legacy app from apache. I ended up with a slightly simpler solution that covered my use case well enough, where I just handled the directories in which the index files could exist separately from the main index.php.