Apache 129 starts as root to bind port 80, then mod_unixd drops its children to User and Group: www-data on Ubuntu 225 (from /etc/apache2/envvars), apache on Red Hat. The manual says this account "should have no privileges that result in it being able to access files that are not intended to be visible to the outside world". Ubuntu's PHP-FPM pool also runs as www-data, and its socket /run/php/php8.5-fpm.sock is mode 660, www-data:www-data. chmod -R 777 is the classic wrong fix: every local account and every other site's PHP pool can then rewrite your code, and one upload bug lets an attacker overwrite index.php. Separate who deploys from who serves:
| Path | Owner:group | Mode | Why |
|---|---|---|---|
| Document root and its directories | deploy:www-data | 2750 | Server traverses; setgid keeps the group |
| PHP, asset and .env files | deploy:www-data | 640 | Server reads but cannot rewrite code |
| storage/, bootstrap/cache/, uploads/ | deploy:www-data | 2770 | The only places the server writes |
cd /var/www/example.com
sudo chown -R deploy:www-data .
sudo find . -type d -exec chmod 2750 {} + # setgid + rwxr-x---
sudo find . -type f -exec chmod 640 {} +
sudo chmod 2770 storage storage/logs bootstrap/cache uploads # the writable islands
sudo -u www-data test -r public/index.php && echo "server can read the entry point"
sudo -u www-data test -w public || echo "server cannot write the code: correct"
sudo -u deploy touch public/new.php # a file added by the next release
sudo stat -c '%a %U:%G %n' public/index.php public/new.phpserver can read the entry point server cannot write the code: correct 640 deploy:www-data public/index.php 644 deploy:www-data public/new.php
new.php joined group www-data with no chgrp, thanks to the setgid 2, but is 644 because sudo used umask 022: deploy with umask 027. Never let the server execute what it stores in uploads/ (Apache).
When modes are not enough: ACLs
A third identity, such as a backup account that must read everything, needs a POSIX ACL (sudo apt install acl). setfacl -m adds a named entry; -d sets a default ACL that new files inherit.
sudo setfacl -R -m u:backup:rX . # existing files and directories
sudo setfacl -R -d -m u:backup:rX . # default ACL: inherited by new files
sudo getfacl -c storage | grep backup
sudo chmod 2700 storage # a later chmod of the group bits...
sudo getfacl -c storage | grep -E '^(user:backup|mask)' # ...lowers the mask
sudo setfacl -R -b . # remove every ACL againuser:backup:r-x default:user:backup:r-x user:backup:r-x #effective:--- mask::---
The mask:: entry caps every named entry and a chmod of the group bits rewrites it, which is why a recursive chmod seems to erase ACLs. If modes and ACLs look right and access is still denied, check mandatory access control: sudo aa-status (AppArmor 454,622 , Ubuntu) or ls -Z (SELinux 1,634 , Red Hat).