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

rw-r--r--

Default for files. The owner writes; the web server and everybody else read

640

rw-r-----

Files on a shared host where other accounts must not read them (group is the web server)

755

rwxr-xr-x

Default for directories

750

rwxr-x---

Directories on a shared host, as above

664 / 775

rw-rw-r-- / rwxrwxr-x

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

r--r----- / r--------

wp-config.php. 440 when PHP reads it through the group, 400 only when PHP runs as the file owner

666 / 777

rw-rw-rw- / rwxrwxrwx

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.

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, wp-admin/, wp-includes/

755 dirs, 644 files

Writable only by the owner account. Core updates replace these files

wp-config.php

440 or 400

Holds the database password and salts. 400 breaks the site unless PHP runs as the owner (see Troubleshooting)

.htaccess (Apache)

644

WordPress writes it from Settings > Permalinks only if PHP can write it. Otherwise the screen shows the rules to paste by hand

wp-content/

755

Writable by PHP only if plugins and themes are installed from the dashboard

wp-content/plugins/, wp-content/themes/, wp-content/mu-plugins/

755 / 644

Code. Writable by PHP only in the "PHP owns the files" model below

wp-content/uploads/

755 / 644, owned by the PHP user

Must be writable by PHP for media uploads, image sub-sizes and fonts

wp-content/upgrade/, wp-content/upgrade-temp-backup/

755

Working directories for updates and the 6.3+ rollback backup. Site Health checks that the backup directory is writable

wp-content/cache/, other plugin directories

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

direct

WP_Filesystem_Direct

Plain PHP file functions, running as the PHP user

ssh2

WP_Filesystem_SSH2

The PECL ssh2 extension and SSH credentials

ftpext

WP_Filesystem_FTPext

PHP’s ftp extension and FTP credentials. Also does FTPS when FTP_SSL is set

ftpsockets

WP_Filesystem_ftpsocket

Only fsockopen() or the sockets extension, and FTP credentials

The decision, as implemented in 7.1.3:

  1. If FS_METHOD is defined, that value is used and nothing is tested.

  2. Otherwise WordPress creates a temporary file named temp-write-test-… in the context directory (wp-content by default). If it cannot create the file, the directory is not writable and direct access is ruled out.

  3. 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 give direct. Writable is not enough: the owners must match.

  4. With different owners, direct is still allowed when the caller passes $allow_relaxed_file_ownership. Core does that only for automatic core updates that add no new files.

  5. Otherwise WordPress falls back to ssh2 (only when the submitted credentials chose SSH and the extension is loaded), then ftpext if the ftp extension is loaded, then ftpsockets.

  6. The filesystem_method filter 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: www-data:www-data, 755/644

direct

direct

Installs, no prompt

Model B: deploy:www-data, 755/644, uploads owned by www-data

ftpsockets

ftpsockets

"Connection Information" form

Group-writable: deploy:www-data, 775/664

ftpsockets

direct

"Connection Information" form

Group-writable plus FS_METHOD direct

direct

direct

Installs; new files owned by www-data, 775/664

Model B plus FS_METHOD direct

direct

direct

Fails: "Could not create directory. `/var/www/html/wp-content/upgrade/hello-dolly.1.7.2`"

Any layout plus DISALLOW_FILE_MODS

(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

FTP_HOST

Host name and optional port (sftp.lumenpress.test:22). The handbook suggests localhost when updates fail with HTTP 503

FTP_USER, FTP_PASS

Account and password. FTP_PASS can be omitted with SSH keys

FTP_PUBKEY, FTP_PRIKEY

Full paths to the SSH public and private key for the ssh2 method

FTP_SSL

FTPS (FTP over TLS) with the ftpext method. It is not SFTP

FTP_BASE, FTP_CONTENT_DIR, FTP_PLUGIN_DIR

The paths of the site root, wp-content and the plugins directory as the FTP/SSH account sees them, when they differ from the PHP paths

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

FS_METHOD

(detected)

Forces direct, ssh2, ftpext or ftpsockets and skips the ownership test

FS_CHMOD_DIR

root mode OR 0755

Mode for directories WordPress creates. Octal, without quotes: 0755, or 02755 to keep a setgid group

FS_CHMOD_FILE

index.php mode OR 0644

Mode for files WordPress creates. Introduced for suexec hosts where core updates failed on restrictive permissions

FS_CONNECT_TIMEOUT

30 seconds

Connection timeout for the FTP and SSH transports

FS_TIMEOUT

30 seconds (240 for ftpext)

Operation timeout. The ftpext class sets its own default before WP_Filesystem() does

WP_TEMP_DIR

system temporary directory

Where packages are downloaded before they are unzipped into wp-content/upgrade/

DISALLOW_FILE_EDIT

false

Removes the theme and plugin file editors only

DISALLOW_FILE_MODS

false

Removes installing, updating and deleting plugins and themes, the file editors, and automatic updates. wp_is_file_mod_allowed() and its file_mod_allowed filter implement it

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 );

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 direct only for the directory a plugin needs, with the filesystem_method filter, 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-dolly as www-data failed with "Could not create directory" because www-data cannot write wp-content. As deploy the same command installed the plugin with deploy-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-root it continues, and the new plugin files belonged to root, which PHP and the deploy account cannot update later.

  • WP-CLI ignores DISALLOW_FILE_MODS. The constant removes the install_plugins capability from users, and the dashboard refused the install. wp plugin install as deploy still worked, because the command does not check user capabilities. That is what makes DISALLOW_FILE_MODS plus 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

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:

  1. request_filesystem_credentials( $form_url, '', false, $context, $extra_fields ) returns true when no credentials are needed, the stored or posted credentials as an array, or prints the credentials form and returns false.

  2. WP_Filesystem( $credentials, $context ) connects and sets the global $wp_filesystem. On failure, call request_filesystem_credentials() again with the error flag set so that the form shows an error.

  3. Use $wp_filesystem methods (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() or wp_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;
}

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 DISALLOW_FILE_MODS. Otherwise give the files to the PHP user (Model A), or use a host that runs PHP as the account

"Could not create directory. …/wp-content/upgrade/…"

FS_METHOD forced to direct, or WP-CLI run by a user that cannot write wp-content

Remove FS_METHOD, or run WP-CLI as the owner

"Unable to create directory wp-content/uploads/2026/10. Is its parent directory writable by the server?"

uploads/ is not writable by PHP

chown -R www-data:www-data wp-content/uploads, never chmod 777

HTTP 500 and "Failed opening required '…/wp-config.php'" in the PHP log

wp-config.php is 400 but PHP is not its owner

440 with the PHP user’s group, or make PHP the owner

"Sorry, you are not allowed to install plugins on this site."

DISALLOW_FILE_MODS is true

Intended. Install with WP-CLI or the deployment

Plugin files owned by root

WP-CLI was run with --allow-root, or files were copied as root

chown them back, and run WP-CLI as the owner

Permalinks screen shows rules to copy instead of saving

.htaccess is not writable by PHP

Paste the rules by hand (normal in Model B), or let PHP own .htaccess

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.