EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / Web vs. Worker — mkdir 0700 Disables Inherited ACL Permissions
Linux

Web vs. Worker — mkdir 0700 Disables Inherited ACL Permissions

by Ton-Or ·9 Oct 2026·9 min read
Web vs. Worker — mkdir 0700 Disables Inherited ACL Permissions

The file permission problems that have cost me the most time are not the ones a chmod can fix. They are the ones where every ACL entry is configured correctly and getfacl shows everything neatly, but the other side still gets Permission denied when it actually tries to write, because the affected directory was created with a mode that disabled its ACL permissions from the moment it came into existence.

One system I maintain is a web app that sends some heavy work to a queue for background processing. The symptom was that, for files users uploaded through the website, the queue could process them normally in some directories but could not write anything at all in others, even though their structure was identical and they had the same default ACL as the working ones. It took me quite a while to trace the difference to "who created that directory first, and what mode they used."

Let me clarify that this is different from the cross-machine NFS ACL case I wrote about before. That case involved mismatched uid values on two machines, but this one involves one machine, one app, one disk, and two user accounts writing files into the same set of directories, an architecture that is very common when deploying Laravel or any PHP app on your own VPS. I have made up all the usernames, directory names, and values in this article, but the kernel behavior described here is real and reproducible.

One App, but Two Actual Users

When setting up a system like this, unless I consolidate everything under one user, the typical layout looks something like this.

Who runs whatBrowser→nginxwww-data→php-fpm poolwebuser→storage/app/privateFiles are created here←queue workerappuser

The web side runs as the php-fpm pool user, while the queue is a systemd service running as a separate application user, and cron usually runs as the same user as the queue. There are real reasons for keeping them separate — some tools are installed in the application user's home directory, and I do not want the process receiving requests directly from the internet to access them.

; /etc/php/8.2/fpm/pool.d/app.conf
[app]
user = webuser
group = webgroup
listen = /run/php/app.sock
listen.owner = www-data
# /etc/systemd/system/app-queue.service
[Service]
User=appuser
Group=webgroup
UMask=0007
WorkingDirectory=/srv/app
ExecStart=/usr/bin/php artisan queue:work --queue=default --timeout=600
Restart=always

Notice that I put both accounts in the same group, webgroup. This matters, and I will come back to it later.

What Default ACLs Are, and Why They Should Solve This

Traditional Unix permissions have only three categories: owner / group / other. When two accounts need to write to the same set of files without opening access to other, POSIX ACLs allow rules for individual accounts to be added.

A default ACL (d:) is a "template" attached to a directory. New items created inside it automatically inherit that ACL. Configure it once, and there is no need to run chmod on everything each time.

sudo setfacl -m u:appuser:rwx -m d:u:appuser:rwx /srv/app/storage/app/private
sudo setfacl -m u:webuser:rwx -m d:u:webuser:rwx /srv/app/storage/app/private
getfacl /srv/app/storage/app/private

The result should look like this.

# file: srv/app/storage/app/private
# owner: webuser
# group: webgroup
user::rwx
user:appuser:rwx
user:webuser:rwx
group::r-x
mask::rwx
other::---
default:user::rwx
default:user:appuser:rwx
default:user:webuser:rwx
default:group::r-x
default:mask::rwx
default:other::---

At this point, everything looked correct, and I thought I was done, until the queue could no longer write files into new subdirectories created by the web side.

The Failure Point: mkdir(0700) Sets a Mask That Overrides the ACL

The cause was a single line in the application code that looked completely harmless.

// โค้ดที่ดู "ปลอดภัยดี" แต่ฆ่า ACL ทิ้งทั้งโฟลเดอร์
$dir = storage_path('app/private/jobs/'.$jobId);
if (! is_dir($dir)) {
    mkdir($dir, 0700, true);
}

0700 sounds secure when you read it: both group and other are shut out. But what actually happens is that the kernel intersects the supplied mode with the default ACL, and the result of that intersection goes into mask::, which is the upper limit for every ACL rule naming an individual user. When the group bits in the mode are 0, the result is immediately mask::---.

sudo -u webuser mkdir -m 0700 /srv/app/storage/app/private/jobs/demo01
getfacl /srv/app/storage/app/private/jobs/demo01
# file: .../jobs/demo01
user::rwx
user:appuser:rwx            #effective:---
user:webuser:rwx            #effective:---
group::r-x                  #effective:---
mask::---
other::---

