chown, chgrp, chmod, ACL: the writable_mode tour
Every PHP app has a handful of directories the web server has to write to. Cache, sessions, log files, image uploads,
compiled views. Deployer logs in as one user (deploy) and unpacks code into a release directory owned by that user.
PHP-FPM runs as a different user (www-data, nginx, apache, depending on the distro) and needs write access to a
subset of that release. How you bridge those two users is writable_mode. Deployer ships six options. This post walks
through what each one does, the syscalls behind it, and where it fits.
The setup
A typical layout after deploy:update_code:
releases/42/
├── storage/ # owned by deploy:deploy, mode 0755
├── public/
│ └── uploads/ # owned by deploy:deploy, mode 0755
└── bootstrap/
└── cache/ # owned by deploy:deploy, mode 0755
PHP-FPM is www-data:www-data. It tries to write to storage/logs/laravel.log. The directory permission bits are
rwxr-xr-x and the owner is deploy. www-data is not the owner and not in the owner's group. The write fails with
EACCES.
deploy:writable is the task that fixes this, by changing ownership, group, mode bits, or ACLs on the directories
listed in writable_dirs. The strategy is selected by writable_mode:
set('writable_mode', 'acl'); // default
Five of the six modes actually do something. Here is what each one does.
chmod
The blunt option. Just relax the mode bits enough that anyone on the system can write:
chmod 0755 storage public/uploads bootstrap/cache
Deployer's implementation:
} elseif ($mode === 'chmod') {
run("$sudo chmod $recursive {{writable_chmod_mode}} $dirs");
}
The writable_chmod_mode config defaults to 0755, which on its own does not actually grant write to the world. To
make this mode useful you usually set something like 0775 or 0777. With 0777, every user on the box can read,
write, and execute every file under the writable directories.
This works because the kernel checks owner, group, then world bits in that order, and "world writable" matches everyone including the web server. It also works on every filesystem and every Unix variant ever shipped.
It is the right answer when you are the only user on the box, the box runs nothing else, and you want zero ceremony. It is the wrong answer the moment you have more than one application or any other process that should not be touching your storage directory.
chown
Hand the directories to the web server user:
chown -L -R www-data storage public/uploads bootstrap/cache
Deployer figures out the http user automatically by scanning the process list for apache, httpd, nginx, _www, or
www-data. You can override with set('http_user', 'php-fpm').
After chown, the directories belong to www-data. Web server can write. Deploy user (deploy) can no longer write,
because it is not the owner and (typically) not in the www-data group. That matters for some recipes that touch these
directories from a deploy task: php artisan storage:link, php bin/magento setup:di:compile, and the like. If those
run as deploy, they will fail until something gives deploy access back.
chown requires either running as root (rare and bad) or writable_use_sudo => true plus a passwordless sudoers entry
for the chown command.
chgrp
A more cooperative version of chown. Instead of giving the web server full ownership, share the group:
chgrp -L -R www-data storage public/uploads bootstrap/cache
chmod -R g+rwx storage public/uploads bootstrap/cache
Now both deploy (the file owner) and any member of the www-data group (including the www-data user) can write. For
this to actually let deploy write to files the web server later creates, deploy needs to be a member of the
www-data group:
usermod -a -G www-data deploy
The catch is that group membership is established at login time. A long-lived SSH session under deploy from before the
usermod will not see the new group. You log out, log back in, and then both users coexist on the same files.
This approach also requires the setgid bit (g+s) on the directory if you want newly created files to inherit the
group. Plain chgrp plus g+rwx only fixes the existing tree; new files created by either user follow that user's
primary group, not the parent directory's group. The sticky mode (below) is the version of this scheme that handles
inheritance.
sticky
The setgid-aware sibling of chgrp. From the recipe:
} elseif ($mode === 'sticky') {
run("for dir in $dirs;"
. 'do '
. 'chgrp -L -R {{http_group}} ${dir}; '
. 'find ${dir} -type d -exec chmod g+rwxs \{\} \;;'
. 'find ${dir} -type f -exec chmod g+rw \{\} \;;'
. 'done');
}
Three steps per directory:
- Recursively change the group to
{{http_group}}. - Find every subdirectory and apply
g+rwxs(group rwx + setgid). - Find every file and apply
g+rw.
The s is the interesting bit. With setgid on a directory, any file or subdirectory created inside inherits the
parent's group, not the creating user's primary group. So when PHP-FPM creates storage/logs/laravel-2026-05-04.log
later, the file ends up www-data:www-data automatically, with rw-rw-r-- mode (assuming a sensible umask). The deploy
user, which is also in www-data, can then read and overwrite that file.
sticky works on any filesystem that supports setgid, which is essentially all of them. The down side is that it relies
on every process that ever writes inside the tree honoring a sensible umask. A misbehaving cron job that sets
umask 077 will create files only it can read, and the next deploy will trip over them.
acl
POSIX ACLs let you grant permissions to specific users and groups beyond the owner-group-world model:
setfacl -L -m u:www-data:rwX -m u:deploy:rwX storage
setfacl -dL -m u:www-data:rwX -m u:deploy:rwX storage
Two setfacl calls. The first sets the access ACL on existing files: both www-data and deploy get
read/write/execute (X is "execute if it makes sense", which means dirs and already-executable files). The second (
-d) sets the default ACL: any file or subdirectory created later inherits the same entries.
Default ACLs are the reason most recipes converge on this mode. Without them, every new file the web server creates
needs another setfacl pass to be writable by deploy, and vice versa. With them, the inheritance happens at
file-creation time inside the kernel.
Deployer's implementation has a few practical bits worth knowing:
- It auto-detects the http user, same as
chownmode. - If you list extra groups in
writable_acl_groups, they get the sameg:<name>:rwXentry. - It checks whether the current user actually exists on the box before adding it, because
setfaclerrors out hard on unknown users. - Without
sudo, it skips directories that already have a matching ACL, to avoid permission errors when the web server has created files under a directorydeployno longer owns. - macOS does not have
setfacl. It haschmod +a, which speaks a different ACL syntax. Deployer detects Darwin and uses that instead. setfaclrequires theaclpackage on most distros (apt-get install acl). If it is missing, Deployer raises a clear error rather than silently falling back.
ACLs require filesystem support. ext4, xfs, btrfs, zfs all do. tmpfs does. Some bind-mounted volumes inside containers may not, depending on the host filesystem and the mount options.
skip
} elseif ($mode === 'skip') {
return;
}
Does nothing. This sounds useless, but it is the right answer for environments where you have already arranged
permissions some other way (provisioning script, Ansible role, Dockerfile USER line) and do not want Deployer touching
them on every deploy. It is also useful for hosts where deploy:writable cannot succeed (no sudo, no ACL, hostile
filesystem) and you would rather skip than fail.
The shared-directory trick
Most apps do not actually need writable permissions on every release directory. They need writable permissions on the
data that survives across releases: log files, uploads, user-generated content. That is exactly what shared_dirs is
for.
A typical Laravel config:
set('shared_dirs', [
'storage',
]);
After deploy:shared, releases/42/storage is a symlink pointing at {{deploy_path}}/shared/storage. The actual files
live under shared/, not under any individual release. Permissions you set on shared/storage once stick around for
every future release, because the symlink target never moves.
In practice, this means deploy:writable can be a relatively cheap operation: it sets permissions on the shared
directories, not on a fresh recursive tree per deploy. Combined with default ACLs or setgid+umask, new files created
between deploys keep the right ownership and the next deploy:writable call is mostly a no-op.
Picking one
Cheat sheet:
aclwhen you havesetfaclavailable and want kernel-level inheritance. The default for a reason.stickywhen you do not have ACLs but have a sane umask everywhere and can putdeployin the http group.chgrpas a simplerstickywhen nothing creates files except your deploy and the web server.chownwhen only the web server ever writes anddeploydoes not need to come back to those files.chmodwhen you are the only user on a single-purpose box and want zero moving parts.skipwhen permissions are managed outside Deployer.
The right pick depends on your filesystem, your sudo access, and how many users are sharing the box. There is no universal best. There is a "what does this server actually allow" question, and the six modes exist because the answer differs.
