PHP Permissions: www-data, Deploys, Writable Dirs
Set PHP file ownership so a deploy user ships code, www-data can read it, and only storage, cache, and upload directories stay group-writable on the VPS.
Netbay Developer Relations
Netbay Engineering
On this page
Most PHP incidents that look like framework bugs are permission bugs. nginx returns 403 because www-data cannot traverse a 700 home. Laravel returns 500 because storage/logs is owned by root from a sudo artisan call. WordPress cannot upload because wp-content/uploads is 755 and owned by deploy. The fix is a two-user model: a deploy user who writes code, and www-data who runs PHP-FPM and the queue. Only a short list of directories are writable by the web user. Everything else is 750/640.
This is the model on Ubuntu 24.04. It works for Laravel, WordPress, and a custom public/index.php. It does not require 777. If a tutorial tells you to chmod 777 storage, close it.
Users and groups
Create a deploy user with a locked password and SSH keys. Add it to group www-data so it can write the shared writable directories. Do not add www-data to sudo. Do not run PHP-FPM as root. The pool user in www.conf should stay www-data unless you create a dedicated app user and point listen.owner at nginx.
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG www-data deploy
sudo mkdir -p /var/www/app
sudo chown deploy:www-data /var/www/app
sudo chmod 750 /var/www/app750 on the app root lets deploy and group www-data in, and keeps other local users out. nginx needs +x on every path component from / to the web root. If you set 700 on /var/www, nginx 403s and you will stare at the vhost for an hour.
The unix socket at /run/php/php8.3-fpm.sock is already owned www-data:www-data on Ubuntu. Leave it. If you change the pool user, update listen.owner, listen.group, and listen.mode to 0660 so nginx can still connect.
The writable set
Laravel: storage/ and bootstrap/cache/. WordPress: wp-content/uploads/, and wp-content/upgrade/ if the UI updates. Custom apps: whatever directory you configured for logs and user files. Everything else is read-only to www-data.
cd /var/www/app
chown -R deploy:www-data .
find . -type d -exec chmod 750 {} ;
find . -type f -exec chmod 640 {} ;
chmod 750 artisan 2>/dev/null || true
chown -R deploy:www-data storage bootstrap/cache
find storage bootstrap/cache -type d -exec chmod 2775 {} ;
find storage bootstrap/cache -type f -exec chmod 664 {} ;2775 is setgid plus group-writable. New files inherit group www-data so a queue worker and an FPM worker do not take turns chowning logs. umask 0002 for those processes keeps files 664. If logs appear as 644 and the next process cannot write, umask is the missing piece.
Never make vendor writable. Never make public/ writable except the storage symlink target. An upload that can write PHP into public/ is a webshell. nginx should deny .php under uploads as a second belt.
Deploy without wrecking ownership
git pull as root is the usual way to create root-owned files that FPM cannot read. Run git and composer as deploy. Run artisan cache commands as www-data or as deploy with a umask that leaves group write on cache files.
If you use sudo, sudo -u deploy git pull and sudo -u www-data php artisan view:cache. A deploy script should set this explicitly. CI that rsyncs as root then chmod -R 777 is how boxes get pwned.
Shared storage directories (the next git-deploy post) should be created once, owned deploy:www-data, 2775, and never overwritten by rsync. rsync --exclude storage --exclude .env. If you must rsync storage permissions, use --chown=deploy:www-data.
Sessions, caches, and logs on High-Speed SSD should live in those writable dirs, not in /tmp with sticky-bit surprises, and not in /root leftovers from a sudo debug session. If you already polluted the tree, fix it with the find commands above rather than a one-off chown on a single file.
SELinux and AppArmor notes
Ubuntu AppArmor profiles for PHP-FPM are usually permissive enough for /var/www. If FPM cannot open a file that ls says is 640 www-data, check journalctl for apparmor DENIED. Do not disable AppArmor globally. If you moved the app to /home/deploy/app, either add a local profile or keep the app under /var/www so the default profile matches.
ACL (setfacl) is optional. The group-plus-setgid model is enough on a single VPS. ACLs help when more than two users need write. They hide from ls unless you ls -l with +; prefer the simple group model until you outgrow it.
Takeaway
PHP permissions are a two-user problem: deploy writes code, www-data runs code, and only storage-class directories are group-writable with setgid. 777 is not a fix. Root-owned vendor is not a fix. On a Lucknow VPS this takes ten minutes and saves the next 500.
You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and apply this ownership model on High-Speed SSD - netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance