Get started Dashboard
Troubleshooting ·

Fix “Primary script unknown” in nginx with PHP-FPM

Explore with AI

FastCGI sent in stderr: "Primary script unknown" while reading response header from upstream

nginx passed PHP-FPM a SCRIPT_FILENAME it could not open, and PHP-FPM wrote “Primary script unknown” to stderr, which nginx copies into its error log. Usually the path is wrong because root is missing from the PHP location or SCRIPT_FILENAME is hardcoded, and less often the PHP-FPM pool user cannot reach the file. Set root at server level, send `fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;`, add `try_files $uri =404;`, and make sure PHP-FPM sees the same file at the same path.

Ongoing incident? Our founding team can help you get production back up.
On this page

This line shows up in the nginx error log when a request for a .php URL reaches PHP-FPM and PHP-FPM can’t find the script it was told to run. In practice the browser often shows a short “File not found.” page or your framework’s 404, while static files on the same site load fine.

nginx is passing on a complaint here. The message comes from PHP-FPM, and in almost every case the fault is the file path nginx sent, or what PHP-FPM can see at that path. Fixing it means finding that path and working out why it doesn’t resolve.

What “Primary script unknown” is telling you

The line comes from two programs.

  • FastCGI sent in stderr comes from nginx. Your FastCGI server, PHP-FPM, wrote something to its error stream.
  • while reading response header from upstream also comes from nginx. It says what nginx was doing at the time: waiting for PHP-FPM’s response headers.
  • "Primary script unknown" is the text PHP-FPM wrote. The primary script is the file PHP-FPM was asked to execute.

nginx tells PHP-FPM which file to run through the SCRIPT_FILENAME FastCGI parameter. The nginx FastCGI module docs say this parameter is what PHP uses to find the script name, and they list it, along with QUERY_STRING, as the minimum PHP needs. A PHP-FPM worker takes that string as an absolute path and tries to open it on its own filesystem, as its own pool user. If the file is missing at that path, or the worker can’t reach it, you get this error.

Unless you add try_files, nginx does not check that the file exists. It builds the path from variables and sends it on. So a typo in root, a missing root, or a file that only exists in another container all end up as the same message from PHP-FPM.

How a PHP request reaches PHP-FPM and where Primary script unknown comes from

graph TB A[Request for a .php URL] --> B[nginx builds SCRIPT_FILENAME] B --> C{try_files finds the file?} C -->|no| D[nginx returns 404 itself] C -->|yes, or no try_files| E[PHP-FPM worker opens the path] E --> F{File reachable by pool user?} F -->|yes| G[Script runs] F -->|no| H[Primary script unknown logged] style H fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d style D fill:#d1fae5,stroke:#6ee7b7,color:#065f46 style G fill:#d1fae5,stroke:#6ee7b7,color:#065f46

Find the exact path PHP-FPM received

Don’t guess. Get PHP-FPM to log the filename it was handed, then check that path as the user PHP-FPM runs as.

  1. Turn on the pool access log with the script filename in it. The PHP-FPM configuration reference says access.log is not set by default, and that the %f placeholder in access.format logs the script filename. Add this to your pool file (for example /etc/php/8.3/fpm/pool.d/www.conf):
access.log = /var/log/php-fpm/$pool.access.log
access.format = "%R - %t \"%m %r\" %s %f"
  1. Restart PHP-FPM, repeat the failing request and read the log:
sudo systemctl restart php8.3-fpm
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/index.php
sudo tail -n 5 /var/log/php-fpm/www.access.log

A failing request looks something like this:

127.0.0.1 - 17/Oct/2026:10:02:11 +0000 "GET /index.php" 404 /scripts/index.php
  1. Read the last field:

    • The path is wrong (/scripts/..., /usr/share/nginx/html/..., or missing the file name): the problem is in nginx. Go to the SCRIPT_FILENAME section below.
    • The path looks right: the file exists for you but not for PHP-FPM. Check permissions, chroot, containers and systemd sandboxing.
  2. Check the path as the pool user. Find the user first, then walk the path:

