Back to tutorials
Tutorial

WordPress File Permissions Fix on a VPS

Fix WordPress file permissions on a VPS: repair ownership, chmod, and PHP-FPM/Apache user mismatches safely.

By Anurag Singh
Updated on Nov 04, 2026
Category: Tutorial
Share article
WordPress File Permissions Fix on a VPS

WordPress updates should be boring. When plugin installs fail, media uploads throw Permission denied, or WordPress asks for FTP credentials, one issue is usually behind it. The web server/PHP user can’t write to the paths WordPress needs.

This walkthrough shows you how to fix WordPress file permissions on an Ubuntu/Debian VPS. It avoids 777 and won’t break an SSH-based deploy process.

You’ll confirm which user runs PHP, correct ownership, apply sane directory/file modes, and validate the result with quick tests.

The steps work for Nginx + PHP-FPM, Apache + PHP-FPM, and Apache with mod_php (rarer in 2026, but still around).

Before you start: know what “correct” looks like

On a typical VPS, WordPress lives somewhere like /var/www/example.com/public. A clean baseline looks like this:

  • Directories: 755
  • Files: 644
  • wp-config.php: 640 (or 600 if your web server can still read it)
  • Ownership strategy: one “deploy” user owns the code; the PHP/web user can write only where needed (usually wp-content and uploads), not your entire document root.

If you want WordPress to update itself (core/plugins/themes) from wp-admin, PHP needs write access to wp-content.

Depending on your setup, it may also need write access to a few other paths.

If you deploy via Git/CI and treat code as immutable, restrict PHP to wp-content/uploads.

Then manage everything else through your pipeline.

This tutorial covers the common VPS scenario.

You want wp-admin updates to work, but you still want sensible guardrails.

Step 1: confirm the exact error and your WordPress path

SSH into the server and confirm where WordPress actually lives.

If you’re not sure, search for wp-config.php:

sudo find /var/www -maxdepth 5 -name wp-config.php 2>/dev/null

Assume your site is at /var/www/example.com/public. Save it as a variable to avoid typos:

export WP_ROOT="/var/www/example.com/public"
cd "$WP_ROOT"

Now reproduce the failure once.

Upload an image or install a small plugin.

Then check logs:

  • Nginx error log: /var/log/nginx/error.log
  • Apache error log: /var/log/apache2/error.log
  • PHP-FPM log: varies; commonly /var/log/php8.3-fpm.log or via systemd
# Nginx
sudo tail -n 80 /var/log/nginx/error.log

# Apache
sudo tail -n 80 /var/log/apache2/error.log

# PHP-FPM (systemd)
sudo systemctl status php8.3-fpm --no-pager
sudo journalctl -u php8.3-fpm -n 120 --no-pager

Look for Permission denied, failed to open stream, or paths under wp-content.

If you see open_basedir, that’s a different restriction.

It’s not a chmod/chown fix.

Step 2: identify the user that runs PHP (this is the usual mismatch)

Permissions “fixes” fail most often because people guess the runtime user. Don’t guess.

Confirm which user handles PHP requests on your stack.

If you use PHP-FPM (most VPS setups in 2026)

Find the active PHP-FPM pool config.

