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

> Why nginx logs “Primary script unknown” from PHP-FPM, how to find the bad SCRIPT_FILENAME, and fixes for root, permissions, containers and systemd.

By Boris Tane, Founder of Polylane · Published October 17, 2026 · 11 min read
Canonical: https://polylane.com/learn/troubleshooting/nginx-fastcgi-sent-in-stderr-primary-script-unknown-while/

```text
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.

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](https://docs.w3cub.com/nginx/http/ngx_http_fastcgi_module) 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.

```mermaid alt="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](https://www.php.net/manual/en/install.fpm.configuration.php) 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`):

```ini
access.log = /var/log/php-fpm/$pool.access.log
access.format = "%R - %t \"%m %r\" %s %f"
```

2. Restart PHP-FPM, repeat the failing request and read the log:

```bash
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:

```text
127.0.0.1 - 17/Oct/2026:10:02:11 +0000 "GET /index.php" 404 /scripts/index.php
```

3. 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.

4. Check the path as the pool user. Find the user first, then walk the path:

```bash
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](https://nginx.org/en/docs/http/ngx_http_core_module.html#root) 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:

```nginx
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;
    }
}
```

2. 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.

3. 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.

4. 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:

```nginx
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;
    }
}
```

5. 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`:

```nginx
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;
}
```

6. Test and reload:

```bash
sudo nginx -t && sudo systemctl reload nginx
```

Example of what a passing test looks like:

```text
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:

```bash
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:

```bash
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
```

3. 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:

```yaml
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:

```nginx
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](https://github.com/php/php-src/commit/40c4d7f1820df1872a71ab07fd26da45a203e37f)) 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](https://github.com/php/php-src/blob/master/sapi/fpm/php-fpm.service.in) keeps `PrivateTmp=true` and `ProtectSystem=full` but has no `ProtectHome` line. The unit installed on your system may not match upstream, so check yours:

```bash
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:

```bash
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:

```bash
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](https://polylane.com/use-cases/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

- [PHP Manual: FPM configuration](https://www.php.net/manual/en/install.fpm.configuration.php)
- [nginx: Module ngx_http_core_module](https://nginx.org/en/docs/http/ngx_http_core_module.html)
- [nginx: Module ngx_http_fastcgi_module (documentation mirror)](https://docs.w3cub.com/nginx/http/ngx_http_fastcgi_module)
- [php-src: sapi/fpm/php-fpm.service.in](https://github.com/php/php-src/blob/master/sapi/fpm/php-fpm.service.in)
- [php-src commit 40c4d7f: Implement FR #72510, systemd service should be hardened](https://github.com/php/php-src/commit/40c4d7f1820df1872a71ab07fd26da45a203e37f)

## About the author

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](https://polylane.com/learn/ai-in-production/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](https://polylane.com/learn/deployment-safety/how-to-correlate-incidents-with-deploys/): 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](https://polylane.com/learn/troubleshooting/railway-healthcheck-failed/): 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](https://polylane.com/learn/troubleshooting/kubernetes-crashloopbackoff/): 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](https://polylane.com/learn/troubleshooting/kubernetes-oomkilled-exit-code-137/): 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.

Get started with one command: `curl -fsSL https://polylane.com/setup | bash` installs the CLI, connects your coding agents, and creates the account.
