The notice said a plugin couldn’t create a file. The file was already there. I checked twice, because after enough years you stop reading error messages and start reading ls.
Some background. I’ve been moving a fleet of WordPress sites off the old Bitnami stack onto the cloud provider’s own managed WordPress blueprint, one site at a time, and every so often a migrated box does something the previous forty didn’t. This is one of those.
August: the first version of this bug
A site had been migrated the week before. Everything worked — front end, admin, forms rendering. Then the forms plugin put a notice at the top of the dashboard saying it couldn’t write to its folder under uploads/.
Easy enough to diagnose. The migration restores files as the admin SSH user, not as the web server user, so the directories come out owned by admin and grouped www-data with mode 755. Group can read them and walk into them. It just can’t create anything inside.
So I wrote a repair script. Parents get mode 2775 with the setgid bit, every gravity_forms directory under uploads/ gets the same, then a recursive sweep adds g+w to any directory under uploads/ that’s missing it. Bit-adds only, no chown on the deep tree, idempotent, dry-run flag. It fixed the site and I ran it across the boxes I’d already migrated.
That felt like the end of it.
September: the second version of this bug
Different site. Different plugin. This time the migration plugin itself, complaining in its own admin screen:
All-in-One WP Migration is not able to create /var/www/html/wp-content/plugins/all-in-one-wp-migration/storage/index.php file
Which is not what was happening. The file existed:
$ ls -la /var/www/html/wp-content/plugins/all-in-one-wp-migration/storage/ drwxr-xr-x 2 admin www-data 4096 Sep 16 09:11 . -rw-r--r-- 1 admin www-data 28 Sep 16 09:11 index.php
It couldn’t overwrite it. “Create” was the plugin describing its intention rather than its failure, and I lost a good twenty minutes to that phrasing before I stopped believing it.
Look at the modes. Directory 755, so www-data can’t make anything new in there. File 644, so it can’t rewrite the one already sitting there either. Two different bits stopping two different operations, and PHP-FPM runs as www-data, so it hits both.
Same bug as August. Nowhere near uploads/.
What I’d actually fixed in August
Nothing, really. I’d fixed a directory.
The August script was scoped to uploads/ because that’s where the symptom appeared. But the symptom had nothing to do with uploads — that was just the first place a plugin happened to want to write. The actual cause sits one level up, in how every file on that box gets created in the first place.
When the admin user unpacks a plugin — wp-cli, a tar extract, a restore from the migration plugin, doesn’t matter — the new directories are created with the default umask of 022. Which means:
$ umask 0022 $ mkdir demo && ls -ld demo drwxr-xr-x 2 admin admin 4096 Sep 17 08:02 demo
755. Every time. And the setgid bit on the parent doesn’t save you, which is the part I’d been quietly fuzzy on. Setgid makes the new directory inherit the parent’s group — it does not make it inherit the parent’s permission bits. (A new subdirectory does pick up the setgid flag itself, which is how the thing propagates down a tree at all. The rwx bits still come from the umask.) So under a setgid parent you get a directory that’s correctly grouped www-data and still isn’t group-writable, which looks fine in ls if you’re only skimming the owner and group columns.
The result is a tree that’s permissioned to be shared and, in practice, isn’t.
And then nothing happens. That’s the annoying bit. The box provisions clean and the site loads; smoke tests pass. The permissions only matter when some plugin, weeks later, decides to write into a directory it shipped. On one box that was a forms plugin. On another it was the migration plugin’s own storage folder. It’ll be something else next time.
Fixing the cause instead of the directory
Two changes, at two different points in the box’s life.
First, in the launch script that provisions a new instance. Directories were being set to 775 during the post-install permissions pass. They’re now 2775:
cd /var/www
sudo chown -R admin:www-data .
sudo find . -type d -exec chmod 2775 {} +
sudo find . -type f -exec chmod 664 {} +
The leading 2 is setgid, so anything created under /var/www from here on inherits the www-data group. That’s half the problem. The other half is the umask, and that needs to be set for the admin user system-wide, not per-session:
sudo tee /etc/profile.d/box-umask.sh >/dev/null <<'UMASK'
# Admin ops under /var/www must produce group-writable files so the
# web server user can write into subdirectories admin created.
if [ "$(id -un)" = 'admin' ]; then
umask 002
fi
UMASK
sudo chmod 644 /etc/profile.d/box-umask.sh
022 gives you 755 and 644. 002 gives you 775 and 664. With setgid on the parent and umask 002 on the operator, a directory unpacked by admin comes out drwxrwsr-x admin www-data and PHP can write into it, which is the whole point.
Second, the repair script for boxes provisioned before that change. Same shape as the August version, wider scope, and now three sweeps rather than one:
WPCONT=/var/www/html/wp-content # 3a. directories missing group-write find "$WPCONT" -type d ! -perm -g=w -print0 | xargs -0 -r chmod g+w # 3b. directories missing setgid find "$WPCONT" -type d ! -perm -2000 -print0 | xargs -0 -r chmod g+s # 3c. files missing group-write find "$WPCONT" -type f ! -perm -g=w -print0 | xargs -0 -r chmod g+w
A note on -perm while we’re here, because I get it wrong about half the time. The leading dash in -perm -g=w means “these bits are set, I don’t care about the others”. So ! -perm -g=w reads as “group-write is not set”. Without the dash it’s an exact match on the whole mode and you’ll get almost nothing back and assume you’re done.
Step 3c is the one that actually cleared the September notice. Files come in at 644 even under a group-inheriting parent, and the migration plugin wasn’t creating index.php — it was trying to rewrite one that was already sitting there owner-write-only.
The bit I’m not entirely comfortable with
Step 3c makes plugin PHP files writable by the web server user. That is, if you’ve read any WordPress hardening guide, exactly the thing they tell you not to do.
My defence is that it’s the posture WordPress already assumes. The built-in plugin installer, theme updates and core auto-updates all want direct filesystem writes as the web server user — that’s what FS_METHOD set to direct means, and that’s how these boxes are configured, because the alternative is handing every site owner an SFTP credential they’ll paste into a form. You can have a read-only plugin tree or you can have one-click updates. You can’t have both and pretend otherwise.
What I did keep out of scope is wp-admin and wp-includes. Core doesn’t need the group bit for the sites to work, so it doesn’t get it.
The takeaway, such as it is
The August fix wasn’t wrong. It was correctly scoped to the evidence I had, which was one plugin on one site. It just wasn’t a fix — it was a well-tested description of where I’d noticed the problem.
There’s no clean rule I can hand you here. “Fix the cause, not the symptom” is a poster, and in August the cause looked like it was the directory. What I’d say instead is narrower: when a repair script takes a path argument, be suspicious of the path. If the mechanism you’ve described in the comment header applies to the whole filesystem and you’ve pointed it at one folder, the comment header is telling you the scope is wrong, and you’re the one who wrote it.
Anyway. The setgid-plus-umask pairing is genuinely worth knowing if you run WordPress on a box where a human occasionally unpacks something by hand. Setgid on its own gets you halfway and looks like it got you all the way, which is the worst amount of halfway there is.
