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
- What “Primary script unknown” is telling you
- Find the exact path PHP-FPM received
- Cause 1: SCRIPT_FILENAME is built from the wrong root
- Cause 2: the PHP file really does not exist
- Cause 3: the PHP-FPM user cannot reach the file
- Cause 4: PHP-FPM sees a different filesystem than nginx
- Keeping “Primary script unknown” out of your logs
- Common questions
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 stderrcomes from nginx. Your FastCGI server, PHP-FPM, wrote something to its error stream.while reading response header from upstreamalso 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.
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.
- Turn on the pool access log with the script filename in it. The PHP-FPM configuration reference says
access.logis not set by default, and that the%fplaceholder inaccess.formatlogs 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"
- 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
-
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.
- The path is wrong (
-
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/scriptssend a path that doesn’t exist. rootset only insidelocation /. The.phplocation then has no root of its own. The nginx core module docs giveroota default ofhtml, so$document_rootin the PHP location points at nginx’s built-in html directory and not at your app.aliasin the PHP location. Withalias,$document_root$fastcgi_script_namecan 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
- Move
rootup to theserverblock so every location inherits it, and setSCRIPT_FILENAMEfrom 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;
}
}
-
Use
$realpath_rootif you deploy by switching a symlink such ascurrent -> releases/42. The core module docs say$realpath_rootis 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. -
Put your own
fastcgi_paramlines after theinclude. The FastCGI docs also sayfastcgi_paramdirectives are inherited from the level above only if the current level defines none. If you includefastcgi_paramsin theserverblock and then set a singlefastcgi_paraminside the location, that location loses every inherited parameter. Keep the include and your overrides in the same block. -
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 therootoraliasdirectives 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;
}
}
- For URLs with path info, such as
/index.php/orders/42, split the URI so$fastcgi_script_nameholds only the script. The FastCGI docs say the first capture offastcgi_split_path_infobecomes$fastcgi_script_nameand 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;
}
- 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
- 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_fileschecks that the PHP file exists before nginx passes the request to the FastCGI server, and=404returns the status straight away. - 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
- Find the pool user from the
user =line in the pool file, or frompsas shown above. - 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
- Run
sudo -u www-data staton 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 -tin 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
%fin 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.
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)
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
- How to Use Claude Code to Debug Production Issues Debug production issues with Claude Code: get logs and traces into the session, test hypotheses against evidence, stay read-only and ship a verified fix.
- How to correlate incidents with deploys using evidence Learn how to correlate incidents with deploys using release markers, metrics, logs and verified GitHub, Vercel, Cloudflare and Kubernetes change events.
- Fix Railway “Healthcheck failed!” on deploy Why a Railway deploy ends in “1/1 replicas never became healthy! Healthcheck failed!”, how to find your cause in the logs, and how to fix each one.
- Kubernetes CrashLoopBackOff: causes and fixes What CrashLoopBackOff means in Kubernetes, how to read the exit code and events behind it, and the fix for each common cause, from bad config to failing probes.
- Kubernetes OOMKilled (exit code 137): causes and fixes Why Kubernetes reports OOMKilled with exit code 137, how to tell a limit kill from a node eviction or a plain SIGKILL, and how to size memory so it stops.