That #effective:--- line is the whole answer. Every rule is still there, and getfacl still prints the name appuser in plain sight, but the actual effective permissions are zero because the mask restricts them. A quick glance will reveal nothing wrong; I have to read the effective column to find it.

mkdir 0700mask::---user:appuser:rwxeffective: ---user:webuser:rwxeffective: ---group::r-xeffective: ---mkdir 0770mask::rwxuser:appuser:rwxeffective: rwxuser:webuser:rwxeffective: rwxgroup::r-xeffective: r-xThe mask caps all named-user rules
A directory created with mkdir 0700 gets mask::---: every rule remains, but its effective permissions are zero. With 0770, it gets mask::rwx, and the named-user rules take effect.
Whoever creates it first gets exclusive access forever

A directory created with mkdir mode 0700 permanently belongs to its creator alone. The other side cannot write, read, or delete, and worse, it depends on which side gets there first. For jobs users trigger through the website, the web side creates it; for jobs arriving through cron, the queue creates it. The result is a "some jobs pass, some fail" symptom that cannot be reproduced on demand, one of the most time-consuming kinds of problems to track down.

0770 Is the Correct Value, Not 777

The fix is very short: change one number.

// โฟลเดอร์: group bit ต้องเปิด ไม่งั้น mask ตายทันที
mkdir($dir, 0770, true);

// ไฟล์ข้างใน: ให้ group เขียนได้ แต่ other ปิดสนิทเหมือนเดิม
file_put_contents($path, $data);
chmod($path, 0660);

0770 still keeps other completely shut out, exactly as before. Security is not reduced in the slightest. The only difference is that it does not force the mask to zero, so the ACL I went to the trouble of configuring can work as intended.

mkdir 0700

ACL disabled but still visible

The mask becomes ---, reducing all named-user rules to zero. getfacl still prints every username, misleading anyone checking it into thinking the permissions are configured. Only the effective permissions show that they do not work.

mkdir 0770

ACL works, other stays closed

The mask is rwx, so named-user rules take effect, while other remains --- as before. Users outside the group still cannot get in, preserving the same security without resorting to 777.

For anyone thinking of running chmod -R 777 to get it over with, I would ask you to pause for a moment, because it does more than "open things up a little too much": it changes the category of risk. If a directory with mode 777 sits in the code tree, the web process can overwrite its own code files, which means an ordinary file upload vulnerability immediately becomes persistent code execution on the machine.

Separate data storage from code storage

The only directories the web process should be able to write to are storage and cache, and they should be outside paths served directly by the web server. All code files should belong to a different user from the one running the web process, and the web process should have read access only. If someone manages to upload a suspicious file one day, at least they will not be able to overwrite my controller.

Each Process Must Have the Same umask

Fixing mkdir alone is not enough, because the mode passed to mkdir() is filtered again by the process's umask before reaching the kernel. If php-fpm runs with the default umask of 0022, the group write bit is stripped before it even reaches the ACL.

# ดู umask จริงของ process ที่รันอยู่ ไม่ใช่ของ shell เรา
cat /proc/$(pgrep -f 'php-fpm: pool app' | head -1)/status | grep -i umask
cat /proc/$(systemctl show -p MainPID --value app-queue)/status | grep -i umask

If the result is not 0007, it needs to be set consistently on both sides. For systemd, use UMask=0007 in the unit file as shown above; for php-fpm, configure it in the pool file.

; /etc/php/8.2/fpm/pool.d/app.conf
process.priority = 0
; ตัด other ทิ้ง แต่ปล่อย group ผ่าน
php_admin_value[upload_tmp_dir] = /srv/app/storage/tmp

Then also set UMask=0007 in the php-fpm service override. I prefer setting it at the systemd level rather than in php because it covers everything that process creates, including files created by external libraries where my code does not call chmod afterward.

Deleting a File Requires Write Permission on the Directory, Not the File

This catches a lot of people out, and I misunderstood it myself for years — deleting a file does not request permission on the file itself; it requests write permission on the directory containing it, because deletion changes the directory's list of names, not the file's contents.

In practice, this means that if the web side creates a directory with 0755, the queue still cannot delete files inside it, no matter how wide open those files are with 0666.

