Ticket: Permission denied on storage/logs. Quick fix: chmod -R 777 storage. App works. Three months later a .php in storage/uploads runs because nginx still served that tree.
Web permissions are not Unix bureaucracy: separation of code, data, uploads, and execution.
Recommended model
Code: human/CI owner, web read-only. Data (storage, cache): targeted www-data write. Uploads: subtree without interpreter.
Table owner/mode by path — app read-only, storage writable, uploads noexec.
umask and systemd
umask 027 or 002 by group model. PHP-FPM per-app pools.
Avoid deploy scripts as root without final chown.
nginx / Apache
Deny PHP execution under uploads even if .php lands there.
Separate vhost user on constrained shared hosting.
ACL for edge cases
setfacl without opening world. Reset ACL after backup restore — often forgotten.
Automated audit
Post-deploy fail on world-writable find. Alert new SUID. Document temporary exceptions with expiry.
Express permissions audit
find /var/www -type f -perm -0002
find /var/www -type d -perm -0002
find /var/www -name '*.php' -path '*/uploads/*'
Fix: chown deploy:www-data, chmod 640 files / 750 dirs, nginx deny PHP uploads.
Document exceptions with ticket and expiry date. Re-scan post-deploy CI.
Dev training: "permission denied" → strace/id/groups before chmod.
One world-writable upload directory enough for small CMS supply chain compromise.
Incident response
If webshell suspected: find newest modified php under uploads, fix permissions + nginx deny + rotate secrets — chmod alone insufficient.
SELinux/AppArmor on hardened distros: chcon sometimes required after deploy — document contexts, not just chmod.
Containers: volume mount UID mapping — mysterious permission denied often host UID ≠ container user.
Permissions technical debt
Audit legacy: old vendor chmod 777 storage documented nowhere. Remediation sprint plan: nginx deny + ACL + chown.
PHP-FPM pool user per client multi-tenant on custom shared — Unix user isolation > world chmod.
Git does not track permissions by default — post-checkout hook restore modes if needed.
Operational summary
Correct permissions on web hosting separate read, write, and execute by role: deploy writes code, www-data writes uploads and cache, nobody executes PHP in uploads. Shared hosting sometimes compensates with open_basedir; VPS requires explicit discipline.
Post-webshell incident audit: chmod alone insufficient — nginx deny execution, secret rotation, upload vector analysis. Integrate permission scan in CI deploy. Training: permission denied → owner/group before mode.
Step-by-step guide
- Identify PHP-FPM user (
ps aux | grep php-fpm). - List current web root owner (
namei -l /var/www/app/public). - Apply deploy:www-data 750/640 on code.
- storage and uploads 770/750 www-data.
- nginx deny PHP in uploads.
- CI scan world-writable.
- Document exceptions.
On malicious upload incident: isolate vhost, preserve file for analysis, rotate DB password if SQL inject suspected, restore permissions from IaC not manual chmod panic.
Shared hosting reality
You cannot always split deploy and www-data users on cheap shared plans. Compensate with open_basedir, disable dangerous PHP functions, and host-level malware scanner — permissions alone insufficient.
When migrating VPS from shared, re-audit every path — assumptions from panel environment do not transfer.
Keep a dated runbook, before/after metrics, post-incident review — cumulative discipline beats Friday night panic.
Keep a dated runbook, before/after metrics, post-incident review — cumulative discipline beats Friday night panic.
Keep a dated runbook, before/after metrics, post-incident review — cumulative discipline beats Friday night panic.
Keep a dated runbook, before/after metrics, post-incident review — cumulative discipline beats Friday night panic.
Keep a dated runbook, before/after metrics, post-incident review — cumulative discipline beats Friday night panic.
Keep a dated runbook, before/after metrics, post-incident review — cumulative discipline beats Friday night panic.
CMS
WP non-exec uploads, Laravel storage writable only. Symlink deploy permissions on current.
Keep a dated runbook, before/after metrics, post-incident review — cumulative discipline beats Friday night panic.
Decide and move forward without blind spots
- Identify PHP-FPM user —
namei -lon web root, owner deploy:www-data. - Apply 750/640 on code — storage and uploads 770/750, nginx deny PHP in uploads.
- Scan world-writable — find in CI post-deploy, exceptions ticketed with expiry date.
- Train devs — permission denied → strace/id/groups before panic chmod 777.
- Restore from IaC after malicious upload — no manual emergency chmod.
Shared vs VPS: compare limits via our directory and security guides.
Frequently asked questions
755 or 775 for web?
755 on static files if www-data reads alone. 775 only if multiple group users write — strict group, never world-writable.
Owner www-data or deploy?
App files: deploy:www-data 640, dirs 750. Uploads: www-data writable, PHP execution disabled in upload dir via nginx.
ACL vs recursive chmod?
setfacl for precise deploy + www-data cases. chmod -R 777 hides real user/group conflict.
How to audit?
find world-writable, unexpected SUID, check upload paths. Add to CI or post-deploy script.
Next permission denied: change owner before mode — in that order, always.