On Ubuntu/Debian it’s usually:

  • /etc/php/8.3/fpm/pool.d/www.conf
  • or custom pools under /etc/php/8.3/fpm/pool.d/*.conf
php -v
sudo ls -la /etc/php/*/fpm/pool.d/
sudo grep -R "^user\s*=\|^group\s*=" -n /etc/php/*/fpm/pool.d/*.conf

You’ll often see user = www-data and group = www-data.

That means PHP executes as www-data.

If you use Apache with mod_php (less common)

Apache worker processes usually run as www-data on Debian/Ubuntu. Confirm it:

ps aux | egrep "apache2|httpd" | head

Also confirm the web server user exists and looks sane:

# Debian/Ubuntu defaults
id www-data

Write down:

  • WEB_USER (most often www-data)
  • DEPLOY_USER (your SSH user, often ubuntu or a custom user like deploy)

Set variables:

export WEB_USER="www-data"
export DEPLOY_USER="$USER"

Step 3: check current ownership and permissions (don’t “fix” blind)

Start with the WordPress root.

Then check the usual pain points:

sudo ls -la "$WP_ROOT" | head -n 40
sudo ls -la "$WP_ROOT/wp-content" | head -n 60
sudo ls -la "$WP_ROOT/wp-content/uploads" 2>/dev/null | head -n 40

These patterns show up again and again on VPS installs:

  • Files owned by root:root because something was run with sudo.
  • Mixed ownership (deploy in some places, www-data in others), which causes inconsistent failures.
  • Directories set to 700, so PHP can’t traverse them.
  • wp-content/uploads not writable by PHP.

Quick “can PHP write here?” checks (no changes made):

sudo -u "$WEB_USER" test -w "$WP_ROOT/wp-content" && echo "wp-content writable" || echo "wp-content NOT writable"
sudo -u "$WEB_USER" test -w "$WP_ROOT/wp-content/uploads" && echo "uploads writable" || echo "uploads NOT writable"

If either result is NOT writable, uploads and wp-admin updates won’t behave.

Fix ownership and group access first.

Step 4: choose a safe ownership model for a VPS

You have two workable approaches.

Pick one model and keep it consistent.

Model A (recommended): deploy user owns code, web group writes where needed

This keeps SSH edits and Git deployments predictable.

It also limits what PHP can modify.

The idea is:

  • Owner: DEPLOY_USER
  • Group: www-data
  • Setgid on directories so new files inherit the group

Add your deploy user to the web group:

sudo usermod -aG "$WEB_USER" "$DEPLOY_USER"
# Re-login so group membership applies
exit

SSH back in.

Then verify you’re in the right group:

id | tr ' ' '\n' | grep -E "\b${WEB_USER}\b" && echo "OK: in web group"

Model B (simple but broader): web user owns the whole WordPress tree

This makes wp-admin updates effortless.

It also expands what an attacker can overwrite if PHP is compromised.

Use it only if you intentionally manage everything through wp-admin.

Also make sure you’ve locked down the rest of the stack.

This tutorial proceeds with Model A.

Step 5: fix ownership without breaking your deployment workflow

Set ownership to DEPLOY_USER:www-data across the WordPress root:

sudo chown -R "$DEPLOY_USER":"$WEB_USER" "$WP_ROOT"

Next, make directories inherit the www-data group (setgid).

Many guides skip this step.

When you skip it, permissions often “drift” after the next plugin install.

sudo find "$WP_ROOT" -type d -exec chmod 2755 {} \;

The leading 2 sets the setgid bit.

That makes new directories inherit the group.

Now apply standard file permissions:

sudo find "$WP_ROOT" -type f -exec chmod 0644 {} \;

Tighten wp-config.php:

sudo chmod 0640 "$WP_ROOT/wp-config.php"

Verify:

stat -c "%U:%G %a %n" "$WP_ROOT" "$WP_ROOT/wp-content" "$WP_ROOT/wp-config.php"

You should see your user as owner and www-data as group.

Modes should be close to 2755 on directories, 644 for files, and 640 for wp-config.php.

Step 6: make wp-content writable (and keep the rest mostly read-only)

With Model A, PHP writes via group permissions.

Make wp-content group-writable:

sudo find "$WP_ROOT/wp-content" -type d -exec chmod 2775 {} \;
sudo find "$WP_ROOT/wp-content" -type f -exec chmod 0664 {} \;

If you prefer stricter boundaries, limit group-write to uploads (and maybe cache).

If you install/update plugins and themes from wp-admin, keeping all of wp-content writable is the practical option.

Verify using the real runtime user:

sudo -u "$WEB_USER" test -w "$WP_ROOT/wp-content" && echo "OK: wp-content writable"
sudo -u "$WEB_USER" test -w "$WP_ROOT/wp-content/uploads" && echo "OK: uploads writable"

Step 7: stop WordPress from asking for FTP credentials

If WordPress can’t write directly, it often falls back to FTP.

Once permissions are correct, the prompt usually disappears.

If it doesn’t, set FS_METHOD to direct in wp-config.php:

sudo -u "$DEPLOY_USER" sed -n '1,160p' "$WP_ROOT/wp-config.php" | tail -n 30

Add this near your other defines:

define('FS_METHOD', 'direct');

Edit with your preferred editor:

sudo nano "$WP_ROOT/wp-config.php"

Common mistake: setting FS_METHOD as a workaround for broken permissions.

Use it only after you’ve confirmed PHP can write where it should.

Step 8: check PHP-FPM / web server user mismatches that recreate the problem

If you run multiple PHP-FPM pools (per-site isolation), one pool may use a different user/group.

That’s normal.

Your ownership model still has to match the pool config.

List pools and print each pool’s user/group:

sudo ls -la /etc/php/8.3/fpm/pool.d/
sudo awk 'FNR==1{print "\n==> " FILENAME " <=="} /^\s*(user|group)\s*=/{print}' /etc/php/8.3/fpm/pool.d/*.conf

If your pool runs as user = site1 and group = site1, the earlier www-data group approach won’t apply as-is.

You can either:

  • Switch the pool to www-data (simple, less isolated).
  • Keep the pool user and adjust ownership/group permissions to that pool group (better isolation).

After any pool changes:

sudo php-fpm8.3 -t 2>/dev/null || true
sudo systemctl restart php8.3-fpm

If your site goes 502 after restart, use this guide to diagnose quickly: Nginx 502 Bad Gateway fix on Ubuntu VPS.

Step 9: fix the “I used sudo and now everything is root-owned” trap

This is the classic self-managed VPS mistake.

You unzip a plugin, run wp-cli, or copy files as root.

WordPress then works halfway.

It fails when it hits something PHP can’t modify.

Two habits prevent repeats:

  • Avoid managing WordPress files as root unless you have a specific reason.
  • Run wp-cli as the deploy user, and keep group permissions correct.

If you use wp-cli, install it and run it as your deploy user:

cd /tmp
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
php wp-cli.phar --info
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp

Verify wp-cli runs and can write:

cd "$WP_ROOT"
wp --allow-root core version || true
sudo -u "$DEPLOY_USER" wp core version
sudo -u "$DEPLOY_USER" wp plugin list | head

If wp-cli throws permission errors, the group-writable wp-content step above is usually the missing piece.

Step 10: verify the fix end-to-end (real tests, not guesswork)

Run three checks.

Together they catch most permission issues quickly.

  1. Upload media in wp-admin (Media → Add New). Confirm the file appears in wp-content/uploads/YYYY/MM.
  2. Install a plugin (pick a small one). Confirm it creates a directory under wp-content/plugins.
  3. Update a plugin you already use. Confirm it completes without FTP prompts.

On the server, confirm new files inherit the www-data group and remain writable:

find "$WP_ROOT/wp-content" -maxdepth 2 -type d -printf "%M %u:%g %p\n" | head -n 25
find "$WP_ROOT/wp-content" -maxdepth 2 -type f -printf "%M %u:%g %p\n" | head -n 25

Hardening notes: don’t “fix” permissions by making everything writable

If you’ve ever run chmod -R 777 in frustration, roll that back.

World-writable WordPress paths are an easy way to end up with persistent malware.

Two safe improvements that fit most VPS setups:

  • Disable PHP execution in uploads (blocks many web-shell techniques). For Nginx, add a location block denying .php under uploads.
  • Auto security updates for the OS so you don’t fall behind on OpenSSL, PHP, or kernel patches.

If you want to set unattended upgrades on Ubuntu, follow: set up automated security updates on Ubuntu VPS.

Quick fixes for related “it still fails” symptoms

Sometimes the error is real, but file permissions aren’t the cause.

These are the usual look-alikes.

Symptom: “413 Request Entity Too Large” on uploads

That’s an Nginx limit, not a permissions problem. Fix it here: Nginx 413 Request Entity Too Large fix on VPS.

Symptom: WordPress scheduled tasks don’t run (updates stall)

Cron failures can look like updates “not applying.”

If scheduled events don’t fire, fix wp-cron: Fix WordPress Cron not running on a VPS.

Symptom: SELinux blocks writes (usually AlmaLinux/Rocky, not Debian/Ubuntu)

This tutorial targets Ubuntu/Debian.

On SELinux systems, you often need to adjust contexts (for example, httpd_sys_rw_content_t) instead of relying on chmod/chown.

If you moved the same WordPress stack to AlmaLinux/Rocky and nothing becomes writable, check SELinux first.

Where this fits in real hosting: VPS vs managed vs shared

On shared WordPress hosting, the provider typically pre-sets ownership and uses per-account isolation.

You rarely touch chmod.

On a VPS, you control the box.

That also means you own the break-fix work.

If you want a clean Ubuntu/Debian server for WordPress with predictable performance, start with a HostMyCode VPS. If you want the same infrastructure but don’t want to handle OS updates, stack tuning, and break-fix, look at managed VPS hosting.

Running WordPress on a VPS stays simple once you get the basics right: the correct runtime user, consistent ownership, and predictable permissions. If you want a stable server for WordPress (with room for Redis, staging sites, and backups), choose a HostMyCode VPS. If you’d rather offload patching and server maintenance, managed VPS hosting is the better fit.

FAQ: common WordPress permissions questions on a VPS

Should I set WordPress permissions to 777 to stop errors?

No. It “works” by making directories world-writable, which is a common way sites get reinfected after cleanup. Use ownership and group write access instead.

What user should own WordPress files on Ubuntu/Debian?

For most VPS setups, your SSH deploy user should own the code, and the web/PHP user (often www-data) should be the group. Then make wp-content group-writable.

Why does WordPress keep asking for FTP credentials?

It can’t write to the filesystem with the PHP user. Fix permissions first. If it still prompts, set define('FS_METHOD','direct'); in wp-config.php.

Do I need to restart Nginx/Apache after changing permissions?

Not usually. Permission changes apply immediately. Restart only if you change PHP-FPM pool users/groups or related config.

Summary: the fast, safe checklist

  • Identify the PHP runtime user (www-data or a pool user).
  • Set ownership to DEPLOY_USER:web-group.
  • Apply 2755 dirs and 644 files; tighten wp-config.php.
  • Make wp-content group-writable (2775 dirs, 664 files).
  • Verify using sudo -u www-data test -w ..., then test uploads and a plugin install.

If you’re moving WordPress between servers, decide on the permissions model early.

That way you’re not chasing errors after DNS cutover.

HostMyCode can help you land on a clean base with HostMyCode migrations, or you can start fresh on a HostMyCode VPS and keep permissions consistent from day one.