sudo -u webuser mkdir -m 0755 /srv/app/storage/app/private/demo02
sudo -u webuser install -m 0666 /dev/null /srv/app/storage/app/private/demo02/a.tmp
sudo -u appuser rm /srv/app/storage/app/private/demo02/a.tmp
# rm: cannot remove 'a.tmp': Permission denied
sudo -u appuser truncate -s 0 /srv/app/storage/app/private/demo02/a.tmp
# สำเร็จ — เขียนได้ แต่ลบไม่ได้

It can overwrite the file's contents but cannot delete it. This is very confusing when reading application logs, because all the main processing steps succeed, only to fail during cleanup.

Deletion Fails in Complete Silence

Now comes the part that turns everything above into an invisible bug — many frameworks design their file deletion APIs to "not throw an exception" by default.

// คืน false เฉยๆ ไม่มี exception ไม่มี log
Storage::disk('private')->delete($relative);

// unlink ของ PHP ดังกว่า แต่ดังแค่ระดับ warning ซึ่งมักถูกกลืนไปกับ error handler
@unlink($absolute);

Code that calls it this way without checking the return value tells me "deleted" every time, whether deletion actually succeeded or not. I changed it to this instead.

$ok = Storage::disk('private')->delete($relative);
if (! $ok) {
    // log แค่ path กับผลลัพธ์ ไม่ต้องเอาเนื้อหาในไฟล์มาใส่
    Log::warning('temp cleanup failed', [
        'path'  => $relative,
        'owner' => function_exists('posix_getpwuid')
            ? posix_getpwuid(fileowner($absolute))['name'] ?? '?'
            : '?',
        'dir'   => substr(sprintf('%o', fileperms(dirname($absolute))), -4),
    ]);
}

Logging the mode of the parent directory was something I added later and found well worth it, because when a problem actually occurs, this one line tells me everything without having to guess which directory was created with the wrong mode.

0770Shared directory mode
0660Shared file mode
0007umask on both sides
mask::---Sign of a disabled ACL

Post-Deployment Checks

I put together a short sequence I can run after every deployment. It takes less than a minute and catches all of these cases.

  1. Search the entire tree for directories with disabled ACL permissions by using grep to find zero masks. Even one match means a mkdir somewhere is still passing the wrong mode.
  2. Test actual writes as both accounts. Do not test as root, because root passes everything and gives misleading results.
  3. Test actual deletion too, not just writing, because they require different sets of permissions, as explained above.
  4. Confirm the umask of the actual running process through proc, rather than relying on what I think I configured in the config file.
  5. Check that all writable directories are outside paths served by the web server, and that no code directory is writable by the web user.

These are the commands I regularly use for steps 1 and 2.

# 1) หาโฟลเดอร์ที่ mask ตาย
find /srv/app/storage -type d -exec sh -c \
  'getfacl -p "$1" 2>/dev/null | grep -q "^mask::---" && echo "MASK DEAD: $1"' _ {} \;

# 2) ทดสอบเขียน/ลบสองทาง
for u in webuser appuser; do
  sudo -u "$u" sh -c '
    d=/srv/app/storage/app/private/_probe
    mkdir -p -m 0770 "$d" && : > "$d/'"$u"'.probe" \
      && echo "[OK] '"$u"' write" || echo "[ERR] '"$u"' write"
  '
done
for u in webuser appuser; do
  sudo -u "$u" sh -c 'rm -f /srv/app/storage/app/private/_probe/*.probe' \
    && echo "[OK] $u delete" || echo "[ERR] $u delete"
done
Testing as root is a meaningless test

root bypasses ACLs, masks, and everything else, so the result is always OK no matter how broken the system is. Whenever I need to verify file permissions, I always use sudo -u to act as the user that actually runs the code. This applies to both files and outbound firewall rules, which are the same trap in different forms.

The Rules I Stick To

I keep just four rules in the project documentation: shared directories must always use 0770, with no 0700 anywhere in code that creates directories under storage · shared files use 0660 · umask must be 0007 on both the web and queue sides · and every piece of file deletion code must check the return value and log failures.

The rest comes down to verification, so I put the commands above at the end of the deployment script. They take less than a minute, and catching this just once is worth the time I would otherwise spend tracking down the cause all night. Feel free to adjust the numbers or group names to suit your own system; the principles are the same. If I have left anything out or made a typo, I will come back and correct it later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first