Skip to main content

chown, chgrp, chmod, ACL: the writable_mode tour

· 8 min read
Anton Medvedev
Deployer Maintainer

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:

  1. Recursively change the group to {{http_group}}.
  2. Find every subdirectory and apply g+rwxs (group rwx + setgid).
  3. 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 chown mode.
  • If you list extra groups in writable_acl_groups, they get the same g:<name>:rwX entry.
  • It checks whether the current user actually exists on the box before adding it, because setfacl errors 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 directory deploy no longer owns.
  • macOS does not have setfacl. It has chmod +a, which speaks a different ACL syntax. Deployer detects Darwin and uses that instead.
  • setfacl requires the acl package 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:

  • acl when you have setfacl available and want kernel-level inheritance. The default for a reason.
  • sticky when you do not have ACLs but have a sane umask everywhere and can put deploy in the http group.
  • chgrp as a simpler sticky when nothing creates files except your deploy and the web server.
  • chown when only the web server ever writes and deploy does not need to come back to those files.
  • chmod when you are the only user on a single-purpose box and want zero moving parts.
  • skip when 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.

Discuss on GitHub →