The right file owners and permissions for a PHP site

Uploads fail, WordPress asks for FTP details, Laravel shows a 500 error, or someone tells you to "just chmod 777". Most of these come down to one question: which user does PHP run as, and what may that user change? This guide answers it with commands you can copy.

We tested every command in this guide on Ubuntu 24.04 (Nginx 1.24, PHP 8.3) and Debian 13 (Nginx 1.26, PHP 8.4), in fresh containers, with a test site and a real Laravel 13 app. The outputs below are copied from those tests.

1. Three users matter

  • The PHP user. PHP-FPM runs your code as the user set in its pool. Whatever PHP must change (uploads, cache, logs) has to be writable by this user.
  • The web server user. Nginx runs as www-data. It only needs to read images, CSS and JavaScript, and enter the folders they're in.
  • You, or the user you deploy with. You change the code.

See which user each PHP pool runs as:

grep -E '^(user|group) ' /etc/php/8.3/fpm/pool.d/*.conf
/etc/php/8.3/fpm/pool.d/shop.conf:user = shop
/etc/php/8.3/fpm/pool.d/shop.conf:group = shop
/etc/php/8.3/fpm/pool.d/www.conf:user = www-data
/etc/php/8.3/fpm/pool.d/www.conf:group = www-data

Use your PHP version in the path (8.4 on Debian 13). Then check which pool your site uses: the fastcgi_pass line in its Nginx file names the pool's socket. Out of the box there is one pool, www, running as www-data for every site.

2. The simple setup we recommend: one user per site

Give each site its own user and its own PHP pool, and let that user own all of the site's files. Our guide to one PHP-FPM pool per site sets this up. For a site user called shop:

sudo chown -R shop:shop /var/www/shop.example/public
sudo find /var/www/shop.example/public -type d -exec chmod 755 {} +
sudo find /var/www/shop.example/public -type f -exec chmod 644 {} +
  • 755 on folders: the owner may change them, everyone else may only open them.
  • 644 on files: the owner may change them, everyone else may only read them.

That's all. PHP runs as shop, so uploads, WordPress updates and Laravel's storage folder just work, with no special folders. A break-in through this site can change this site, but not the others. Nginx can read the static files because of the last digit (the "others" part).

The pool guide also closes the site folder itself (chmod 750 /var/www/shop.example) and adds www-data to the shop group, so other site users can't look inside. In our test, the user of a second site got Permission denied. Keep both parts: when we took www-data out of the group, Nginx returned 404 (not 403) for the CSS file, with this in its error log:

stat() "/var/www/shop.example/public/index.php" failed (13: Permission denied)

Group changes only apply to Nginx after a restart: sudo systemctl restart nginx.

Files with passwords

Make the file with the database password readable by the site user only:

sudo chmod 600 /var/www/shop.example/public/wp-config.php

For Laravel, the same for .env. PHP still reads it, because PHP runs as the owner. Check that the web server can't:

sudo -u www-data cat /var/www/shop.example/public/wp-config.php
cat: /var/www/shop.example/public/wp-config.php: Permission denied

Run commands as the site user

Run wp, composer and php artisan as the site user, not as root, so new files get the right owner:

sudo -u shop php artisan config:cache

If you copy files in as root (with sudo unzip, sudo git pull or scp as root), run the chown line again afterwards. This finds files with another owner:

sudo find /var/www/shop.example/public ! -user shop

In our test, with the files owned by root, PHP could no longer write uploads. WordPress then asks for FTP details when you update. See harden WordPress for more on that.

3. The other setup: a deploy user and www-data

Some people keep one shared pool running as www-data, and a separate deploy user owns the code. Then PHP can't change its own code, which is good. But you must open the folders PHP writes to, one by one:

sudo chown -R deploy:www-data /var/www/app
sudo find /var/www/app -type d -exec chmod 755 {} +
sudo find /var/www/app -type f -exec chmod 644 {} +

Then make only the writable folders writable for the www-data group:

  • WordPress: wp-content/uploads (and wp-content itself if you want WordPress to install updates and plugins; most people who choose this setup update with wp-cli instead).
  • Laravel: storage and bootstrap/cache.
cd /var/www/app
sudo chmod -R ug+rwX storage bootstrap/cache
sudo find storage bootstrap/cache -type d -exec chmod g+s {} +

ug+rwX lets the owner and the group read and write (the capital X adds "open" only to folders). g+s on a folder makes new files in it belong to the www-data group too. In our test, the Laravel app gave a 500 error before these lines and worked after them, while PHP still couldn't change the code.

The catch: files PHP creates belong to www-data and are only writable by it. In our test, after the site had written its log and cache, php artisan cache:clear as deploy failed with "Failed to clear cache", and logging failed with:

The stream or file "storage/logs/laravel.log" could not be opened in append mode: Failed to open stream: Permission denied

Running the same command as the PHP user worked: sudo -u www-data php artisan cache:clear. So in this setup, run Artisan and wp-cli as www-data. That's one reason we recommend the setup in step 2: there is only one user to think about.

One more downside: with a shared www-data pool, every site on the server runs as the same user, so one hacked site can change the uploads of all the others.

4. Why never 777

777 means "everyone on this server may change this". It hides the real problem (the wrong owner) and opens a new one. In our test, we set an uploads folder to 777, and the user of another site wrote a PHP file into it with no trouble. A hacked plugin on any site, or any user on the server, can do the same, and Nginx will run that file. With 2775 and the right group, the same attempt got Permission denied.

Find anything that everyone can write to:

sudo find /var/www -perm -o+w -not -type l

It should print nothing. If it prints files or folders, take away the "everyone may write" part, then fix the owner as in step 2 or 3:

sudo find /var/www -perm -o+w -not -type l -exec chmod o-w {} +

Also stop PHP from running inside the uploads folder. Our WordPress hardening guide shows the Nginx lines.

5. Still getting errors?

  • 403, 404 or "Permission denied" in the Nginx log: a folder on the way is closed. Run namei -l on the file. See fix 403 Forbidden and File not found.
  • "Access denied." on a PHP page: the PHP user can't read the file. Check its owner and permissions.
  • Uploads fail in WordPress: the PHP user can't write to wp-content/uploads. Compare the folder's owner with the pool's user.
  • Laravel 500 error: often storage isn't writable. See fix a Laravel 500 error.

Quick list

  • Check the PHP user: grep -E '^(user|group) ' /etc/php/*/fpm/pool.d/*.conf.
  • Best: one user per site owns everything; 755 folders, 644 files.
  • Password files: wp-config.php and .env at 600.
  • Deploy user + www-data: group www-data, writable folders ug+rwX and g+s; run Artisan and wp-cli as www-data.
  • Never 777. find /var/www -perm -o+w should print nothing.

Official sources

Next steps: give each site its own PHP-FPM pool, or let one person upload files to one site with SFTP.

ServerNest is being built to set up one user per site and warn you about files anyone can write to. Join early access to try it first.

← Back to the security blog