— Article — № 071

071 —Operations

chmod -R 777 recovery: restoring a legacy site's permissions

A panicked chmod -R 777 felt like the fastest fix at 23:41. Twelve minutes later PHP-FPM was rejecting every script and the login screen returned 500s.

Overhead still life on bone linen: Unix permissions cheat-sheet, PHP-FPM error log, manila wp-content folder, brass CHMOD plate, red wax seal.
Hero · staged still№ 071

A developer at a Dutch agency we work with ran chmod -R 777 /var/www/clients/wonen-23 at 23:41 because a customer was waiting on a media upload that kept failing in the WordPress admin. The upload went through. So did the next four. Then, twelve minutes later, every page on the site started returning a blank 500, and the error log filled with Premature end of script headers. By 00:15 the agency lead was on a Loom recording, explaining what they had done and asking how to walk it back.

This post is the recovery walkthrough we sent back the next morning, generalised for any legacy site running on a shared or VPS host with a UID-aware PHP handler. The commands are the real ones we used. The error messages are the ones you will actually see in production.

Recursive 777 and the handler rejection

The textbook reason a recursive 777 is bad is security: world-writable files mean anyone with shell access can edit your scripts. The operational reason, the one that bites the same night, is that modern PHP handlers refuse to execute world-writable code on purpose. Apache suEXEC rejects scripts whose containing directory is writable by group or other. PHP-FPM pools running under suEXEC, mod_ruid2, or CageFS do the same. cPanel's suPHP returns the famous SoftException in Application.cpp:597: Directory "..." is writable by group. On nginx with PHP-FPM you usually see FastCGI sent in stderr: Primary script unknown instead, because the worker refuses to fork before logging the real reason.

WordPress notices too. When wp-config.php is world-writable, several hardening plugins quarantine the file and the dashboard stops loading. mod_security rulesets on managed hosts pattern-match 777 directories and start dropping requests at the WAF. And if your backups run with rsync's -a flag, the next snapshot will faithfully preserve the mistake, so the obvious "just restore last night's tarball" stops being a clean rollback.

The damage is also cached. OPcache holds compiled bytecode keyed by file path and mtime, so the next request after the chmod can still execute the previous compiled copy for a few seconds, which makes the outage look intermittent at first. By the time OPcache evicts and the worker tries to reload, the file is rejected and the 500s start in earnest. Restarting PHP-FPM after the recovery clears that cache; until you do, you may keep seeing rejections on files you have already fixed.

None of this is theoretical. Every line above corresponds to an error message we have seen in a production log this year.

Identifying the right ownership before you touch anything

The first move is not to run another chmod. It is to find out what the permissions and ownership were supposed to be before the panic.

Three places to look, in order of reliability:

  1. A sibling vhost on the same machine. ls -la /var/www/clients/ usually shows the same pattern across customers on the same box.
  2. A backup taken before the incident. stat -c '%U:%G %a %n' filename reports user, group, and mode in one line and is easy to grep over a restored tarball.
  3. The PHP-FPM pool file, which tells you which UID the script will be read by.

That last one is the source of truth, because the pool config is whatever process is going to read the code on the next request. On Debian and Ubuntu it lives under /etc/php/8.2/fpm/pool.d/. The relevant lines are user, group, listen.owner, and listen.group. Whatever user is set to is who must own (or share a group with) every executable PHP file on disk.

grep -E '^(user|group)' /etc/php/8.2/fpm/pool.d/wonen-23.conf
# user = web23
# group = client5

If you are on cPanel, the pool user is typically the account user, something like wonen23. On DirectAdmin it is the same. On Plesk under PHP-FPM the group is usually psacln. On a plain LAMP box without a panel it is almost always www-data on Debian-family systems and apache on RHEL-family systems. Confirm by reading the running process list rather than guessing from documentation:

ps -eo user,group,cmd | grep php-fpm | grep -v grep

