Web

nginx try_files Explained: Static Sites and PHP Front Controllers

By Nawawi JamiliPublished 7 min read

try_files is the directive I see copied most often from one nginx config to the next, usually without anyone reading what it does. Most of the time that works. When it does not, the symptoms are confusing. You may see a 403 on a directory, a redirect that drops the port, or PHP running a file that was uploaded as an image.

This post walks through how try_files actually decides, then builds the two patterns I use most: a static site with clean URLs and a PHP front controller. Everything was tested with the official nginx:stable-alpine image (nginx 1.30.6) and php:8.3-fpm-alpine (PHP 8.3.35) in temporary containers, with curl from the host.

How try_files decides

The syntax is a list of paths followed by one final argument:

nginx
try_files file1 file2 ... last;

nginx checks each of the first arguments in order, relative to root (or alias), and stops at the first one that exists on disk. The request is then served from that file inside the current location. If none of them exist, the last argument is used, and it is treated differently from the others: it is never checked on disk.

The last argument can be one of three things:

  • a URI such as /index.php?$args: nginx makes an internal redirect to it, and location matching starts again from the beginning with the new URI
  • =404 (or any other code): nginx returns that status, which error_page can then turn into a custom page
  • a named location such as @backend: nginx jumps straight to that location

Two details often confuse people. First, a path ending in a slash, like $uri/, checks for a directory instead of a file. Second, only the last argument causes a redirect. Putting /index.php in the middle of the list just checks whether that file exists, it does not send the request to PHP.

A static site with clean URLs

My test site has a few HTML files, a CSS file, a directory with an index.html, and a directory with no index file at all:

output
site/404.html
site/about.html
site/blog/first-post.html
site/blog/index.html
site/css/site.css
site/docs/readme.txt
site/index.html

The config tries the exact file, then a directory, then the same name with .html appended, and returns a 404 if none of them exists:

nginx
server {
    listen 80;
    server_name _;
    root /srv/site;
    index index.html;
    location / {
        try_files $uri $uri/ $uri.html =404;
    }
    error_page 404 /404.html;
    location = /404.html {
        internal;
    }
}

I mounted the config over the image's default.conf, started the container on a high local port and checked the syntax inside it:

terminal
$ docker run -d --name rg-static -p 127.0.0.1:18081:80 \
    -v "$PWD/static.conf:/etc/nginx/conf.d/default.conf:ro" \
    -v "$PWD/site:/srv/site:ro" nginx:stable-alpine
$ docker exec rg-static nginx -v
nginx version: nginx/1.30.6
$ docker exec rg-static nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Then a few requests, trimmed to the status line, any Location header and the heading of each page I created:

output
== /about
HTTP/1.1 200 OK
<h1>About</h1>
== /about.html
HTTP/1.1 200 OK
<h1>About</h1>
== /blog
HTTP/1.1 301 Moved Permanently
Location: http://127.0.0.1/blog/
== /blog/
HTTP/1.1 200 OK
<h1>Blog index</h1>
== /blog/first-post
HTTP/1.1 200 OK
<h1>First post</h1>
== /docs/
HTTP/1.1 403 Forbidden
== /missing
HTTP/1.1 404 Not Found
<h1>Not found</h1>

/about has no file of that name, and no directory either, so the third argument $uri.html finds about.html. /blog/first-post works the same way. /missing reaches the final =404, and error_page serves the custom page. The internal flag means a direct request for /404.html also gets a 404 status, so the error page cannot be indexed as a normal page with a 200.

$uri/ and the directory index

The other three results come from the $uri/ argument, and they are the ones that surprise people.

For /blog/, $uri/ matches the blog directory. nginx then hands the request to the index directive, which finds blog/index.html and serves it. For /docs/ the directory also exists, so try_files stops there, but there is no index file and autoindex is off. The result is a 403, not a 404. If you would rather hide such directories, drop $uri/ from the list or add an index file.

For /blog without the slash, nginx sends a 301 to /blog/. Note the Location header: http://127.0.0.1/blog/, without my :18081 port. nginx builds absolute redirects from its own listening port, which inside the container is 80. Behind Docker port mapping or a load balancer, this produces broken redirects. Adding absolute_redirect off; to the server block makes nginx send a relative one:

terminal
$ curl -sI http://127.0.0.1:18081/blog | grep -iE '^(HTTP|Location)'
HTTP/1.1 301 Moved Permanently
Location: /blog/

The PHP front controller pattern

Most PHP frameworks, and WordPress, route every request through one index.php. Real files such as CSS and images should be served directly by nginx, and everything else should reach PHP with the original URI intact. That is what this config does:

nginx
server {
    listen 80;
    server_name _;
    root /var/www/app/public;
    index index.php;
    location / {
        try_files $uri $uri/ /index.php?$args;
    }
    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass rg-php:9000;
    }
}

