If you’ve ever managed WordPress on Plesk, you know the default setup: each subscription gets a system user, and PHP-FPM runs under that user. That same account also owns the WordPress files.
And in most cases, that’s actually a good thing. Site owners usually want to install plugins, apply WordPress core updates, or upload new themes directly from the WordPress dashboard. The default Plesk model makes this seamless — PHP can write wherever it needs to, so updates “just work.”
But convenience comes at a cost. Because PHP runs as the same user that owns the files, a vulnerability in a plugin or theme means PHP can also rewrite your entire site code. In other words, if an attacker slips in through WordPress, they have free rein.
For sites where security outweighs convenience — financial services, membership sites, healthcare portals, or any installation where WordPress should not be able to write its own code — there’s a better way.
This guide introduces a modified two-user approach:
- Runtime user (
site_runtime) → executes PHP-FPM (Plesk subscription system user). - Deploy user (
site_owner) → owns the WordPress code and handles deployments.
With this model, PHP can still upload files and generate cache data, but it can’t overwrite your plugins, themes, or WordPress core. Tradeoff: WordPress in-dashboard updates and plugin installs will no longer work. Updates must happen through Git, SFTP, or another deployment method. If your goal is maximum convenience, stick with Plesk’s defaults. If your goal is maximum security, read on.
Prerequisite: Install ACL (required)
Why: We use Linux Access Control Lists to grant site_owner just the rights it needs — without loosening global directory modes or fighting Plesk defaults.
Install the tools (setfacl, getfacl) and verify:
Debian/Ubuntu
|
1 2 3 |
sudo apt-get update sudo apt-get install -y acl |
RHEL/CentOS/Rocky/Alma
|
1 2 |
sudo dnf install -y acl || sudo yum install -y acl |
Amazon Linux
|
1 2 |
sudo dnf install -y acl || sudo yum install -y acl |
SUSE / SLES
|
1 2 |
sudo zypper install -y acl |
Check it works:
|
1 2 |
setfacl --version && getfacl --version |
If
setfaclreturns Operation not supported, add theaclmount option for the filesystem that backs/var/www/vhosts(ext4 example):
123 sudo mount -o remount,acl /var/www/vhosts# persist by adding ",acl" to the options column in /etc/fstab
Step 1: Create the subscription in Plesk (this defines the runtime user)
What & why: First, create the site as a Plesk subscription. Plesk provisions /var/www/vhosts/<domain> and creates the subscription system user — this will be your runtime user (site_runtime) that runs PHP-FPM.
- Plesk UI: Websites & Domains → Add Domain (or Customers → Add Subscription) → set
example.com. In subscription settings, set the System user tosite_runtime(or rename the existing one). Ensure PHP is enabled and set to PHP-FPM. - The rest of this guide assumes this user and the vhost home already exist.
Step 2: Create the Deploy User (site_owner)
What & why: site_owner owns the WordPress code and performs deployments. It does not run PHP. Separating code ownership from the PHP runtime enforces least privilege. We set the shell to /bin/bash for a normal admin experience.
|
1 2 3 4 5 6 7 |
# Debian/Ubuntu: sudo adduser --disabled-password --gecos "" --shell /bin/bash site_owner sudo usermod -aG psacln site_owner # RHEL/Alma/Rocky (alternative): # sudo useradd -m -s /bin/bash site_owner && sudo usermod -aG psacln site_owner |
Step 3: Confirm the Runtime User (site_runtime, created by Plesk)
What & why: The subscription’s system user already exists — that’s who runs PHP-FPM. We’ll share its home with site_owner, but keep code ownership separate.
|
1 2 3 4 |
grep home /etc/passwd | grep example.com # Expect something like: # site_runtime:x:1001:1004::/var/www/vhosts/example.com:/bin/false |
Step 4: Hand Over the Code to site_owner
What & why: Move ownership of all WordPress files to site_owner:psacln, then set sane read-only permissions (755 for directories, 644 for files). This makes the code immutable to PHP, even if a plugin is compromised.
|
1 2 3 4 5 6 |
VHOSTROOT="/var/www/vhosts/example.com/httpdocs" sudo chown -R site_owner:psacln $VHOSTROOT sudo find $VHOSTROOT -type d -exec chmod 755 {} \; sudo find $VHOSTROOT -type f -exec chmod 644 {} \; |
Result: site_runtime (PHP) can read/execute code, but not write it. site_owner can still deploy changes.
Step 5: Give PHP a Sandbox to Write In
What & why: WordPress needs to write to uploads and usually cache. We allow that and only that. We use 2775 so the psacln group sticks, and ACLs so site_runtime always gets write access, including for new files and subfolders.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# Uploads sudo chown -R site_owner:psacln $VHOSTROOT/wp-content/uploads sudo chmod -R 2775 $VHOSTROOT/wp-content/uploads sudo setfacl -R -m u:site_runtime:rwX -m d:u:site_runtime:rwX \ $VHOSTROOT/wp-content/uploads # Cache sudo mkdir -p $VHOSTROOT/wp-content/cache sudo chown -R site_owner:psacln $VHOSTROOT/wp-content/cache sudo chmod -R 2775 $VHOSTROOT/wp-content/cache sudo setfacl -R -m u:site_runtime:rwX -m d:u:site_runtime:rwX \ $VHOSTROOT/wp-content/cache |
Need additional writable paths (e.g., a custom storage directory)? Add them to the setup script with
-w "path1 path2".
Step 6: Lock Down WordPress from the Inside
What & why: Disable the built-in file editor and, optionally, automatic updates. With the filesystem locked down, the editor won’t work anyway; turning it off removes a tempting attack surface. Many teams also disable automatic updates to keep deployments controlled.
|
1 2 3 |
define('DISALLOW_FILE_EDIT', true); define('AUTOMATIC_UPDATER_DISABLED', true); // optional |
Heads-up for NFS-mounted uploads: if
wp-content/uploads(or any writable path) lives on an NFS share,setfaclmay print “Operation not permitted” when the script tries to apply ACLs. That’s expected on many NFS exports and safe to ignore. The recipe still works thanks tochown+chmod 2775; just make sure your NFS export/umask keeps new files group-writable sosite_runtimecan write.
Step 7: Automate Everything (with ACL preflight & readable vhost root)
What & why: Our setup automation makes the environment reproducible and fast to recover. It verifies ACL support, grants site_owner read+traverse (rx) on the shared home (so ls /var/www/vhosts/<domain> works), pre-creates dotfolders, applies ACLs for writable paths, and drops a repair script into /var/www/vhosts/<domain>/scripts/.
Get the script from GitHub:
- Repo: https://github.com/reliablepenguin/rp_wp_plesk_two_user
- See the README for the latest usage flags and examples.
Typical usage (example):
|
1 2 3 4 5 6 |
# download the latest script from the repo (adjust path/branch if needed) curl -fsSL -o wp_two_user_setup.sh \ https://raw.githubusercontent.com/reliablepenguin/rp_wp_plesk_two_user/main/wp_two_user_setup.sh sudo bash wp_two_user_setup.sh -p example.com -r site_runtime -o site_owner |
The script generates a repair utility at
/var/www/vhosts/<domain>/scripts/wp_two_user_repair_<domain>.sh
to quickly re-apply ownership/permissions/ACLs after Plesk repairs.
Step 8: Prepare for Plesk “Repairs”
What & why: Commands like plesk repair fs or some WordPress Toolkit actions can reset ownership and modes. Use the generated repair script to restore everything in seconds.
|
1 2 |
sudo -u site_owner bash /var/www/vhosts/example.com/scripts/wp_two_user_repair_example.com.sh |
Tip: run this after major Plesk operations, or schedule a weekly cron as
rootorsite_owner.
Step 9: Deployment Workflow
What & why: With the two-user model, deployments happen as site_owner, and PHP runs as site_runtime. This keeps runtime and code ownership separate, enforcing least privilege. Use Git+SSH or SFTP to deploy, and run build steps (Composer/NPM) as site_owner. Avoid giving site_runtime extra rights it doesn’t need.
Conclusion
Plesk’s default model is excellent for convenience and in-dashboard updates. When you need stronger guarantees, this ACL-based, two-user model prevents PHP from editing your codebase while preserving normal WordPress behavior for uploads and caching. Security up, blast radius down.
Quick reference
- Repo: https://github.com/reliablepenguin/rp_wp_plesk_two_user
- Requires:
setfacl/getfacl+ filesystem mounted with ACLs - Runtime user:
site_runtime(Plesk system user) - Deploy user:
site_owner(owns code,/bin/bash) - Generated repair script:
/var/www/vhosts/<domain>/scripts/wp_two_user_repair_<domain>.sh
Addendum: Handling sshd Ownership and ACL Issues on Plesk
In some Plesk environments the WordPress owner user’s home directory is mapped to the vhost path, for example:
|
1 2 |
site_owner:x:10001:10001::/var/www/vhosts/acme.com:/bin/bash |
with permissions like:
|
1 2 |
drwxr-x--x+ 13 site_runtime psacln 4096 Sep 10 07:00 acme.com |
Because this directory is not owned by the SSH login user and carries default ACLs (+), OpenSSH may reject key authentication with the error:
|
1 2 |
Authentication refused: bad ownership or modes for directory /var/www/vhosts/acme.com |
Even if the Unix bits look correct, the presence of default ACLs makes sshd treat the home as “potentially writable.”
Solution: Root-Managed Authorized Keys
Rather than fighting the ACLs on the vhost, you can move the SSH authorized keys outside of the webroot and instruct sshd to look there.
1. Create a root-owned directory
|
1 2 3 |
sudo mkdir -p /etc/ssh/authorized_keys sudo chmod 755 /etc/ssh/authorized_keys |
2. Create a per-user keys file
|
1 2 |
sudo install -m 644 -o root -g root /dev/null /etc/ssh/authorized_keys/site_owner |
Edit the file and paste in the public keys:
|
1 2 3 |
sudo nano /etc/ssh/authorized_keys/site_owner # ssh-ed25519 AAAAC3Nz… user@example |
3. Update sshd_config
Append a Match User block:
|
1 2 3 |
Match User site_owner AuthorizedKeysFile /etc/ssh/authorized_keys/%u |
4. Test and reload
|
1 2 3 |
sudo sshd -t sudo systemctl reload sshd |
Benefits
- Keeps StrictModes enabled: sshd will enforce proper permissions.
- Removes dependence on vhost ACLs and ownership quirks.
- Works cleanly with the two-user model: the site_owner account has a safe login path, while the site_runtime account continues to serve files and run PHP.
With this adjustment, you can lock down WordPress on Plesk even when the owner’s $HOME is not directly owned by the SSH login user.
Addendum: Granting Site Owner Access to Logs
By default, Plesk protects the logs directory for each domain so that only root and the Plesk administrative daemons can read webserver logs. This is secure, but sometimes you may want the site owner account to traverse into /var/www/vhosts/<domain>/logs to view access and error logs directly.
Because Plesk logs often live on filesystems that support POSIX ACLs, the cleanest approach is to add an Access Control List entry rather than changing ownership or relaxing group permissions.
Step 1: Verify ACL Support
Check that your filesystem supports ACLs:
|
1 2 |
mount | grep /var/www/vhosts |
If the mount options include acl (for example rw,relatime,acl), you are good to go. If not, update /etc/fstab to include acl for the /var/www/vhosts mount and remount:
|
1 2 |
mount -o remount,acl /var/www/vhosts |
Make sure the acl package is installed:
|
1 2 |
apt-get install -y acl |
Step 2: Grant Read and Traverse Permission
Use setfacl to add read/execute permission for the site owner user on the logs directory:
|
1 2 |
setfacl -m u:site_owner:rx /var/www/vhosts/<domain>/logs |
Replace site_owner with the actual system user for your subscription (e.g. lottery_owner).
Step 3: Apply Access Recursively (Optional)
If you want the site owner to read the existing log files inside the directory, apply recursively:
|
1 2 |
setfacl -R -m u:site_owner:rX /var/www/vhosts/<domain>/logs |
Step 4: Verify Access
Switch to the site owner account and list the logs:
|
1 2 3 |
su -l site_owner ls -l /var/www/vhosts/<domain>/logs |
You should now see the log files and be able to view them with less or tail.
⚠️ Security Note: Granting the site owner access to logs may expose sensitive information such as query strings, cookies, or request headers. Only apply this change if you are comfortable with the user having that visibility.
Addendum: Creating a Private Folder for the Site Owner
In some cases, the site owner account needs to upload files that should not be directly accessible from the website. For example, you may want to store backups, staging uploads, or other artifacts outside of the web-accessible httpdocs directory.
The simplest way to achieve this is to create a private folder at the vhost root. This folder will be owned entirely by the site_owner user and inaccessible to the runtime user.
Step 1: Create the Directory
Run as root, replacing <domain> and site_owner with the actual values for your subscription:
|
1 2 3 4 5 6 7 8 |
domain=<domain> user=site_owner base=/var/www/vhosts/$domain mkdir $base/private chown $user:$user $base/private chmod 700 $base/private |
- Ownership: The folder is owned by
site_owner. - Permissions: Mode
700ensures only the owner can read, write, or traverse.
Step 2: Verify Access
Switch to the site_owner account and confirm write access:
|
1 2 3 |
su -l site_owner touch /var/www/vhosts/<domain>/private/test.txt |
Now switch to the runtime user and attempt to list the folder:
|
1 2 3 |
su -l site_runtime ls /var/www/vhosts/<domain>/private |
This should fail with Permission denied.
Step 3: Usage Notes
- Files in
/privateare not web-accessible since the folder is outside ofhttpdocs. - This provides the site owner with a secure area for private uploads or staging files.
- If you need the runtime user (
site_runtime) to access certain files, you can selectively grant ACLs:
|
1 2 |
setfacl -m u:site_runtime:rx /var/www/vhosts/<domain>/private |
Use this only if PHP or the webserver legitimately needs read-only access.
⚠️ Security Note: Keeping sensitive files outside of the document root reduces the risk of accidental disclosure. Always use restrictive permissions (700) unless you have a specific reason to relax them.




