File Permissions and Filesystem
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), WP-CLI 2.12.0 and PHP 8.4 in the official wordpress:7.1.3-php8.4-apache image, and
checked on 2026-10-28. WordPress 7.x runs on PHP 7.4 or later and recommends
8.3 or later; nothing on this page depends on the PHP version. Every scenario in the tables and outputs below was
reproduced on Linux in Docker on the check date.
A WordPress site is a directory of PHP files plus a database. Two operating-system accounts touch those files: the account that owns them (the person or deployment pipeline that uploaded them) and the account that runs PHP (the web server or PHP-FPM pool). Almost every "WordPress asks for FTP credentials", "could not create directory" or "installation failed" problem comes from the relationship between those two accounts. The fix is nearly always to correct ownership, not to loosen permissions to 666 or 777.
The page is written for administrators of the Lumen Press site and for plugin developers who need to write files. Installing the files is covered in Installation, the constants that disable file changes in wp-config.php Reference, and the wider hardening checklist in Security Hardening.
Unix permissions as WordPress sees them
Each file and directory has an owner, a group, and three permission sets: owner (u), group (g) and others (o).
Each set combines read (4), write (2) and execute (1). For a directory, "execute" means "may enter and traverse", so
directories need x wherever files must be reachable. PHP files never need the execute bit: the PHP interpreter reads
them.
| Mode | Symbolic | Use in WordPress |
|---|---|---|
644 |
|
Default for files. The owner writes; the web server and everybody else read |
640 |
|
Files on a shared host where other accounts must not read them (group is the web server) |
755 |
|
Default for directories |
750 |
|
Directories on a shared host, as above |
664 / 775 |
|
Group-writable variant, for when several trusted accounts share a group. The File Permissions article mentions it for teams that share one FTP group |
440 / 400 |
|
|
666 / 777 |
|
Never. World-writable: any process or account on the server can change the code |
The account PHP runs as depends on the server. With Apache and mod_php (as in the official Docker image) it is the
Apache user, www-data on Debian and Ubuntu, apache on Red Hat family systems. With PHP-FPM it is the user of the
pool (www-data by default, but a per-site user is common and better). The Advanced Administration Handbook also
describes "suexec"-style shared hosting (suPHP, mod_ruid2, mpm-itk, mod_fcgid, PHP-FPM per account) where PHP runs as
the account that owns the files, and notes that this layout is more secure only for shared hosting.
Recommended permissions
The Hardening article states the principle: everything should be writable only by the owner account, except the parts
of wp-content that must take user-supplied content. In practice:
| Path | Mode | Notes |
|---|---|---|
Site root, |
755 dirs, 644 files |
Writable only by the owner account. Core updates replace these files |
|
440 or 400 |
Holds the database password and salts. 400 breaks the site unless PHP runs as the owner (see Troubleshooting) |
|
644 |
WordPress writes it from Settings > Permalinks only if PHP can write it. Otherwise the screen shows the rules to paste by hand |
|
755 |
Writable by PHP only if plugins and themes are installed from the dashboard |
|
755 / 644 |
Code. Writable by PHP only in the "PHP owns the files" model below |
|
755 / 644, owned by the PHP user |
Must be writable by PHP for media uploads, image sub-sizes and fonts |
|
755 |
Working directories for updates and the 6.3+ rollback backup. Site Health checks that the backup directory is writable |
|
755 |
Only when a caching or other plugin asks for it |
When WordPress itself creates files and directories (updates, plugin installs, unzipping), it uses FS_CHMOD_FILE and
FS_CHMOD_DIR. By default WP_Filesystem() derives them from the site root: fileperms( ABSPATH ) OR 0755 for
directories and the mode of index.php OR 0644 for files. With a 755/644 tree that gives the 755/644 that the
Hardening article promises. With a 775/664 tree it gives 775/664, which the test site confirmed.
Two ownership models
There are two sound ways to set up ownership. Choose one per environment.
Model A: PHP owns the files
The PHP process user owns the whole tree. This is the layout of the official Docker image, of most managed hosts and of a PHP-FPM pool that runs as the site’s own account. Dashboard installs, updates and background (automatic) updates all work without credentials, because WordPress detects that it creates files as the same owner as its own files.
The cost: any PHP code that an attacker manages to run, through a vulnerable plugin for example, can rewrite core,
plugins and wp-config.php. On a server that hosts only this site, or a pool per site, the cost is limited to that
site. On a host where many sites share one PHP user it is not acceptable. The Hosting Handbook asks hosts to run each
site as its own, non-privileged user for that reason.
Model B: a deploy account owns the code, PHP writes only uploads
A separate account (here deploy, in the group www-data) owns the code. PHP can read it through the group but can
write only wp-content/uploads/. A compromised plugin cannot persist code outside uploads (and uploads should not
execute PHP; see Web Servers: Apache and nginx).
The cost: the dashboard can no longer install or update plugins, themes or core without FTP/SSH credentials, and
background updates stop (Site Health reports it). Updates must then come from a process that runs as deploy: WP-CLI,
a deployment pipeline (Deployment and CI/CD), or Composer. The Hosting
Handbook accepts this layout as long as the host supplies such an alternative update process. Pair it with
DISALLOW_FILE_MODS so that the dashboard does not offer buttons that cannot work.
A third, common variant makes the tree group-writable (deploy:www-data, 775/664). It lets PHP write everywhere, but
it does not make WordPress write directly: the ownership test below still fails, and the dashboard still asks for
credentials. It combines the weakness of Model A with the inconvenience of Model B, unless FS_METHOD is also forced
(see FS_METHOD and related constants).
Recipes
Model A on a Debian or Ubuntu server where Apache or the PHP-FPM pool runs as www-data, with the Lumen Press site in
/var/www/lumenpress:
cd /var/www/lumenpress
sudo chown -R www-data:www-data .
sudo find . -type d -exec chmod 755 {} +
sudo find . -type f -exec chmod 644 {} +
sudo chmod 440 wp-config.php # owner www-data reads it; nobody else does
Model B, same server, with a deploy account whose primary or secondary group is www-data:
cd /var/www/lumenpress
sudo chown -R deploy:www-data .
sudo find . -type d -exec chmod 755 {} +
sudo find . -type f -exec chmod 644 {} +
sudo mkdir -p wp-content/uploads
sudo chown -R www-data:www-data wp-content/uploads # the only tree PHP may write
sudo chmod 440 wp-config.php # owner deploy and group www-data read it
find … -exec … {} + passes many paths per chmod call, so it is fast on large trees, and it never sets the execute bit on
files the way a recursive chmod -R 755 does. Run the commands again after every manual upload or git pull, or
better, let the deployment write files with the right owner in the first place.
How WordPress chooses a filesystem method
Before it installs, updates or deletes code, WordPress calls get_filesystem_method() (in
wp-admin/includes/file.php). The function returns one of four transports, each a subclass of WP_Filesystem_Base:
| Method | Class | Needs |
|---|---|---|
|
|
Plain PHP file functions, running as the PHP user |
|
|
The PECL |
|
|
PHP’s |
|
|
Only |
The decision, as implemented in 7.1.3:
-
If
FS_METHODis defined, that value is used and nothing is tested. -
Otherwise WordPress creates a temporary file named
temp-write-test-…in the context directory (wp-contentby default). If it cannot create the file, the directory is not writable and direct access is ruled out. -
If it can, it compares the owner of the temporary file (the PHP user) with the owner of
wp-admin/includes/file.php(the owner of the WordPress files). Equal owners givedirect. Writable is not enough: the owners must match. -
With different owners,
directis still allowed when the caller passes$allow_relaxed_file_ownership. Core does that only for automatic core updates that add no new files. -
Otherwise WordPress falls back to
ssh2(only when the submitted credentials chose SSH and the extension is loaded), thenftpextif theftpextension is loaded, thenftpsockets. -
The
filesystem_methodfilter can change the result.
The function has existed since 2.5 and the filter since 2.6. The comparison uses the owner of a core file even when the
context directory is wp-content/uploads, which matters to plugins (see Writing files from a plugin: the Filesystem API).
What it returns, by ownership
A small diagnostic script, placed in the site root for a moment and removed afterwards, prints what the PHP process
sees. It must be requested over HTTP: WP-CLI forces direct (see WP-CLI and ownership), so wp eval does not show the real
answer.
<?php
// fs-probe.php: diagnostic only. Delete it after use.
require __DIR__ . '/wp-load.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
header( 'Content-Type: text/plain' );
$user = posix_getpwuid( posix_geteuid() );
$own = posix_getpwuid( fileowner( ABSPATH . 'wp-admin/includes/file.php' ) );
printf( "php runs as: %s\n", $user['name'] );
printf( "core files owner: %s\n", $own['name'] );
printf( "FS_METHOD: %s\n", defined( 'FS_METHOD' ) ? FS_METHOD : '(not defined)' );
printf( "method: %s\n", var_export( get_filesystem_method(), true ) );
printf( "relaxed method: %s\n", var_export( get_filesystem_method( array(), '', true ), true ) );
printf( "wp-content writable: %s\n", wp_is_writable( WP_CONTENT_DIR ) ? 'yes' : 'no' );
The results on the test site, with PHP running as www-data and the posix extension available as in the official
image. The image ships neither the ftp nor the ssh2 extension, so the fallback is ftpsockets:
| Ownership and modes | get_filesystem_method() |
Relaxed | Dashboard plugin install |
|---|---|---|---|
Model A: |
|
|
Installs, no prompt |
Model B: |
|
|
"Connection Information" form |
Group-writable: |
|
|
"Connection Information" form |
Group-writable plus |
|
|
Installs; new files owned by |
Model B plus |
|
|
Fails: "Could not create directory. `/var/www/html/wp-content/upgrade/hello-dolly.1.7.2`" |
Any layout plus |
(not reached) |
(not reached) |
"Sorry, you are not allowed to install plugins on this site." |
The credentials prompt
When the method is not direct and no credentials are configured, every screen that changes code
(Plugins > Add Plugin, Appearance > Themes, Dashboard > Updates) shows a Connection Information form. On the test
site it read: "To perform the requested action, WordPress needs to access your web server. Please enter your FTP
credentials to proceed." The form asks for a host name, user name and password, and offers one connection type per
available transport: FTP, FTPS (SSL) only with the ftp extension, SSH2 only with the ssh2 extension. Updates
from the list screens open the same form in a modal.
WordPress stores the host name, user name and connection type in the ftp_credentials option, but never the password.
Constants in wp-config.php fill in or replace the form fields:
| Constant | Meaning |
|---|---|
|
Host name and optional port ( |
|
Account and password. |
|
Full paths to the SSH public and private key for the |
|
FTPS (FTP over TLS) with the |
|
The paths of the site root, |
Once all needed values are defined, the prompt disappears and WordPress connects silently. Background updates can then use the configured transport too, because they read the same constants. Plain FTP sends the password in clear text. Prefer SSH where a transport is needed at all.
FS_METHOD and related constants
The Advanced Administration Handbook groups these under "WordPress Upgrade Constants" and asks to define as few of them as needed, and to remove one again if it did not solve the problem.
| Constant | Default | Effect |
|---|---|---|
|
(detected) |
Forces |
|
root mode OR 0755 |
Mode for directories WordPress creates. Octal, without quotes: |
|
|
Mode for files WordPress creates. Introduced for suexec hosts where core updates failed on restrictive permissions |
|
30 seconds |
Connection timeout for the FTP and SSH transports |
|
30 seconds (240 for |
Operation timeout. The |
|
system temporary directory |
Where packages are downloaded before they are unzipped into |
|
|
Removes the theme and plugin file editors only |
|
|
Removes installing, updating and deleting plugins and themes, the file editors, and automatic updates. |
A Model B site that is updated only by its deployment pipeline sets:
// wp-config.php (Lumen Press production): code changes only by deployment, never by PHP.
define( 'DISALLOW_FILE_MODS', true );
Source: Advanced Administration Handbook: wp-config.php (Disable Plugin and Theme Update and Installation)
A site where PHP must update code but the files are not owned by PHP (the group-writable variant) can force the method:
// wp-config.php: only on a server that hosts this site alone.
define( 'FS_METHOD', 'direct' );
define( 'FS_CHMOD_DIR', ( 0755 & ~ umask() ) );
define( 'FS_CHMOD_FILE', ( 0644 & ~ umask() ) );
With the usual umask of 022, the two mode constants keep new files at 755/644 instead of inheriting the 775/664 of a group-writable root.
Why direct can be risky
The handbook calls direct the preferred method but warns that it opens security issues on poorly configured hosts.
The reason is what direct creates. Every file it writes is owned by the PHP user. On the test site, after forcing
direct on a deploy:www-data 775/664 tree, a dashboard install produced:
$ ls -la wp-content/plugins/hello-dolly
drwxrwxr-x 2 www-data www-data 4096 Oct 28 10:39 .
drwxrwsr-x 4 deploy www-data 4096 Oct 28 10:39 ..
-rw-rw-r-- 1 www-data www-data 2593 Oct 28 10:39 hello.php
-rw-rw-r-- 1 www-data www-data 624 Oct 28 10:39 readme.txt
On a single-site server that only mixes ownership, and the next chown fixes it. On a shared host where all sites run
PHP as the same www-data or nobody account, it means:
-
any other site’s PHP code (for example a compromised plugin of a neighbour) can modify these files, because they belong to the shared user, and
-
the account owner may no longer be able to change or delete them over SFTP.
The ownership test exists to prevent exactly this. That is why get_filesystem_method() refuses direct for a tree
that PHP can write but does not own, and why forcing FS_METHOD to direct on shared hosting is a bad idea. On shared
hosting, choose a host that runs PHP as the account (suexec, or a PHP-FPM pool per account): direct is then detected
automatically.
Two narrower alternatives avoid forcing the method for the whole site:
-
Run updates out of band as the owner, with WP-CLI or a deployment, and set
DISALLOW_FILE_MODS(Model B). -
Allow
directonly for the directory a plugin needs, with thefilesystem_methodfilter, as shown in Writing files from a plugin: the Filesystem API.
SFTP and SSH
For people, the Hardening article recommends SFTP whenever the host offers it: credentials and files travel encrypted.
FTP sends the password in clear text, and FTPS (FTP_SSL) is a different protocol from SFTP.
For WordPress itself, the ssh2 transport needs the PECL ssh2 extension, which few hosts and none of the official
WordPress images install. A key-based configuration, with the key pair readable by the PHP user and the public key
installed for the deploy account on the same server, looks like this:
// wp-config.php: SSH2 transport for dashboard updates (needs the PECL ssh2 extension).
define( 'FS_METHOD', 'ssh2' );
define( 'FTP_HOST', 'localhost:22' );
define( 'FTP_USER', 'deploy' );
define( 'FTP_PUBKEY', '/home/deploy/.ssh/wp_rsa.pub' );
define( 'FTP_PRIKEY', '/home/deploy/.ssh/wp_rsa' );
This keeps files owned by deploy, but it gives PHP a private key that can log in as the code owner. A compromised
plugin can read that key, so the protection is weaker than it looks. Running WP-CLI over SSH from outside the web server
(below) gives the same result without storing a key where PHP can read it.
WP-CLI and ownership
WP-CLI loads WordPress in the shell and adds a filesystem_method filter (priority 99) that always returns direct.
Whatever it writes is therefore owned by the account that runs wp, and the ownership test never applies. Three
consequences, all seen on the test site:
-
Run WP-CLI as the owner of the code. In Model B,
wp plugin install hello-dollyaswww-datafailed with "Could not create directory" becausewww-datacannot writewp-content. Asdeploythe same command installed the plugin withdeploy-owned files. -
Do not run it as root. WP-CLI refuses with a "YIKES! It looks like you’re running this as root" error. With
--allow-rootit continues, and the new plugin files belonged toroot, which PHP and the deploy account cannot update later. -
WP-CLI ignores
DISALLOW_FILE_MODS. The constant removes theinstall_pluginscapability from users, and the dashboard refused the install.wp plugin installasdeploystill worked, because the command does not check user capabilities. That is what makesDISALLOW_FILE_MODSplus WP-CLI deployments a workable production setup.
# Model B on the Lumen Press server: run WP-CLI as the code owner.
sudo -u deploy -- wp --path=/var/www/lumenpress plugin update --all
sudo -u deploy -- wp --path=/var/www/lumenpress core update
# wp-config.php is 440, so even its owner cannot edit it: open it for one change, then close it again.
sudo chmod 640 /var/www/lumenpress/wp-config.php
sudo -u deploy -- wp --path=/var/www/lumenpress config set DISALLOW_FILE_MODS true --raw
sudo chmod 440 /var/www/lumenpress/wp-config.php
Source: WP-CLI Commands: wp config set
Without the chmod 640 step, wp config set stops with "Could not process the 'wp-config.php' transformation. Reason:
wp-config.php is not writable." WP-CLI also warns when it cannot create its cache under the user’s home directory. Set
WP_CLI_CACHE_DIR or a HOME for accounts without one. WP-CLI covers the commands
themselves.
Writing files from a plugin: the Filesystem API
The Filesystem API (added in 2.6 for updates) is the abstraction behind all of the above. A plugin that writes files
should go through it, so that the write uses direct where safe and the user’s FTP/SSH credentials elsewhere. The
WordPress Coding Standards (the WordPress.WP.AlternativeFunctions sniff in WPCS 3.4.1) flag direct
file_put_contents() calls for the same reason.
The sequence is always the same:
-
request_filesystem_credentials( $form_url, '', false, $context, $extra_fields )returnstruewhen no credentials are needed, the stored or posted credentials as an array, or prints the credentials form and returnsfalse. -
WP_Filesystem( $credentials, $context )connects and sets the global$wp_filesystem. On failure, callrequest_filesystem_credentials()again with the error flag set so that the form shows an error. -
Use
$wp_filesystemmethods (put_contents,get_contents,mkdir,is_dir,exists,delete,copy,move) with paths mapped onto the transport’s root by$wp_filesystem->abspath(),wp_content_dir(),wp_plugins_dir()orwp_themes_dir(). Under FTP the remote path is often different from the PHP path.
request_filesystem_credentials() only works in the admin area after wp_loaded. The form reserves the field names
hostname, username, password, public_key, private_key and connection_type, so a plugin form must not use
them. Do not trigger the form on plugin activation; use a button the user presses, and test get_filesystem_method()
first if a silent check is enough.
Example: the Lumen Press catalogue export
lumen-catalog adds Tools > Catalogue Export, which writes the published books to
wp-content/uploads/lumen-catalog/catalogue.json. The main plugin file loads it with
require_once __DIR__ . '/admin/export.php'; inside if ( is_admin() ).
<?php
/**
* Tools > Catalogue Export: writes the published books to
* wp-content/uploads/lumen-catalog/catalogue.json through the Filesystem API.
*
* File: lumen-catalog/admin/export.php
*/
defined( 'ABSPATH' ) || exit;
add_action( 'admin_menu', 'lumen_catalog_export_menu' );
function lumen_catalog_export_menu() {
add_management_page(
__( 'Catalogue Export', 'lumen-catalog' ),
__( 'Catalogue Export', 'lumen-catalog' ),
'manage_options',
'lumen-catalog-export',
'lumen_catalog_export_page'
);
}
function lumen_catalog_export_page() {
echo '<div class="wrap"><h1>' . esc_html__( 'Catalogue Export', 'lumen-catalog' ) . '</h1>';
if ( isset( $_POST['lumen_export'] ) ) {
check_admin_referer( 'lumen_catalog_export' );
$result = lumen_catalog_write_export();
if ( null === $result ) {
echo '</div>'; // The credentials form has been printed; stop here.
return;
}
$class = is_wp_error( $result ) ? 'notice-error' : 'notice-success';
$text = is_wp_error( $result ) ? $result->get_error_message() : sprintf(
/* translators: %s: file path relative to the uploads directory. */
__( 'Catalogue written to %s.', 'lumen-catalog' ),
'lumen-catalog/catalogue.json'
);
printf( '<div class="notice %1$s"><p>%2$s</p></div>', esc_attr( $class ), esc_html( $text ) );
}
echo '<form method="post">';
wp_nonce_field( 'lumen_catalog_export' );
submit_button( __( 'Write catalogue.json', 'lumen-catalog' ), 'primary', 'lumen_export' );
echo '</form></div>';
}
/**
* @return true|WP_Error|null True on success, WP_Error on failure, null when
* WordPress is asking for FTP/SSH credentials.
*/
function lumen_catalog_write_export() {
require_once ABSPATH . 'wp-admin/includes/file.php';
$uploads = wp_upload_dir();
$dir = trailingslashit( $uploads['basedir'] ) . 'lumen-catalog';
$url = wp_nonce_url( admin_url( 'tools.php?page=lumen-catalog-export' ), 'lumen_catalog_export' );
// Ask for credentials only when direct access is not possible. Keep the button field.
$creds = request_filesystem_credentials( $url, '', false, $uploads['basedir'], array( 'lumen_export' ) );
if ( false === $creds ) {
return null;
}
if ( ! WP_Filesystem( $creds, $uploads['basedir'] ) ) {
request_filesystem_credentials( $url, '', true, $uploads['basedir'], array( 'lumen_export' ) );
return null;
}
global $wp_filesystem;
// Paths differ under FTP/SSH: map the local path onto the remote root.
$remote_dir = str_replace( ABSPATH, $wp_filesystem->abspath(), $dir );
if ( ! $wp_filesystem->is_dir( $remote_dir ) && ! $wp_filesystem->mkdir( $remote_dir, FS_CHMOD_DIR ) ) {
return new WP_Error( 'lumen_mkdir', __( 'Could not create the export directory.', 'lumen-catalog' ) );
}
$books = get_posts(
array(
'post_type' => 'lp_book',
'post_status' => 'publish',
'numberposts' => -1,
)
);
$data = array_map(
static function ( $book ) {
return array(
'id' => $book->ID,
'title' => get_the_title( $book ),
'isbn' => get_post_meta( $book->ID, 'lp_isbn', true ),
);
},
$books
);
if ( ! $wp_filesystem->put_contents( $remote_dir . '/catalogue.json', wp_json_encode( $data, JSON_PRETTY_PRINT ), FS_CHMOD_FILE ) ) {
return new WP_Error( 'lumen_write', __( 'Could not write catalogue.json.', 'lumen-catalog' ) );
}
return true;
}
Source: Common APIs Handbook: Filesystem
On a Model A site the button writes the file straight away:
$ cat wp-content/uploads/lumen-catalog/catalogue.json
[
{
"id": 6,
"title": "The Lantern Keeper",
"isbn": "978-0-00-000001-9"
}
]
On a Model B site the same button shows the Connection Information form, even though wp-content/uploads/ is owned
and writable by www-data. The ownership test compares the temporary file with the owner of the core files (deploy),
not with the owner of the context directory. A site that wants PHP to write directly inside uploads, and nowhere else,
can say so in a must-use plugin:
<?php
/**
* Plugin Name: Lumen direct uploads
* Description: Allows direct filesystem writes inside the uploads directory only.
*
* File: wp-content/mu-plugins/lumen-direct-uploads.php
*/
add_filter(
'filesystem_method',
static function ( $method, $args, $context ) {
if ( 'direct' === $method || '' === (string) $context ) {
return $method;
}
$uploads = wp_normalize_path( trailingslashit( wp_upload_dir( null, false )['basedir'] ) );
$context = wp_normalize_path( trailingslashit( $context ) );
if ( 0 === strpos( $context, $uploads ) && wp_is_writable( $context ) ) {
return 'direct';
}
return $method;
},
10,
3
);
With it installed on the Model B test site, the export succeeded (the file was owned by www-data, mode 644), while a
dashboard plugin install still showed the credentials form, because its context is wp-content. The filter only
affects callers that pass a context, which is why the export passes $uploads['basedir']. Media uploads themselves do
not go through the Filesystem API: wp_handle_upload() uses plain PHP functions and works whenever uploads/ is
writable. HTTP and Filesystem APIs covers the remaining
$wp_filesystem methods and unzip_file().
Site Health checks
Tools > Site Health reports ownership problems under the "Background updates" test (since 5.2):
| Layout on the test site | Result |
|---|---|
Model A |
"Background updates are working" (good) |
Model B |
"Background updates are not working as expected" (critical), with "Your installation of WordPress prompts for FTP credentials to perform updates. (Your site is performing updates over FTP due to file ownership. Talk to your hosting company.)" |
On a Model B site with DISALLOW_FILE_MODS this result is expected and acceptable when the deployment process keeps
the site updated. The same test also lists core files that PHP cannot write, and a separate test (6.3+) checks that
wp-content/upgrade-temp-backup is writable, because plugin and theme rollbacks depend on it
(Updates and Auto-Updates).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Connection Information form on install or update |
PHP does not own the WordPress files (Model B or group-writable) |
Intended in Model B: update with WP-CLI as the owner and set |
"Could not create directory. …/wp-content/upgrade/…" |
|
Remove |
"Unable to create directory wp-content/uploads/2026/10. Is its parent directory writable by the server?" |
|
|
HTTP 500 and "Failed opening required '…/wp-config.php'" in the PHP log |
|
440 with the PHP user’s group, or make PHP the owner |
"Sorry, you are not allowed to install plugins on this site." |
|
Intended. Install with WP-CLI or the deployment |
Plugin files owned by |
WP-CLI was run with |
|
Permalinks screen shows rules to copy instead of saving |
|
Paste the rules by hand (normal in Model B), or let PHP own |
Where older books are outdated
Older books, WordPress All-in-One For Dummies among them, suggest 666 for files or 777 for directories whenever an
upload or plugin installation fails. That makes the files writable by every account and every process on the server,
and on shared hosting by every other site’s PHP code. It does not fix the actual problem, which is that PHP and the
files have different owners. Even with 777 the ownership test in get_filesystem_method() still fails and the dashboard
still asks for FTP credentials, as the group-writable row of the table above shows. The current guidance is 644 for
files, 755 for directories, 440 or 400 for wp-config.php, and an ownership model chosen on purpose.
The official "Changing File Permissions" article itself still carries an older troubleshooting sequence that raises a
directory’s mode step by step from 746 up to 767 and finally 777, and suggests 666 for .htaccess. The same article
warns that 777 gives full access to any web-server process and says it should only ever be temporary. The Hardening
article and the Hosting Handbook have replaced that approach: fix the owner or the PHP user, then keep 755/644.
Reproducing the scenarios
The tables on this page were produced with the official images and the Compose file below, by changing ownership inside
the web container (useradd -u 1000 -g www-data deploy, then the recipes above) and requesting the probe and
dashboard URLs. Local Development Environments describes the
Compose setup in general. The password comes from the environment, never from the file.
# compose.yaml: a throw-away site for permission experiments.
name: lumen-permissions
services:
db:
image: mariadb:11.8
environment:
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD: ${WP_DB_PASSWORD:?set WP_DB_PASSWORD}
MARIADB_RANDOM_ROOT_PASSWORD: "1"
tmpfs: [ /var/lib/mysql ]
web:
image: wordpress:7.1.3-php8.4-apache
depends_on: [ db ]
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${WP_DB_PASSWORD}
WORDPRESS_DB_NAME: wordpress
volumes: [ html:/var/www/html ]
cli:
image: wordpress:cli-php8.4
depends_on: [ web ]
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${WP_DB_PASSWORD}
WORDPRESS_DB_NAME: wordpress
volumes: [ html:/var/www/html ]
command: sleep infinity
volumes:
html:
The cli image is Alpine-based, where www-data is uid 82, while the Debian-based web image uses uid 33. Run WP-CLI
with the web image’s numeric ids (docker compose exec -u 33:33 cli wp …, or -u 1000:33 for deploy) so that the
files it writes get the intended owner.