Compare the user reported by ps with the user written in the pool file. If they disagree, something else is going on (a stale init script, a manual chown on the binary, an old pool that never restarted), and you need to resolve that before you start fixing the docroot. Applying modes against the wrong target user does not get you any closer to a serving site; it just rewrites the failure across more files.

Restoring directory and file modes

The standard, boring, correct pattern for almost every PHP site is 755 for directories and 644 for files. The cleanest way to apply that to a tree without writing a script is two find calls.

cd /var/www/clients/wonen-23/htdocs

# Directories: rwx for owner, rx for group and other
find . -type d -exec chmod 755 {} +

# Files: rw for owner, r for group and other
find . -type f -exec chmod 644 {} +

Use the trailing + rather than \;. The plus form batches arguments into a single chmod invocation, which on a 40,000-file Drupal install is the difference between three seconds and three minutes.

That gets you to a sane default. Then come the CMS-specific tightenings.

WordPress

The WordPress hardening guide recommends 640 or 600 on wp-config.php. The uploads directory must be writable by the PHP user, but 755 with correct ownership is enough. Never put it back to 777, ever, even temporarily.

chmod 640 wp-config.php
chown www-data:www-data wp-config.php

# Uploads must be writable by the PHP user, not the world
find wp-content/uploads -type d -exec chmod 755 {} +
find wp-content/uploads -type f -exec chmod 644 {} +

Drupal

The Drupal permissions guide is more conservative. sites/default/settings.php should be 444 and ideally owned by root, with the webserver user only in the read group. The sites/default/files tree must be writable by the webserver user and nobody else. Drupal also writes Twig compile output into sites/default/files/php; on a fresh recovery that directory is often empty and silently regenerates on the first authenticated request, so don't panic when it shows up after the fact.

Joomla

Joomla expects the same 755 / 644 baseline. configuration.php should be 644 owned by the PHP user, or 444 if you only edit it through deploys. The administrator and the front-end both write to cache, tmp, logs, and administrator/cache, so those four directories need to be writable by the PHP user with the right ownership.

chmod 644 configuration.php
find cache tmp logs administrator/cache -type d -exec chmod 755 {} +
find cache tmp logs administrator/cache -type f -exec chmod 644 {} +

Magento 2

Magento 2 needs 770 on var, generated, and pub/media, with the setgid bit on directories so new files inherit the right group. The official bin/magento sequence handles this, but when you are recovering by hand:

find var generated pub/media pub/static -type d -exec chmod 2770 {} +
find var generated pub/media pub/static -type f -exec chmod 660 {} +
chmod 440 app/etc/env.php

The setgid bit (the leading 2 in 2770) is the load-bearing part. Magento's CLI writes new files into var/cache and generated/code on every deploy, and without setgid those files inherit the CLI user's primary group rather than the webserver group, which means the next request can't read them. The Adobe Commerce file system reference documents the full ownership matrix, and it is worth reading once even if you only manage one Magento store.

Restoring ownership

Mode is half the story. The other half is the user and group that own the files. A single chown over the whole tree puts that right.

# Plain LAMP on Debian or Ubuntu
chown -R www-data:www-data /var/www/clients/wonen-23/htdocs

# cPanel under PHP-FPM
chown -R wonen23:wonen23 /home/wonen23/public_html

# Plesk under PHP-FPM
chown -R wonen23:psacln /var/www/vhosts/wonen.example/httpdocs
# Setgid so new files inherit the shared group
find /var/www/vhosts/wonen.example/httpdocs -type d -exec chmod g+s {} +

The setgid trick on Plesk and other shared-group setups is worth remembering. Without it, files created later by the PHP process get the process's primary group rather than the vhost group, and you end up with a slow drift back into permission errors over months as new uploads land in the wrong group.

Verifying the recovery

You want two checks before you sign off. The first one lists anything still world-writable, which is what you were trying to undo in the first place.

find /var/www/clients/wonen-23/htdocs -perm /o+w

On a clean tree this returns nothing. If it lists anything that is not a deliberately writable cache directory, that file is still in the broken state and the chmod did not reach it (often because it was a symlink or lived on a different filesystem).

