M
Writing

Production incident · Nginx · Linux permissions

The permission race behind intermittent Nginx cache 500s

How a recursive ownership race made Nginx lose access to FastCGI cache files, and how directory ACLs delivered a safer, faster fix.

July 12, 2026 9 min read By Mahfuzur Rahman
xCloud NginxFastCGI cacheLinux ACLsIncident analysis

01 · The incident

The failures looked random. The filesystem was being precise.

The sites were healthy, the application code had not changed, and most cached requests were fast. Then, under traffic, a small number returned HTTP 500.

The useful clue was in the Nginx error log: permission denied while opening a FastCGI cache file. Nginx had created the file, successfully served from the same cache moments earlier, and then somehow lost the ability to read it.

A recovery task appeared to fix the problem. Running it again could also bring the problem back. That contradiction narrowed the investigation: the repair process was not merely observing corrupted state. It was participating in it.

open() "/cache/example/…" failed (13: Permission denied)
upstream: fastcgi://unix:/run/php/php-fpm.sock
result: cached request returns HTTP 500
This was not an application exception. It was a storage contract failing between two Linux users while both were actively working in the same directory tree.

02 · The ownership model

One process wrote the cache. Another needed to purge it.

Nginx naturally created cache files as its own worker user. A WordPress site user also needed to purge cached entries through a helper plugin. The original solution made the site user own the cache tree, then widened permissions so Nginx could continue reading it.

That sounds reasonable until the cache becomes active. Nginx is continuously creating and replacing files. A recursive ownership repair is walking the same tree at the same time. The system has two writers and no atomic boundary between them.

Cache worker

Needs stable read and write access

It creates cache files, opens them on later requests, and expects its ownership model to remain valid.

Site process

Needs permission to remove entries

It does not need to read or own cached response bodies. It only needs to remove names from cache directories.

The original design granted ownership because purge access was required. Those are different capabilities. Treating them as equivalent created the entire failure mode.

03 · The race condition

Two recursive commands created a gap large enough for production traffic.

The repair used one recursive pass to change ownership and another to change permissions. Shell syntax can place those commands on the same line; the filesystem does not therefore perform them as one transaction. A busy cache keeps changing between the passes.

01 Nginx writesA cache worker creates a fresh file with restrictive permissions.
02 Ownership changesA recursive ownership pass moves that file to the site user.
03 Modes lag behindThe separate permission pass has not reached the file yet—or misses it entirely.
04 The next request failsNginx can no longer read the cache file it created moments earlier.

The result was a narrow but repeatable state: the cache file belonged to the site user while retaining a restrictive mode inherited from its creation. The site user owned it; Nginx could not read it. The next cache hit became a 500.

Recursive work also scaled with the number of cached paths. More sites and more objects meant a wider race window, a slower repair, and more opportunities to revisit files that did not need changing. The repair was working quite hard—unfortunately, on the wrong abstraction.

04 · The ACL insight

Deleting a file is permission on its parent directory.

On Unix-like systems, removing a directory entry is governed primarily by write and execute permission on the parent directory—not ownership of the file being removed.

That changed the design. Nginx could remain the owner of every cache file, preserving reliable read access. The site user could receive a narrow directory ACL that allowed cache entries to be unlinked without taking ownership of their contents.

Before

Transfer ownership repeatedly

Walk files and directories, change owners, change modes, then repeat after configuration or cache events.

After

Keep ownership stable

Nginx owns its files. The directory grants the site account only the access needed to purge names.

directory owner  → cache worker
directory group  → cache service group
named ACL user   → site account: write + traverse
file owner       → cache worker, unchanged

A default ACL on new directories preserved the rule as the cache grew. Set-group-ID on directories kept group inheritance predictable. Most importantly, no request-time helper needed to chase new files and repair them after creation.

05 · Safe recovery

A root-level repair tool must distrust the tree it repairs.

Changing the ownership model solved the race, but migration and repair still required privileged filesystem work. That introduced a separate question: what can the repair command safely believe?

A marker placed inside a site-writable directory is not authoritative. It can become stale, be replaced, or be redirected through a symbolic link. A privileged command that trusts it may change permissions outside the intended cache tree.

Guardrail

Trust external identity

Resolve the site account from a trusted system mapping, never from a marker stored inside a writable cache directory.

Guardrail

Replace, do not accumulate

Write an authoritative ACL so stale named-user entries disappear instead of surviving forever.

Guardrail

Refuse symlink surprises

Validate structural directories before privileged commands traverse them. A repair tool must not follow a user-controlled detour.

Guardrail

Scope every mutation

Touch only directories that can be mapped to a real site. Skip orphaned or reserved paths deliberately.

The final repair path rebuilt canonical state from trusted mappings, removed unexpected structural symlinks, skipped directories that could not be tied to a real site, and replaced ACLs authoritatively. Re-running it produced the same result. That is what recovery should feel like: boring, bounded, and unsurprising.

06 · The performance pass

Once the model stopped drifting, routine work became constant-time.

The old process assumed ownership could drift after every cache write or configuration regeneration, so it repeatedly walked large directory trees. The ACL model removed the source of that drift.

Routine regeneration could now inspect the top-level directory—owner, mode, and expected ACL—and take a fast path when the state was canonical. Recursive enforcement remained available for explicit migration or repair, where paying the full cost was justified.

Routine path

Inspect, then leave it alone

A small canonical-state check replaces repeated recursive ownership and permission passes.

Repair path

Force full enforcement deliberately

Migration and operator-triggered recovery retain a comprehensive, idempotent repair mode.

The performance improvement was not a cleverer recursive command. It came from proving that recursion was unnecessary during healthy operation.

07 · Lessons carried forward

Model the capability, then choose the permission.

The incident disappeared when the design stopped asking, “Who should own these files?” and started asking, “What is each process actually allowed to do?”

  • Ownership is not a convenient synonym for access. Grant the narrow capability a process needs.
  • Sequential shell commands are not atomic. On a live tree, the gap between them is part of the design.
  • Repair paths are security boundaries. Treat writable markers, symlinks, and orphaned paths as hostile inputs.
  • Authoritative state beats additive repair. Replace the permission model instead of layering new exceptions over old ones.
  • The best optimization often removes work. Stable invariants make expensive reconciliation unnecessary.

Intermittent failures often tempt us toward more logging, more retries, and more aggressive repair. Here, the reliable answer was smaller: let Nginx own what it creates, let the site user remove only what it must, and stop moving the ground beneath both of them.