ps -o user= -C php-fpm8.3 | sort -u
sudo -u www-data stat /var/www/example/public/index.php
namei -l /var/www/example/public/index.php

namei -l prints the owner and mode of every directory on the way to the file, which makes a missing execute bit easy to spot.

PHP-FPM logs nothing about this error by default. The reference says catch_workers_output defaults to no, so worker stdout and stderr go to /dev/null, and log_level defaults to notice. The access log with %f is the quickest way to see what happened.

If you still can’t explain it, trace a worker. Set the pool to a single worker (pm = static, pm.max_children = 1), restart, run sudo strace -f -p <worker pid> and send the request. Look for the file call on the SCRIPT_FILENAME path that fails and the error code next to it: as a general guide, a missing file and a permission problem report different codes.

Cause 1: SCRIPT_FILENAME is built from the wrong root

This is the most common cause. Three versions of it come up again and again.

  • A hardcoded prefix copied from an example. The nginx docs use fastcgi_param SCRIPT_FILENAME /home/www/scripts/php$fastcgi_script_name; to explain the variable. Configs that keep a prefix like /scripts send a path that doesn’t exist.
  • root set only inside location /. The .php location then has no root of its own. The nginx core module docs give root a default of html, so $document_root in the PHP location points at nginx’s built-in html directory and not at your app.
  • alias in the PHP location. With alias, $document_root$fastcgi_script_name can produce a path that doesn’t match the file on disk. Step 4 below shows the variable to send in that case.

How to tell: the %f field in the PHP-FPM access log doesn’t match where your files live.

Fixing the root and SCRIPT_FILENAME

  1. Move root up to the server block so every location inherits it, and set SCRIPT_FILENAME from variables:
server {
    listen 80;
    server_name example.com;
    root /var/www/example/public;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
}
  1. Use $realpath_root if you deploy by switching a symlink such as current -> releases/42. The core module docs say $realpath_root is the root or alias path with all symbolic links resolved to real paths. PHP-FPM then gets the real release directory, which keeps OPcache and the file on disk in step.

  2. Put your own fastcgi_param lines after the include. The FastCGI docs also say fastcgi_param directives are inherited from the level above only if the current level defines none. If you include fastcgi_params in the server block and then set a single fastcgi_param inside the location, that location loses every inherited parameter. Keep the include and your overrides in the same block.

  3. If you serve a PHP app under a URL prefix with alias, send $request_filename, which the core module docs describe as the file path for the current request based on the root or alias directives and the request URI:

location ^~ /admin/ {
    alias /srv/admin/public/;
    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $request_filename;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }
}
  1. For URLs with path info, such as /index.php/orders/42, split the URI so $fastcgi_script_name holds only the script. The FastCGI docs say the first capture of fastcgi_split_path_info becomes $fastcgi_script_name and the second becomes $fastcgi_path_info:
