WordPress updates are supposed to be boring. Click “Update,” wait a few seconds, and move on with your life.
Then production decides to remind you that it runs on filesystems, permissions, and whatever charming decision you made six months ago at 2 a.m.
If you’re seeing “Update failed,” “Could not create directory,” “Permission denied,” “PCLZIP_ERR,” “Disk full,” or a site stuck in maintenance mode,
this is the practical path out. Not the “chmod 777 until it works” path. The correct one.
Fast diagnosis playbook
When updates fail, don’t start by changing permissions. Start by identifying the bottleneck.
WordPress is telling you “update failed,” but the system is telling you why. Your job is to listen to the system.
First: determine what kind of failure it is
- Is the filesystem writable for the web/PHP user? If not, you’ll get “Permission denied” or “Could not create directory.”
- Is disk space or inodes exhausted? Updates write temp files, unzip archives, and do atomic renames. They need headroom.
- Is the temp directory writable and large enough? PHP uses
/tmp(often tmpfs) or a configured temp path. - Is WordPress stuck in maintenance mode? It may have succeeded halfway and left
.maintenancebehind. - Is the update process timing out? Big plugins/themes can trip PHP timeouts and memory limits.
Second: check the error at the right layer
- WordPress UI / logs: The admin UI messages are often vague. Better than nothing, but not enough.
- Web server / PHP-FPM logs: Often contain the real “Permission denied” path or “No space left on device.”
- System layer:
df,mount,ls -l,namei,getfacl. This is where the truth lives.
Third: do the least risky fix first
- Disk space/inodes: Safe to check and often immediately explains “mysterious” failures.
- Ownership mismatches: Fix the mismatch so the intended user can write. Avoid world-writable.
- Permission bits/ACL: Fix to minimum necessary. Keep
wp-config.phptight. - Timeout/memory: If it fails mid-unzip or mid-download, adjust PHP limits or use WP-CLI.
One guiding principle: if your fix is “make everything 777,” you didn’t fix it. You just moved the blast radius to security.
How WordPress updates actually work (and why they fail)
WordPress updates are file operations dressed up as a button. WordPress (through PHP) downloads a zip, writes it to a temp area,
unpacks it, then swaps files into place. Plugins and themes are similar, just aimed at different directories.
The common update paths
- Core updates: write into the WordPress root and
wp-includes/wp-admin. It will also write.maintenance. - Plugin updates: write into
wp-content/pluginsand may replace a plugin directory entirely. - Theme updates: write into
wp-content/themes. - Language packs: write into
wp-content/languages.
Why failure happens
Updates fail when PHP can’t write files where it needs to, or can’t create temp files, or can’t rename directories,
or runs out of resources mid-flight. The error message you see depends on which function failed: filesystem init, unzip, copy, rename, or cleanup.
The biggest conceptual mistake: thinking “WordPress” owns these files. It doesn’t. The OS does. The web server user does. Your deployment process does.
WordPress is just a PHP process asking the OS for permission to perform file operations.
Interesting facts and history (short, useful, slightly nerdy)
- Automatic background updates in WordPress core became mainstream around 3.7, shifting failure modes from “human forgot” to “machine tried and hit permissions.”
- WordPress uses a filesystem abstraction layer that can choose between “direct” writes and FTP/SSH methods depending on what it detects.
- Many “WordPress permission” problems are actually parent directory traversal problems; one non-executable (
x) bit on a directory can block everything below. - Inodes matter as much as bytes; you can have gigabytes free and still fail if you used all inodes (common on small-block filesystems with lots of cached files).
- Atomic renames are a classic deployment trick, but they require write permission on the parent directory, not just the target.
- tmpfs for /tmp is common on modern Linux; it’s fast, but it’s sized from RAM and can fill during unzip operations.
- zip extraction in PHP historically relied on external tools or libraries; missing ZipArchive support still shows up as unzip errors in some builds.
- “WordPress stuck in maintenance mode” is usually a leftover file named
.maintenance, created during updates and removed after success.
Hands-on tasks: commands, outputs, decisions (12+)
These are the checks I run in production, in roughly the order they pay off. Each task includes:
the command, what typical output means, and the decision you make based on it.
Task 1: Confirm disk space on the relevant mount
cr0x@server:~$ df -hT /var/www/html
Filesystem Type Size Used Avail Use% Mounted on
/dev/vda1 ext4 40G 39G 520M 99% /
Meaning: 99% used. Updates need space for downloads, extraction, and sometimes double-writing.
Decision: Fix disk space before touching permissions. Clean logs, old backups, caches, or expand the filesystem. If you “chmod” now, you’re polishing a sinking ship.
Task 2: Check inode exhaustion (yes, it happens)
cr0x@server:~$ df -i /var/www/html
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 2621440 2621400 40 100% /
Meaning: You’re out of inodes. Creating one more file fails with “No space left on device” even if bytes are available.
Decision: Identify directories with huge file counts (cache folders, session dirs) and purge. Don’t reboot and hope; that doesn’t delete files.
Task 3: Identify the PHP execution user (the one that actually writes)
cr0x@server:~$ ps -eo user,comm,args | egrep 'php-fpm|apache2|httpd' | head
www-data php-fpm8.2 php-fpm: pool www
www-data php-fpm8.2 php-fpm: pool www
root nginx nginx: master process /usr/sbin/nginx -g daemon on;
Meaning: PHP-FPM workers run as www-data. Nginx master is root, but workers proxy to PHP-FPM.
Decision: The write permissions must work for www-data, not for your SSH user. If you “fix” ownership for yourself, updates still fail.
Task 4: Validate the WordPress root and wp-content ownership quickly
cr0x@server:~$ ls -ld /var/www/html /var/www/html/wp-content
drwxr-xr-x 5 root root 4096 Dec 27 08:10 /var/www/html
drwxr-xr-x 10 root root 4096 Dec 27 08:10 /var/www/html/wp-content
Meaning: Root owns everything; group isn’t writable; web user can’t write. Plugin/theme updates will fail.
Decision: Decide your ownership model (see later). For most single-server setups: make www-data owner of writable paths, or set group ownership and use a shared group.
Task 5: Confirm the exact failing path from logs
cr0x@server:~$ sudo tail -n 50 /var/log/php8.2-fpm.log
[27-Dec-2025 08:12:19] WARNING: [pool www] child 22190 said into stderr: "PHP Warning: copy(/var/www/html/wp-content/plugins/akismet/.htaccess): failed to open stream: Permission denied in /var/www/html/wp-admin/includes/class-wp-filesystem-direct.php on line 309"
Meaning: This is not a vague “update failed.” It’s a specific file path and function. The OS denied the write.
Decision: Fix permissions/ownership for that directory subtree. If the path is under wp-content, that’s your writable zone.
Task 6: Verify directory traversal permissions with namei
cr0x@server:~$ namei -l /var/www/html/wp-content/plugins
f: /var/www/html/wp-content/plugins
drwxr-xr-x root root /
drwxr-xr-x root root var
drwxr-xr-x root root www
drwxr-xr-x root root html
drwxr-xr-x root root wp-content
drwxr-xr-x root root plugins
Meaning: Even if plugins looked fine, you must have execute (x) on every directory in the path to traverse it.
Decision: If any parent dir lacks x for the web user (or group), fix that. “But wp-content is writable” doesn’t help if /var/www blocks traversal.
Task 7: Check ACLs if permissions look “right” but still fail
cr0x@server:~$ getfacl -p /var/www/html/wp-content | sed -n '1,20p'
# file: /var/www/html/wp-content
# owner: root
# group: root
user::rwx
group::r-x
other::r-x
Meaning: No ACL entries here; it’s classic mode bits. If you see an ACL denying access, that can override your expectations.
Decision: If ACLs exist and are wrong, fix them intentionally. Don’t randomly strip ACLs unless you know why they were added.
Task 8: Confirm the mount is not read-only
cr0x@server:~$ mount | grep ' / '
/dev/vda1 on / type ext4 (rw,relatime,errors=remount-ro)
Meaning: Mounted read-write now, but errors=remount-ro means a filesystem error could flip it to read-only.
Decision: If you see (ro,...), stop. Fix the underlying disk/filesystem problem first (dmesg, fsck, storage health). Updates can’t proceed.
Task 9: Verify PHP temp directory and its free space
cr0x@server:~$ php -i | egrep 'upload_tmp_dir|sys_temp_dir|temporary' | head
upload_tmp_dir => no value => no value
sys_temp_dir => no value => no value
cr0x@server:~$ df -hT /tmp
Filesystem Type Size Used Avail Use% Mounted on
tmpfs tmpfs 1.0G 980M 44M 96% /tmp
Meaning: PHP defaults to system temp (often /tmp). If /tmp is tiny or full, unzip/download fails.
Decision: Free space in /tmp or set a dedicated temp directory on disk with correct permissions for PHP-FPM.
Task 10: Confirm ZipArchive availability
cr0x@server:~$ php -m | grep -i zip
zip
Meaning: Zip support is loaded. If missing, WordPress may fall back to slower methods or fail depending on environment.
Decision: If not present, install/enable the PHP zip extension and restart PHP-FPM. Unzip errors are often “dependency missing,” not “WordPress is broken.”
Task 11: Check for the maintenance mode file
cr0x@server:~$ ls -la /var/www/html/.maintenance
-rw-r--r-- 1 root root 58 Dec 27 08:12 /var/www/html/.maintenance
Meaning: WordPress thinks an update is in progress. If an update crashed, this can persist and block the site.
Decision: Remove it only after you confirm no update is actively running. Then re-run the update.
Task 12: Use WP-CLI to reproduce the failure with clearer output
cr0x@server:~$ cd /var/www/html
cr0x@server:~$ sudo -u www-data wp plugin update --all
Downloading update from https://downloads.wordpress.org/plugin/example.1.2.3.zip...
Unpacking the update...
Error: Could not create directory '/var/www/html/wp-content/upgrade'.
Meaning: The failure is consistent and points to the upgrade working directory.
Decision: Ensure wp-content is writable and specifically allow creating wp-content/upgrade.
Task 13: Verify ownership and permissions on the specific failing directory
cr0x@server:~$ ls -ld /var/www/html/wp-content /var/www/html/wp-content/upgrade
drwxr-xr-x 10 root root 4096 Dec 27 08:10 /var/www/html/wp-content
ls: cannot access '/var/www/html/wp-content/upgrade': No such file or directory
Meaning: It can’t create upgrade because wp-content isn’t writable by PHP.
Decision: Fix ownership/group write for wp-content (not necessarily the entire WordPress root).
Task 14: Spot “permission denied” caused by immutable attribute
cr0x@server:~$ lsattr -d /var/www/html/wp-content
-------------e---- /var/www/html/wp-content
Meaning: No immutable flag here. If you see i, the file/directory is immutable and can’t be modified even by root.
Decision: If immutable is set, remove it deliberately on the affected paths. Usually this was a “hardening” attempt or a misguided backup script.
Fix permissions and file ownership without breaking security
You need a model. “Just make it writable” is not a model. A model tells you who is allowed to write which directories, and why.
The three sane ownership models
Model A: Web user owns writable content (common, simple, riskier)
In this model, www-data (or whatever PHP runs as) owns wp-content and can write plugin/theme uploads and updates.
Core files may remain owned by root to reduce the risk of core tampering.
Use it when: single server, small team, updates via admin UI are expected, you accept the security tradeoff.
Avoid it when: compliance constraints, shared hosting with multiple sites, or you want immutability for code.
Model B: Shared group ownership + setgid directories (my default for teams)
Create a group (say wp), make both deploy user and web user members, and set group ownership on the writable areas.
Then use setgid on directories so new files inherit the group.
Use it when: you deploy via SSH/CI and still want WordPress to write to wp-content.
Model C: Web user writes nothing; deployments and updates are CI-only (most secure, most disciplined)
WordPress updates happen via CI pipelines or maintenance windows. The web user can’t write into the codebase.
Uploads (media) may live on a separate writable mount or object store integration.
Use it when: regulated environments, lots of traffic, multiple instances, or you’ve been burned before.
Pick one. Mixing them casually is how you end up with a filesystem that works only when you’re logged in as the “right” person on Tuesdays.
Practical, safe permission targets
- Directories: typically
755(or775if you rely on group write). - Files: typically
644(or664with group write). - wp-config.php: usually
640or600, depending on group strategy.
WordPress needs to write to wp-content (plugins/themes/languages/uploads) for admin-driven updates.
It does not need to write everywhere else, and you shouldn’t let it unless you have a reason.
Implement Model B (shared group) without drama
cr0x@server:~$ sudo groupadd -f wp
cr0x@server:~$ sudo usermod -aG wp www-data
cr0x@server:~$ sudo usermod -aG wp deploy
cr0x@server:~$ sudo chgrp -R wp /var/www/html/wp-content
cr0x@server:~$ sudo find /var/www/html/wp-content -type d -exec chmod 2775 {} \;
cr0x@server:~$ sudo find /var/www/html/wp-content -type f -exec chmod 664 {} \;
Meaning: Group is wp. Directories are setgid (2 in 2775) so new files keep group ownership.
Decision: If you see new files coming in as the wrong group later, your umask or service configuration is fighting you. Fix that next.
Set a predictable umask for PHP-FPM (optional but often necessary)
If PHP-FPM creates files with restrictive permissions, updates can partially succeed then fail later when another process tries to modify them.
The fix is to ensure consistent file creation permissions.
cr0x@server:~$ sudo grep -R "umask" -n /etc/php/8.2/fpm/pool.d
/etc/php/8.2/fpm/pool.d/www.conf:404:;php_admin_value[error_log] = /var/log/php8.2-fpm.log
Meaning: No explicit umask in pool config (common). Creation modes depend on defaults.
Decision: If you need group-writable artifacts, set umask at service level or enforce with ACLs (below).
Use default ACLs to keep group write consistent (powerful, easy to forget later)
cr0x@server:~$ sudo setfacl -R -m g:wp:rwx /var/www/html/wp-content
cr0x@server:~$ sudo setfacl -R -d -m g:wp:rwx /var/www/html/wp-content
cr0x@server:~$ getfacl -p /var/www/html/wp-content | sed -n '1,25p'
# file: /var/www/html/wp-content
# owner: root
# group: wp
user::rwx
group::r-x
group:wp:rwx
mask::rwx
other::r-x
default:user::rwx
default:group::r-x
default:group:wp:rwx
default:mask::rwx
default:other::r-x
Meaning: Any new files/dirs created under wp-content get group wp with rwx (subject to mask).
Decision: If your environment is ACL-friendly, this prevents the “created as 640, now update can’t overwrite” problem. If your backups or sync tools don’t preserve ACLs, document it.
Joke #1: If your incident plan is “chmod -R 777,” congratulations—you’ve invented a distributed security breach generator.
Fix disk space and inode problems (the silent killers)
WordPress updates are deceptively space-hungry. The process often needs:
download space for zip, extraction space for unzipped files, plus the final installed files.
In practice you want at least a few hundred megabytes free for small updates and more for big plugins/themes.
Find what’s using space fast
cr0x@server:~$ sudo du -xhd1 /var | sort -h
8.0M /var/cache
120M /var/log
4.2G /var/lib
12G /var/www
Meaning: /var/www is huge. That may be uploads, backups, or caches.
Decision: Drill into the biggest directory. Don’t delete blindly. If it’s uploads, consider moving media off-root disk or pruning old backups.
Find inode-heavy directories (the “lots of tiny files” problem)
cr0x@server:~$ sudo find /var/www/html/wp-content -xdev -type f | wc -l
412356
Meaning: Hundreds of thousands of files. Cache plugins can do this. Each file consumes an inode.
Decision: Identify cache directories and configure cache to use fewer files or different storage. If it’s a cache, it’s allowed to die.
Locate common WordPress “space pigs”
cr0x@server:~$ sudo du -sh /var/www/html/wp-content/* | sort -h | tail -n 10
120M /var/www/html/wp-content/languages
1.3G /var/www/html/wp-content/plugins
3.8G /var/www/html/wp-content/uploads
9.1G /var/www/html/wp-content/cache
Meaning: Cache is enormous. That’s often safe to purge during an incident (with performance consequences).
Decision: Clear cache to regain space, then fix root cause (cache configuration, log rotation, offloading uploads, disk sizing).
Clear a cache directory safely (example)
cr0x@server:~$ sudo systemctl stop php8.2-fpm
cr0x@server:~$ sudo rm -rf /var/www/html/wp-content/cache/*
cr0x@server:~$ sudo systemctl start php8.2-fpm
Meaning: You’re avoiding concurrent writes while clearing. Not always required, but reduces edge-case pain.
Decision: If stopping PHP-FPM is too disruptive, delete more carefully (or clear via plugin UI). But in an emergency, space beats cache.
Temp directories, unzip failures, and PHP limits
Unzip errors are usually blamed on WordPress because it’s the part you can see. But the failure is often:
temp storage is full, permissions are wrong, ZipArchive is missing, or PHP runs out of memory mid-extraction.
Confirm where PHP is writing temp files
cr0x@server:~$ php -r 'echo sys_get_temp_dir().PHP_EOL;'
/tmp
Meaning: PHP uses /tmp.
Decision: Ensure /tmp is writable by PHP-FPM user and has space. If /tmp is tmpfs and frequently full, move temp elsewhere.
Set a dedicated temp directory for WordPress updates
A pragmatic approach: create /var/tmp/wp-tmp, make it owned by the PHP user, and point WordPress at it using WP_TEMP_DIR.
cr0x@server:~$ sudo install -d -o www-data -g www-data -m 1770 /var/tmp/wp-tmp
cr0x@server:~$ sudo grep -n "WP_TEMP_DIR" /var/www/html/wp-config.php || true
cr0x@server:~$ sudo sh -c "printf '\ndefine(\"WP_TEMP_DIR\", \"/var/tmp/wp-tmp\");\n' >> /var/www/html/wp-config.php"
cr0x@server:~$ sudo tail -n 3 /var/www/html/wp-config.php
define("WP_TEMP_DIR", "/var/tmp/wp-tmp");
Meaning: WordPress now uses a controlled temp directory. 1770 gives sticky-ish behavior via group separation; adjust to your model.
Decision: If multiple pools/users use the same directory, separate them. Shared temp becomes a permission mud pit.
Check PHP memory limit and max execution time (common for large plugin updates)
cr0x@server:~$ php -i | egrep 'memory_limit|max_execution_time' | head -n 2
memory_limit => 128M => 128M
max_execution_time => 30 => 30
Meaning: 128MB and 30s are fine for small sites, but can fail on big updates, slow disks, or overloaded servers.
Decision: If updates time out, raise these for admin/update operations or run updates with WP-CLI in a controlled shell (still uses PHP, but less web timeout pressure).
Apache, Nginx, PHP-FPM: user models that change everything
Permissions are not “a WordPress thing.” They’re a “which Unix user is writing files” thing. Different stacks choose different users.
Apache with mod_php
PHP runs inside Apache worker processes. The Apache user (often www-data or apache) is the file writer.
If Apache is writing as www-data but your deploys are writing as deploy with umask 077, you’ll get mixed ownership and future failures.
Nginx + PHP-FPM
Nginx serves static files and forwards PHP to PHP-FPM. The user that writes is the PHP-FPM pool user, not Nginx.
This is good: PHP runs as a more controlled identity. It also means “Nginx has access” is irrelevant if PHP-FPM doesn’t.
Multiple sites on one host
If you run multiple WordPress sites under the same www-data, you’ve created a shared fate environment.
A compromise in one site can often write into another site’s directories if permissions allow.
If you’re serious about security, use per-site users/pools and isolate at the filesystem level.
One operational quote (paraphrased idea)
“Hope is not a strategy.” — paraphrased idea commonly attributed to engineering and operational leadership.
Three corporate mini-stories from real life patterns
Mini-story 1: The incident caused by a wrong assumption
A mid-sized company ran WordPress for marketing, but the system lived on a “real” production VM with other services.
Their internal assumption was simple: “If the site is running, updates are safe to do anytime.”
That assumption is how you get paged by a website.
The marketing team clicked update on a plugin during business hours. The update failed, leaving .maintenance behind.
The site started returning maintenance mode responses. Sales escalated because the campaign landing page was down.
The initial responder did what many people do under pressure: restarted services. This didn’t help. It just made logs harder to correlate.
The actual root cause was disk pressure. Not full enough to trigger monitoring, but low enough that extracting a zip in /tmp failed.
/tmp was tmpfs-sized from RAM and was already crowded due to unrelated processes.
WordPress took the site into maintenance, tried to write upgrade files, failed, and never cleaned up.
The fix was boring: free space, move temp to disk, remove .maintenance, rerun update via WP-CLI.
The lesson wasn’t “don’t update during business hours” (though, maybe). The lesson was: updates are storage operations; treat them like deployments.
Mini-story 2: The optimization that backfired
Another org wanted “faster performance.” Someone decided to mount the entire WordPress directory on a network filesystem
so multiple app servers could share the same code and uploads. The pitch sounded reasonable in a meeting.
The filesystem did not attend that meeting.
Updates began failing intermittently with weird unzip errors and incomplete plugin directories.
Sometimes the admin UI said success, but the next page load crashed with missing PHP files.
Engineers chased permissions, PHP versions, even “maybe WordPress is corrupt” theories.
The real issue was semantics and latency: network filesystem behavior around file locks and atomic renames didn’t match what the update process expected.
The update process relies on filesystem operations that are reliable and fast, especially when swapping directories.
Under load, operations lagged, timed out, or returned inconsistent directory listings to different nodes.
They fixed it by separating concerns: code deployed as immutable artifacts per node, uploads stored separately with a design that matched the storage’s strengths.
Updates moved to CI, not the admin UI. Performance improved, and the update process stopped playing roulette.
Mini-story 3: The boring but correct practice that saved the day
A large enterprise had a habit that looked old-fashioned: every WordPress update was executed through WP-CLI in a maintenance window,
preceded by a filesystem snapshot and followed by a quick integrity check.
Nobody bragged about it. It was just “the runbook.”
One day, a plugin update introduced a fatal error due to an unexpected PHP dependency.
The admin UI wasn’t reachable due to the fatal error, but the site was still serving cached pages—until the cache expired.
The incident could have escalated into a full outage.
Because updates were run via WP-CLI with logs captured, they knew exactly which plugin changed and when.
Because the filesystem was snapshotted, rollback was a matter of seconds.
They rolled back the plugin directory, restored service, and then tested the update in staging with the missing PHP module installed.
Nothing heroic happened. That’s the point. Boring practices are how you get to be boring in the middle of a failure.
Common mistakes: symptoms → root cause → fix
These are the patterns I see repeatedly. The symptom is what you’ll notice. The root cause is what’s actually broken. The fix is specific.
1) “Could not create directory” during plugin/theme update
- Symptom: Update fails; WP-CLI shows cannot create
wp-content/upgradeor a plugin directory. - Root cause:
wp-contentnot writable by PHP user; or parent directories missing execute bit. - Fix: Adjust ownership/group and permissions for
wp-contentonly; verify withnamei -land rerun update.
2) “Update failed” with no details; logs show “No space left on device”
- Symptom: UI is vague; PHP logs show ENOSPC while copying/unzipping.
- Root cause: Disk full or inode exhaustion; sometimes
/tmpfull on tmpfs. - Fix: Check
df -hT,df -i, anddf -h /tmp. Free space or move temp. Then rerun.
3) Site stuck in maintenance mode after failed update
- Symptom: Frontend shows maintenance message indefinitely.
- Root cause:
.maintenanceleft behind due to crash/timeout/permission error. - Fix: Verify no update process is running; remove
.maintenance; fix underlying error; rerun update via WP-CLI.
4) Updates work when run as root over SSH but fail in admin UI
- Symptom: Manual file changes succeed; UI updates fail.
- Root cause: Wrong assumption about file writer; PHP runs as unprivileged user, not root.
- Fix: Align filesystem permissions with PHP user (or shared group model). Stop “fixing” things as root and calling it done.
5) Some files are owned by deploy user, others by www-data; updates randomly fail later
- Symptom: Today’s update succeeds; next week’s fails. Ownership is inconsistent across tree.
- Root cause: Mixed update paths: sometimes via admin UI (www-data), sometimes via SSH/CI (deploy), with incompatible umask.
- Fix: Choose a model. Use shared group + setgid + (optional) default ACLs. Enforce consistent umask in your deploy process.
6) “PCLZIP_ERR” or unzip error on large plugins/themes
- Symptom: Extraction errors, incomplete directories, or timeouts.
- Root cause: Low temp space, missing ZipArchive, memory/time limits, or slow storage.
- Fix: Ensure zip extension is present, allocate temp space, and adjust PHP limits. Prefer WP-CLI for controlled updates.
7) Updates fail only on one node in a cluster
- Symptom: One node reports missing plugin files or different versions.
- Root cause: Local disks drifted; shared storage semantics mismatch; deployments not coordinated.
- Fix: Stop updating via admin UI on clustered setups. Use CI deployment and keep code immutable across nodes.
Joke #2: The update process is like a cat—if you don’t control where it writes, it will choose the least convenient place available.
Checklists / step-by-step plan
Incident checklist: get the site back and finish the update
- Check disk and inodes:
df -hT,df -i, anddf -h /tmp. Free space first. - Check maintenance mode: remove
.maintenanceonly after confirming no update is running. - Read the logs: PHP-FPM and web server error logs for the precise failing path.
- Confirm PHP user: identify whether writes happen as
www-data,apache, or a pool user. - Fix writability of wp-content: ownership/group/ACL as per chosen model. Verify with
namei -l. - Retry via WP-CLI: it is clearer, scriptable, and less subject to browser timeouts.
- Validate: load homepage, admin, and a couple of pages that exercise plugins (forms, checkout, etc.).
Hardening checklist: prevent repeat incidents
- Pick your ownership model: web-owned, shared group, or CI-only. Document it. Enforce it.
- Separate writable areas: keep
wp-content/uploadswritable; consider making core read-only. - Set predictable permissions: directory setgid and/or default ACLs for group write.
- Size temp storage: dedicated temp directory if
/tmpis too small or shared. - Monitor the right things: disk bytes, inodes, and
/tmputilization. Alerts should fire before you hit 99%. - Prefer WP-CLI for production updates: capture logs; run in maintenance window; automate rollback steps.
Step-by-step: a safe “fix permissions” runbook (shared group model)
This is the plan that tends to work in real organizations where both humans and automation touch the filesystem.
- Create group
wp; add bothwww-dataand your deploy user. - Set group ownership on
wp-contentand setgid on its directories. - Set file and directory modes to allow group write, but not world write.
- Optionally set default ACLs to keep behavior consistent even with varying umask.
- Verify by creating a test file as
www-dataand as deploy user, confirm group and write bits.
cr0x@server:~$ sudo -u www-data bash -lc 'touch /var/www/html/wp-content/.permtest && ls -l /var/www/html/wp-content/.permtest'
-rw-rw-r-- 1 www-data wp 0 Dec 27 08:40 /var/www/html/wp-content/.permtest
cr0x@server:~$ sudo -u deploy bash -lc 'echo ok >> /var/www/html/wp-content/.permtest && tail -n 1 /var/www/html/wp-content/.permtest'
ok
Meaning: Both identities can write. Ownership and group are consistent.
Decision: If either user can’t write, fix group membership, directory permissions, or ACL masks before attempting updates again.
FAQ
1) Should I ever use chmod -R 777 to fix WordPress updates?
No. It “works” by allowing anyone to write anything, which is a security failure disguised as progress.
Fix ownership and group permissions instead, ideally limited to wp-content.
2) Do I need to make the whole WordPress root writable?
Not usually. For UI-driven plugin/theme updates, wp-content must be writable.
Core updates may require writing in the root and core directories; many teams avoid that by performing core updates via CI/WP-CLI with controlled permissions.
3) Why does WordPress ask for FTP credentials when updating?
WordPress does that when it thinks it can’t write directly to the filesystem. It tries alternative filesystem methods.
On modern Linux servers, you usually want direct writes to wp-content (if you allow UI updates) and correct permissions, not FTP.
4) My update fails with “No such file or directory” during rename. What gives?
Often it’s a partial update: extraction succeeded, then a rename or cleanup failed due to permissions or missing write access on the parent directory.
Check the failing path in logs, verify write permission on the directory where the rename occurs, and rerun via WP-CLI after cleanup.
5) What’s the safest way to handle updates in a multi-node setup?
Don’t update via the admin UI. Use a deployment pipeline so every node gets the same versioned artifact.
Keep code immutable; keep uploads in a dedicated shared store if needed.
6) If wp-content is writable, why do updates still fail?
Because the path to it may not be traversable (x bit missing), /tmp might be full, you may be out of inodes, ACLs may deny access,
or PHP runs as a different user than you think. Use namei -l, df -i, and logs.
7) Is it okay for www-data to own plugin directories?
It’s common and functional. It’s also a security tradeoff: if WordPress or a plugin is compromised, writable plugin directories are a convenient persistence mechanism.
If you can, prefer shared group model or CI-only updates for production.
8) How do I fix “stuck in maintenance mode” safely?
Confirm no update is currently running (check processes and recent logs), then delete the .maintenance file in the WordPress root.
If the underlying cause was permissions or disk space, fix that first or it will happen again on the next attempt.
9) Why do updates fail after I restore from backup?
Restores often change ownership, permissions, or drop ACLs. Suddenly PHP can’t write to wp-content.
After any restore, run a permission/ownership validation and correct it to your chosen model.
10) What’s the minimum monitoring to avoid surprise update failures?
Alert on disk usage and inode usage for the mount containing WordPress, and separately for /tmp if it’s tmpfs.
Also alert on PHP-FPM errors containing “Permission denied” and “No space left on device” so you see patterns before the next update window.
Next steps you can do today
If your WordPress update failed, don’t treat it like a mystical WordPress problem. It’s almost always one of three things:
the process can’t write, the disk is full (or out of inodes), or temp/unzip resources are constrained.
- Run the fast diagnosis: disk/inodes,
/tmp, PHP user, logs. - Fix the smallest surface area: make
wp-contentwritable to the correct identity; avoid making the whole tree writable. - Standardize your model: web-owned, shared group, or CI-only. Mixed ownership is future outages in a trench coat.
- Operationalize updates: WP-CLI in maintenance windows, logs captured, and a rollback plan that doesn’t involve panic.