The second check is to tail the real PHP error log while loading the homepage and the admin login. If suEXEC or PHP-FPM is still unhappy, you will see the rejection within a few seconds.

tail -f /var/log/apache2/suexec.log /var/log/php8.2-fpm.log

Open the log paths that match your stack, not the ones above. On a cPanel box that means /usr/local/apache/logs/suexec_log and the per-user error log under /home/USER/logs/. On a Plesk box it is /var/log/plesk-php-fpm/USER/error_log. On a plain nginx host it is /var/log/nginx/error.log alongside /var/log/php8.2-fpm.log. The handler rejection always logs the offending path, so grep for the directory you just fixed and confirm the entries stop appearing.

Finally, hit a real request. On WordPress that is the admin login; on Drupal it is /user/login; on Magento it is the storefront homepage. Tail the access log and confirm a 200 comes back. A 500 at this stage usually means an OPcache that still holds the pre-recovery file metadata; systemctl reload php8.2-fpm clears it without dropping connections.

Keeping a perms baseline you can return to

The reason the recovery above is doable in twenty minutes, not twenty hours, is that the agency had a sibling vhost we could read the correct values off. If yours is a one-of-one box, write the baseline down before you need it.

A small script checked into the site repo is enough:

#!/usr/bin/env bash
# scripts/restore-perms.sh
set -euo pipefail
ROOT="${1:-/var/www/clients/wonen-23/htdocs}"
OWNER="${2:-www-data:www-data}"

chown -R "$OWNER" "$ROOT"
find "$ROOT" -type d -exec chmod 755 {} +
find "$ROOT" -type f -exec chmod 644 {} +
chmod 640 "$ROOT/wp-config.php" 2>/dev/null || true

Committed in scripts/, this is the file you run at 23:41 instead of chmod -R 777. It is also the file you hand a new junior on the team when they ask what the permissions are supposed to be. The answer stops being tribal knowledge and becomes a one-line command.

Two refinements pay for themselves the second time you use the script. First, log every run to a known location so you have an audit trail: echo "$(date -Iseconds) restore-perms $ROOT $OWNER" >> /var/log/perms-restore.log. Second, refuse to run unless the PHP-FPM pool actually agrees with the OWNER argument. A one-line grep against the pool file catches the case where a colleague changed the pool user without telling you, and stops the script from confidently applying the wrong owner across 40,000 files.

Treating permission changes as discrete events

When we built Pier we ran into this exact pattern on customer sites: someone had nuked permissions months earlier, and the only way to figure out the original modes was to read another customer's vhost or dig through a backup tarball. The way we ended up handling it was to record every permission and ownership change as a discrete event, so the version history for a tree can be rewound the same way you rewind a file edit. The same approach covers the MySQL editor: every UPDATE is its own undoable step rather than a one-way trip.

The smallest thing you can do today, with or without any of that: run find /var/www -perm /o+w on the boxes you maintain, and write the results into a text file next to your runbook. Most legacy hosts have at least one 777 directory that nobody remembers creating. Knowing where they already are is half the cleanup.

— Questions —

Why does chmod -R 777 break PHP execution on most modern hosts?

Handlers like suEXEC, suPHP, mod_ruid2, and CageFS refuse to execute scripts in world-writable directories by design. The worker rejects the file before PHP ever runs it.

What is the correct mode for wp-config.php?

640 owned by the PHP user is the safest default that still lets WordPress read the file. 600 also works on single-user setups. Never leave it at 777 even briefly.

Will 755 on a directory let attackers read sensitive files?

755 lets any local user list and read the directory contents. Keep sensitive files inside at 640 or stricter, owned by the PHP user. Mode alone is not a security boundary on a shared box.

How do I find every world-writable file on a server?

Run find / -xdev -perm /o+w 2>/dev/null. The -xdev flag stops it crossing filesystem boundaries. Pipe to a file and read it offline rather than scrolling the terminal.