location ~ ^(.+\.php)(/.*)?$ {
    fastcgi_split_path_info ^(.+\.php)(/.*)$;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_path_info;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
  1. Test and reload:
sudo nginx -t && sudo systemctl reload nginx

Example of what a passing test looks like:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Cause 2: the PHP file really does not exist

If your site is up and the error comes in bursts, read the request: field of each error line. Paths like /wp-login.php, /xmlrpc.php or /phpmyadmin/index.php on a site that has none of them are often scanner traffic. nginx forwards every .php URL to PHP-FPM, PHP-FPM can’t find the file, and the error log fills up.

How to tell: the failing paths don’t exist in your codebase, and they come from a small number of client IPs.

Stopping missing files at nginx

  1. Add try_files $uri =404; as the first line of the PHP location, as in the config above. The core module docs show this pattern for FastCGI: try_files checks that the PHP file exists before nginx passes the request to the FastCGI server, and =404 returns the status straight away.
  2. Reload nginx. Requests for missing scripts now get an nginx 404 and never reach PHP-FPM, so the error line goes away for them.

One catch: try_files checks nginx’s own filesystem. If PHP-FPM runs on another host or in another container and nginx has no copy of the code, try_files will 404 every real request too. In that setup, mount the code into the nginx container as well (see cause 4), or leave try_files out and accept the log noise.

Cause 3: the PHP-FPM user cannot reach the file

The path is right and the file exists, but the worker runs as the pool’s user (the PHP reference marks it mandatory) and that user is blocked somewhere along the path. As a general Unix rule, to open /home/alice/www/index.php the user needs execute (search) permission on /home, /home/alice and /home/alice/www, plus read permission on the file. Home directories with mode 0700 or 0750 are the usual blocker.

How to tell: sudo -u <pool user> stat <path> fails with Permission denied, and namei -l shows a directory with no execute bit for that user.

Granting the pool user access

  1. Find the pool user from the user = line in the pool file, or from ps as shown above.
  2. Give that user search permission on every directory and read permission on the code. Either open the directories:
sudo chmod o+x /home/alice /home/alice/www
sudo chmod -R o+rX /home/alice/www

or, tighter, put the pool user in the owning group and grant group access:

sudo usermod -aG alice www-data
sudo chmod g+x /home/alice
sudo chmod -R g+rX /home/alice/www
sudo systemctl restart php8.3-fpm
  1. Run sudo -u www-data stat on the file again. It should print the file’s details with no error.

Don’t mix this up with the socket permission settings. listen.owner, listen.group and listen.mode set the permissions on the Unix socket, which decide whether nginx can connect to PHP-FPM at all. A socket permission problem shows up in nginx as a connection error, which is a different message.

Cause 4: PHP-FPM sees a different filesystem than nginx

The path is right, the permissions look fine from your shell, and it still fails. PHP-FPM is running with a different view of the disk.

nginx and PHP-FPM in separate containers

nginx builds SCRIPT_FILENAME from its own root, and PHP-FPM opens that path inside its own container. If the code is mounted at /usr/share/nginx/html in the nginx container and at /var/www/html in the PHP container, every request fails. Mount the same code at the same path in both:

services:
  nginx:
    image: nginx:stable
    volumes:
      - ./app:/var/www/html:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
  php:
    image: php:8.3-fpm
    volumes:
      - ./app:/var/www/html

Then set root /var/www/html/public; in nginx and fastcgi_pass php:9000;. Check from inside the PHP container with docker compose exec php ls -l /var/www/html/public/index.php. The PHP reference also warns that in container setups the FPM port shouldn’t be published on the host, because an exposed FastCGI endpoint allows arbitrary code execution.

A chrooted pool

The reference says chroot changes root to the given directory at start, and that chdir defaults to / when chroot is set. Inside the jail, /srv/example/public/index.php no longer exists. If the pool has chroot = /srv/example, nginx has to send the path as seen from inside it:

fastcgi_param SCRIPT_FILENAME /public$fastcgi_script_name;

systemd sandboxing on the PHP-FPM service

The php-src commit that hardened the systemd unit (FR #72510) added ProtectHome=true, which makes /home, /root and /run/user inaccessible and empty for the service. A document root under /home then disappears for PHP-FPM while nginx still serves static files from it. The current upstream unit keeps PrivateTmp=true and ProtectSystem=full but has no ProtectHome line. The unit installed on your system may not match upstream, so check yours:

systemctl cat php-fpm | grep -E 'Protect|PrivateTmp'

If ProtectHome=true is there and your code lives in /home, move the code to somewhere like /var/www, or override the setting:

sudo systemctl edit php-fpm
# add:
# [Service]
# ProtectHome=false
sudo systemctl daemon-reload
sudo systemctl restart php-fpm

PrivateTmp=true causes the same failure for code kept under /tmp or /var/tmp, because the service gets its own private copies of both.

fastcgi_pass points at the wrong pool

After a PHP upgrade, or on a server with several pools, fastcgi_pass can point at a socket owned by a different pool or PHP version, with a different user, chroot or container. Compare the listen = line in the pool you expect with the fastcgi_pass value in nginx, and confirm the socket belongs to that pool’s process.

Keeping “Primary script unknown” out of your logs

  • Put try_files $uri =404; in every PHP location where nginx can see the code. Scanner traffic then stops at nginx and PHP-FPM only gets real scripts.
  • Run nginx -t in CI and before every reload, and add a smoke test to your deploy that requests a real PHP route and expects a 200:
test "$(curl -s -o /dev/null -w '%{http_code}' https://example.com/index.php)" = "200"
  • Keep the PHP-FPM access log with %f in production. When the error comes back, the bad path is already on disk.
  • Alert on the rate of this log line, grouped by request path. Many paths from a few IPs is scanner noise. Your main entry point failing is an outage, usually from a deploy that moved the root or a package upgrade that changed the unit file.
  • Diff the systemd unit and pool files after PHP upgrades. New packages can bring new sandboxing settings or socket paths that only fail once traffic arrives.

See Polylane as your AI SRE.

Common questions.

Why do I see “File not found.” in the browser with this error?

In practice that short page is what PHP-FPM sends back when it can't open the SCRIPT_FILENAME it was given, and the nginx error log records the matching “Primary script unknown” line. Add `try_files $uri =404;` to the PHP location so nginx answers missing files with its own 404, then fix the path for files that do exist.

Should I use $document_root or $realpath_root in SCRIPT_FILENAME?

Both come from your root or alias directive. `$realpath_root` also resolves symbolic links to real paths, according to the nginx core module docs. Use `$realpath_root` if you deploy by switching a symlink such as `current`. Otherwise either works.

Why does PHP-FPM log nothing when this happens?

By default `catch_workers_output` is `no`, so worker stdout and stderr go to `/dev/null`, and `access.log` isn't set. Set `access.log` with `%f` in `access.format` to log the script filename each request asked for, then restart PHP-FPM.

It worked before a PHP upgrade and broke after it. What changed?

Check the PHP-FPM systemd unit with `systemctl cat php-fpm`. A php-src hardening change added `ProtectHome=true`, which hides `/home` from the service, and the unit on your system may differ from upstream. Also check that the new version's `listen` socket matches your `fastcgi_pass`.

Is “Primary script unknown” a security problem?

The error itself isn't. It often means scanners are requesting PHP files you don't have. `try_files $uri =404;` stops those requests at nginx. Separately, never publish the PHP-FPM port to the internet, because the PHP manual warns that an exposed FastCGI endpoint allows arbitrary code execution.

Can I use try_files when PHP-FPM runs in another container?

Only if nginx has the same code mounted at the same path, because `try_files` checks nginx's own filesystem. If nginx has no copy of the code, `try_files` will 404 every real request. Mount the code read-only into the nginx container so both sides agree.

Do I still need fastcgi_index if I set SCRIPT_FILENAME?

Only for URIs that end in a slash. The nginx docs say `fastcgi_index` appends a file name such as `index.php` to those URIs in `$fastcgi_script_name`. Without it, a request for `/blog/` sends a directory path, and PHP-FPM can't run a directory.

Sources (5)

  1. PHP Manual: FPM configuration
  2. nginx: Module ngx_http_core_module
  3. nginx: Module ngx_http_fastcgi_module (documentation mirror)
  4. php-src: sapi/fpm/php-fpm.service.in
  5. php-src commit 40c4d7f: Implement FR #72510, systemd service should be hardened

About the author

Boris Tane

Founder of Polylane

Boris Tane is the founder of Polylane. He previously founded Baselime, observability for the future of the cloud, which Cloudflare acquired. At Cloudflare he built and led the Workers observability team.

Related

Nobody should be on-call. Polylane watches your infra, finds what broke, and writes the fix.

Get started for free