WordPress Auto-Updates Silently Fail for Months After a Root-Run Update¶
TL;DR — If a WordPress update is ever run as
root(for examplewp core updatefrom a root shell), it leaves root-owned files behind. The web server user can no longer write to them, so every later automatic update fails quietly. The site keeps working, nothing alerts, and security patches stop landing. The tell is a site that flickers into maintenance mode (HTTP 503) at the same two times every day. Fix: restore ownership, then run the backlog by hand in a watched session. Runwpas the web user from then on.
Symptom¶
- A web-server "5xx" alert fires, and the 503s cluster at exactly the same two times every day (the 12-hourly update cycle), usually only a handful of requests wide.
- Access logs from past days show the same pattern at the same hours. Most days the blip is too small to trip an alert; a scanner or monitor hitting during the window is what finally crosses a threshold.
- The 12-hourly update run takes roughly six times longer than other runs of the same cron job, and the site admin receives an "update failed" email, if anyone reads that mailbox.
wp plugin list --update=availableshows a long list of updates pending.wp core check-updateshows several releases behind.
Why it happens¶
The automatic updater runs as the web user (www-data on Debian/Ubuntu). Before replacing a plugin, WordPress has to move the current copy into wp-content/upgrade-temp-backup/ so it can roll back. Before replacing core files, it has to write to them directly.
If an earlier update was run as root, the files and directories it created or replaced are owned by root. The web user can't write to them, so:
- Plugin updates fail at the backup step, before any new code is installed.
- Core updates are recorded as failed with a
files_not_writablelist naming the root-owned files, and are not retried.
Each attempt puts the site into maintenance mode while it tries. That is where the daily 503 blip comes from. The attempt then fails and the site comes back.
Why nobody noticed. In the case that produced this article, the cron line sent all output to /dev/null, WP_DEBUG was off, and the failure emails went to the site admin's own address rather than the sysadmin's. The site stayed up and the front end looked fine, so five and a half months of security updates were missed, including those for a commerce stack with payment gateways.
Diagnose (read-only)¶
Run these as root, but have WordPress commands act as the web user:
cd /var/www/html # your WordPress root
# 1. How much of the tree is NOT owned by the web user?
find . -not -user www-data | wc -l
# 2. Is the plugin rollback directory writable by the web user?
ls -ld wp-content/upgrade-temp-backup wp-content/upgrade-temp-backup/plugins
# 3. What does WordPress itself say about the last failed core update?
sudo -u www-data wp option get auto_core_update_failed --format=json
# 4. What is waiting?
sudo -u www-data wp core check-update
sudo -u www-data wp plugin list --update=available
A large count in step 1, root-owned rollback directories in step 2, and files_not_writable in step 3 confirm it. Step 1 is the quickest check, and the count is often in the tens of thousands.
Fix¶
- Take backups first. Database dump, a tarball of the tree, and a provider-level snapshot if you have one. You are about to apply months of updates at once.
- Restore ownership:
chown -R www-data:www-data /var/www/html. Don't change modes; ownership alone is the problem. Hash the mode listing before and after if you want proof nothing else moved. - Run the backlog deliberately, in a watched session, not by waiting for the next unattended cycle to apply everything at once. Core first, then plugins (commerce and payment plugins one at a time, with a page load check after each), then themes. Use
sudo -u www-data wp …throughout. - Verify against reality, not the exit code.
wp core check-updatesays "latest",wp plugin list --update=availableis empty, andfind . -not -user www-datareturns0. - Watch two real updater cycles before calling it fixed. They should show no 503s and a normal cron run time.
Gotchas found while fixing it¶
- PHP
memory_limitmay be too tight for a big post-update spike. A 128 MB limit produced a short burst of "Allowed memory size exhausted" fatals in the first seconds after the three largest swaps (a ten-plugin batch, WooCommerce, Jetpack). They hit unrelated files each time, which is the signature of a cold opcache plus post-update routines, not a bad plugin. Steady state was clean after every stage. Raising the limit to 256 MB removed the risk for future updates. - Don't let a "no 503s" grep fool you. A naive
grep 503 access.logmatched a response size of 503 bytes on a 200 response. Match the status field, not the substring. - Plugins without an update channel stay behind. Premium plugins with an expired licence can't update. That is a licensing decision, not an ownership problem. Leave them installed and note them separately.
- Set
DISALLOW_FILE_EDITinwp-config.phpwhile you're in there. It closes the in-dashboard code editor and costs nothing.
Prevent a repeat¶
- Never run
wpas root against a live site. Usesudo -u www-data wp …, or pass--allow-rootonly when you immediately restore ownership afterwards. - Alert on the daily blip. A recurring 503 at fixed hours is the earliest signal; trend it rather than only alerting on counts.
- Send updater cron output somewhere you read instead of
/dev/null, and point WordPress's failure emails at an address someone checks. - Add an ownership check to routine health checks:
find <wp-root> -not -user www-data | wc -lshould be0.