File permissions for ProjectSend zip installs
ProjectSend 2.8.1 introduced a clearer permission model for zip installs: root owns the program files, and the web server writes only to two directories.
· By ProjectSend Team

ProjectSend 2.8.1 set a clearer ownership model for zip installs: root owns the program files, and the web server user writes only to storage/ and bootstrap/cache/. This note covers what that layout looks like, how to set it up on a fresh install, and what changes if your web server and PHP-FPM run as different users. This note is based on ProjectSend 2.8.1 (November 2026).
Why the web server should not own the application files
If the web server user can write to directories like app/, config/, or public/ -- the directories that contain ProjectSend PHP code -- then any request the server processes can modify those files. A vulnerability in an upload handler, an injection, or a compromised dependency could each become a path to changing what the application does on the next request. Keeping those directories owned by root breaks that path: the PHP process handling the request cannot write to the code it is executing.
This is the layout INSTALL.md documents, and what update.sh establishes on its first run as root when updating to 2.8.1. For a broader security checklist, see Hardening a ProjectSend Instance.
The expected ownership layout
Four things to keep track of:
- . (the app root) -- owned by
root:www-data, write bits cleared for the group and others. The web server can read the app files but not modify them. - storage/ -- owned by
www-data:www-data, mode 775. Uploaded files, logs, sessions and cache all live here. - bootstrap/cache/ -- owned by
www-data:www-data, mode 775. Laravel framework cache. - .env -- owned by
root:www-data, mode 640. Credentials and configuration, readable by the web server group but writable only by root.
www-data is the typical web server user on Debian and Ubuntu. On RHEL-family systems it is often nginx. To check yours:
ps -o user= -C nginx | sort -uSetting it up
On a fresh install, run these from your install directory (Step 4 of the install guide):
cd /var/www/projectsend
sudo chown -R root:www-data .
sudo chmod -R go-w .
sudo chown -R www-data:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
sudo chmod 640 .env
sudo ln -sfn /var/www/projectsend/storage/app/public public/storageThe last command creates the symlink that makes uploaded logos reachable from the web. It has to run as root because public/ is owned by root.
If you are updating an existing install, running sudo ./update.sh as usual sets this layout up on its first run after updating to 2.8.1. Worth knowing: update.sh determines which user is the web server from the owner of public/index.php. If storage/ already belongs to root -- which should not happen in a correctly-set-up install, but can after a manual intervention -- the script stops and tells you. The fix is to restore storage/ to the web server user and run again. See the update guide and the 2.8.1 release notes for the full list of steps.
One other change in 2.8.1: update.sh --yes no longer installs a download it could not verify. If the checksum file is unreachable, run without --yes and confirm by hand, or pass the zip directly with --zip. See also Updating ProjectSend for the full update procedure.
When nginx and PHP-FPM run as different users
Most servers you set up yourself run both as www-data, and there is nothing else to do. Some hosting panels -- cPanel, Plesk and others -- run nginx as its own user while PHP-FPM runs as a per-site user. In that case, downloads fail while everything else works, and the nginx error log says:
open() ".../storage/app/files/..." failed (13: Permission denied)That happens because on nginx, downloads pass through an X-Accel-Redirect header: PHP checks the permission then asks the web server to send the file. The web server user has to open the file. When the two users differ and the file is not readable by the web server, downloads fail while the rest of the site works fine.
The fix is a setting in .env:
FILES_WEB_SERVER_READABLE=trueThis relaxes new upload file modes to 0644/0755. On a machine with other accounts, those modes let every account on the server read the uploaded files. The files stay off the web -- the internal directive in the nginx config takes care of that -- but they are no longer private from other accounts on the same server. Leave the setting off unless you need it.
Checking an existing installation
From your install directory:
ls -la /var/www/projectsendThe root line (.) should be owned by root, and storage and bootstrap by www-data (or your web server user). Also check that .env shows 640, not 644 -- credentials should not be world-readable. If the app root is owned by www-data, running sudo ./update.sh after updating to 2.8.1 corrects the layout. See UPDATE.md for the full manual steps.
This applies to zip installs only. Docker manages permissions differently: uploaded files live in a named volume or a bind-mounted directory, and the container entrypoint takes care of ownership on each start. See the Docker guide for Docker-specific details.
Questions and corrections
If something here does not match what you are seeing, ask in our Discord or open an issue on GitHub. Corrections to the docs in the app repository are welcome as pull requests. More notes on this topic in the Security category.
Frequently asked questions
- Does this apply to Docker installs too?
- No. Docker manages permissions through the container entrypoint. The layout described here applies to zip installs only.
- What happens if I skip this step after updating to 2.8.1?
- Earlier versions of ProjectSend gave the full app directory to the web server user. update.sh corrects this on its first run as root when you update to 2.8.1. Without that correction, the program files remain writable by the web server.
- My web server and PHP-FPM run as different users. Downloads fail. What do I do?
- Add FILES_WEB_SERVER_READABLE=true to .env. This lets the web server user read uploaded files, at the cost of making them readable by all accounts on the server. Leave it off unless you need it.
- How do I check which user my web server runs as?
- Run: ps -o user= -C nginx | sort -u. For PHP-FPM: ps -o user= -C php-fpm | sort -u. If both show the same user, the default permissions work without FILES_WEB_SERVER_READABLE.
- What is bootstrap/cache/ and why does it need to be writable?
- Laravel stores compiled configuration, routes and service provider bindings there to avoid recomputing them on every request. The web server user writes to it when the application regenerates those caches, which happens automatically on update.
ProjectSend Team. Written by the people who build and maintain ProjectSend, the open-source client portal developed in the open since 2011.
Keep reading
- Requiring staff sign-in through one providerProjectSend 2.8.0 adds an option to require staff to sign in through Microsoft, Google, or another provider. Remove their access there, and ProjectSend follows.
- Hardening a ProjectSend InstanceThe practices that keep a ProjectSend instance secure: timely updates, HTTPS, TOTP two-factor authentication, API token scoping, and virus scanning for uploads.
ProjectSend is free and open source
It's GPL v2 and built in the open. If something here didn't work for you, or you know a better way to do it, let us know: that's how the docs and the software get better.
Prefer not to run a server? ProjectSend Cloud is the hosted version, run by the same people.