The test app is a single index.php that prints what PHP received:

php
<?php
// Front controller: every pretty URL ends up here
header('Content-Type: text/plain');
printf("index.php handled it\n");
printf("REQUEST_URI:     %s\n", $_SERVER['REQUEST_URI']);
printf("SCRIPT_NAME:     %s\n", $_SERVER['SCRIPT_NAME']);
printf("QUERY_STRING:    %s\n", $_SERVER['QUERY_STRING']);
printf("\$_GET:           %s\n", json_encode($_GET));

php-fpm and nginx run as two containers on a private Docker network, both with the same directory mounted at the same path. That last part matters: nginx checks for files on its own filesystem, and PHP opens SCRIPT_FILENAME on its own, so the paths must agree.

terminal
$ docker network create rg-net
$ docker run -d --name rg-php --network rg-net \
    -v "$PWD/app:/var/www/app:ro" php:8.3-fpm-alpine
$ docker run -d --name rg-web --network rg-net -p 127.0.0.1:18082:80 \
    -v "$PWD/php.conf:/etc/nginx/conf.d/default.conf:ro" \
    -v "$PWD/app:/var/www/app:ro" nginx:stable-alpine
$ docker exec rg-web nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

A clean URL with a query string:

terminal
$ curl -s 'http://127.0.0.1:18082/blog/2026/hello-world?ref=home'
index.php handled it
REQUEST_URI:     /blog/2026/hello-world?ref=home
SCRIPT_NAME:     /index.php
QUERY_STRING:    ref=home
$_GET:           {"ref":"home"}
$ curl -s http://127.0.0.1:18082/app.css
body { color: #333 }

There is no file or directory called /blog/2026/hello-world, so the last argument rewrites the request internally to /index.php?ref=home. That new URI matches the \.php$ location and goes to php-fpm. REQUEST_URI still holds the original path, which is what the framework's router reads. The ?$args part is what keeps $_GET populated. Leave it out and every query string silently disappears. The CSS file exists, so nginx serves it and PHP never sees the request.

The security mistake: sending any .php URI to PHP

The try_files $uri =404; line inside the PHP location looks redundant. It is not. Without it, nginx passes every URI that ends in .php to php-fpm, whether or not the file exists. To show why that is dangerous, I put a GIF with PHP code in it into an uploads directory, the way an attacker would via an avatar upload:

output
GIF89a
<?php echo "PHP code inside an image just ran\n"; ?>

Then I removed the try_files line from the PHP location and requested a path that does not exist:

terminal
$ curl -s -i http://127.0.0.1:18082/uploads/avatar.gif/x.php
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=UTF-8
X-Powered-By: PHP/8.3.35

Access denied.

nginx sent /var/www/app/public/uploads/avatar.gif/x.php as the script name. With cgi.fix_pathinfo at its default of 1, PHP walks back up the path until it finds a real file, and finds avatar.gif. The only thing that stopped it was php-fpm's security.limit_extensions, which by default only allows .php scripts. The php-fpm log said so:

output
NOTICE: Access to the script '/var/www/app/public/uploads/avatar.gif' has been denied (see security.limit_extensions)

So only one setting prevents a serious problem. Many old guides told people to widen it. I set security.limit_extensions = .php .gif in a pool override to see what a badly configured server would do:

terminal
$ curl -s http://127.0.0.1:18082/uploads/avatar.gif/x.php
GIF89a
PHP code inside an image just ran

The code in the upload ran. Then I restored the try_files $uri =404; line. With the same php-fpm settings, nginx returned its own 404 for both the exploit path and a plain missing script. It refused to pass a non-existent file to PHP at all:

terminal
$ curl -s -o /dev/null -w '%{http_code}\n' \
    http://127.0.0.1:18082/uploads/avatar.gif/x.php
404
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18082/nothing.php
404

I use all three layers: try_files $uri =404; in the PHP location, the default security.limit_extensions, and cgi.fix_pathinfo=0 in php.ini when the app does not need PATH_INFO. Better still, never let an upload directory execute PHP in the first place. The location matching rules explain how to write that block so it actually wins.

Note: try_files $uri =404; only works when nginx can see the PHP files. php-fpm may run on another host or container without the code mounted on the nginx side. Then the check fails for every script, and you need to rely on the PHP-side settings instead.

Small things worth knowing

  • $uri is the normalised, decoded path without the query string. It changes after an internal redirect, while $request_uri keeps the original.
  • $args and $query_string are the same value. Writing /index.php$is_args$args avoids a trailing ? when there is no query, which some apps care about.
  • The fallback URI goes through location matching again, so it must land in a location that can handle it. Pointing it at /index.php only works because a \.php$ location exists.

The full reference is on the nginx core module page. For the next step, putting nginx in front of an application server rather than php-fpm, see the reverse proxy article below.