apache2 · systemd
Apache "Read-only file system" after upgrading to Ubuntu 26.04
Published · HardStacked · 11 min
You moved a server or a workstation to Ubuntu 26.04 LTS. Apache and PHP ran fine on 24.04 or 25.10, and now every script that writes under /home gets Failed to open stream: Read-only file system. That is the exact title of an Ask Ubuntu question from September (question 1569472, created 2026-09-06, 286 views on 2026-11-06, one answer, none accepted). You checked: the disk is mounted read-write, fsck finds nothing, the permissions are right, the same script from the command line writes fine.
Your disk is not read-only. What is read-only is the view Apache has of it. Since Ubuntu 26.04, apache2.service starts Apache inside a systemd sandbox: /home and /etc are remounted read-only in there, sudoers is made invisible, /dev is private. Your shell looks at the real filesystem, Apache looks at its own, which is why everything "works from the command line". The fix that circulates, ProtectHome=no, opens one of those walls. Another one, ProtectSystem=no, does not open the one people think it does. The right fix reopens the one folder you need and nothing else.
Measured on an Ubuntu 26.04.1 LTS virtual machine (arm64, official cloud image), apache2 2.4.66-2ubuntu2.6, PHP 8.5 with mod_php, systemd 259.5, on 2026-11-06.
01. The disk is writable, Apache's view of it is not
Everything we create here has demo in its name and is removed at the end (section 07). We restart Apache several times, so do this on a test machine or outside service hours. /home/user is the generic name for your own home. Two small PHP files (one writes to a path you pass, one runs sudo -n id) and a sudoers line that lets www-data run id as root, nothing more.
$ sudo mkdir /home/user/demo-site
$ sudo chown www-data: /home/user/demo-site
$ sudo install -o www-data -m 644 /dev/null /etc/demo-app.conf
$ printf '%s\n' '<?php' '$p = $_GET["p"];' 'echo @file_put_contents($p, "ok\n") === false ? error_get_last()["message"] : "wrote $p", "\n";' | sudo tee /var/www/html/demo-write.php >/dev/null
$ printf '%s\n' '<?php' 'echo shell_exec("sudo -n /usr/bin/id 2>&1");' | sudo tee /var/www/html/demo-sudo.php >/dev/null
$ echo 'www-data ALL=(root) NOPASSWD: /usr/bin/id' | sudo tee /etc/sudoers.d/50-demo-www
www-data ALL=(root) NOPASSWD: /usr/bin/id
$ sudo chmod 440 /etc/sudoers.d/50-demo-www
Apache fails in /home. The same user, www-data, writes there from a shell. findmnt says rw. And a control in /var/www/html fails too, with a different message:
apache vs the shell
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-site/test.txt'
file_put_contents(/home/user/demo-site/test.txt): Failed to open stream: Read-only file system
$ sudo -u www-data touch /home/user/demo-site/test.txt && echo 'shell: written'
shell: written
$ findmnt -no TARGET,OPTIONS -T /home/user/demo-site
/ rw,relatime,discard,errors=remount-ro,commit=30
$ curl -s 'http://localhost/demo-write.php?p=/var/www/html/test.txt'
file_put_contents(/var/www/html/test.txt): Failed to open stream: Permission denied
Read these two errors apart. Permission denied is a real permissions problem: the user may not write there. Read-only file system is not about permissions at all, and chmod will never fix it. In the question, the asker tried fsck ("I use fsck as we can read lot of things about that. Nothing change.") and the mount options ("I put "rw, users" options to my mount directive. Nothing change."). Both look at the real disk, which was never the problem.
02. What changed in Ubuntu 26.04
The unit itself tells you. Hardening directives, then the systemd-analyze security score (5.7 MEDIUM).
the unit
$ dpkg-query -W apache2
apache2 2.4.66-2ubuntu2.6
$ systemctl cat apache2 | grep -E '^(Protect(Home|System)|PrivateDevices|ReadWritePaths|InaccessiblePaths)='
PrivateDevices=yes
ProtectHome=read-only
ProtectSystem=full
ReadWritePaths=/var/log/apache2
ReadWritePaths=/var/cache/apache2/mod_cache_disk
InaccessiblePaths=/boot
InaccessiblePaths=/root
InaccessiblePaths=-/etc/sudoers
InaccessiblePaths=-/etc/sudoers.d
InaccessiblePaths=-/etc/ssh
InaccessiblePaths=-/etc/apt
InaccessiblePaths=-/etc/.git
InaccessiblePaths=-/etc/.svn
$ systemd-analyze security apache2 --no-pager | tail -1
→ Overall exposure level for apache2.service: 5.7 MEDIUM :-|
ProtectSystem=full makes /usr, /boot, /efi and /etc read-only, ProtectHome=read-only does the same for /home, /root and /run/user (systemd.exec(5)). ReadWritePaths= reopens a path. InaccessiblePaths= hides it behind an empty stand-in with no permissions (section 03), and the - prefix means "ignore it if it does not exist".
The block comes from Debian. The apache2 2.4.66-1 changelog (unstable, Bastien Roucariès) says it plainly: "Harden systemd services. Set ProtectSystem=full ProtectHome=read-only, RestrictSUIDSGID=yes. This may break read-write CGI script to /home and WebDaV or other CGI/php/lua uses." Ubuntu 26.04 ships 2.4.66-2ubuntu2.6 with it. The units of 24.04 (2.4.58-1ubuntu8.17), 25.04 (2.4.63-1ubuntu1.1) and 25.10 (2.4.64-1ubuntu3.5) have PrivateTmp=true only, and so does Debian 13 (2.4.69-1~deb13u1), which is not touched today. The change comes from Debian unstable, so Debian's next release will likely carry it too.
03. See the filesystem with Apache's eyes
The instrument is findmnt -N: the mounts as seen from the namespace of a process, here Apache's main process. Same disk, same path, two different answers.
$ sudo findmnt -N $(systemctl show -p MainPID --value apache2) -no TARGET,OPTIONS -T /home/user/demo-site
/home ro,relatime,discard,errors=remount-ro,commit=30
$ sudo findmnt -N $(systemctl show -p MainPID --value apache2) -no TARGET,OPTIONS -T /etc
/etc ro,relatime,discard,errors=remount-ro,commit=30
$ sudo nsenter -t $(systemctl show -p MainPID --value apache2) -m ls -l /etc/sudoers
---------- 1 root root 0 Nov 6 19:22 /etc/sudoers
/home and /etc are read-only for Apache. And /etc/sudoers is a file of 0 bytes with no permission at all (----------): in the sandbox it is replaced by an empty inaccessible file, while it is intact on the real system. Same family of trap as a systemd service that runs as root and still gets "Permission denied": the sandbox changes what the service sees, and your shell cannot tell you.
04. Four walls, not one
/home was the first wall. The second is /etc, same error as before. The third is sudo, and it lies to you. From a shell, www-data can run sudo -n /usr/bin/id and gets root. From PHP, through Apache, sudo answers as if no rule existed:
/etc and sudo
$ curl -s 'http://localhost/demo-write.php?p=/etc/demo-app.conf'
file_put_contents(/etc/demo-app.conf): Failed to open stream: Read-only file system
$ sudo -u www-data sudo -n /usr/bin/id
uid=0(root) gid=0(root) groups=0(root)
$ curl -s http://localhost/demo-sudo.php
sudo: I'm sorry www-data. I'm afraid I can't do that
The rule exists. It is /etc/sudoers and /etc/sudoers.d that Apache cannot read, and sudo-rs, the default sudo of 26.04, gives the same answer as for a user with no rule at all, instead of saying it cannot read its files. The fourth wall is /dev: PrivateDevices=yes gives Apache a private /dev. On the same machine, PHP sees /etc/ssh/sshd_config as absent, and /dev/ttyAMA0 (a serial port on a Pi or on the VM) as absent for Apache and present in a shell. Scripts that talk to hardware, home automation on a Pi for example, break the same way.
05. The fixes, measured
First ProtectHome=no, the one that circulates, as a drop-in. The /home write passes. /etc does not:
ProtectHome=no
$ sudo mkdir -p /etc/systemd/system/apache2.service.d
$ printf '[Service]\nProtectHome=no\n' | sudo tee /etc/systemd/system/apache2.service.d/demo.conf
[Service]
ProtectHome=no
$ sudo systemctl daemon-reload && sudo systemctl restart apache2
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-site/test.txt'
wrote /home/user/demo-site/test.txt
$ curl -s 'http://localhost/demo-write.php?p=/etc/demo-app.conf'
file_put_contents(/etc/demo-app.conf): Failed to open stream: Read-only file system
Then ProtectSystem=no alone, an alternative that circulates too. It is the other way around: /etc is writable, but /home is still read-only. ProtectSystem is not the directive that protects /home, so if your problem is /home, this one does nothing for you.
ProtectSystem=no
$ printf '[Service]\nProtectSystem=no\n' | sudo tee /etc/systemd/system/apache2.service.d/demo.conf
[Service]
ProtectSystem=no
$ sudo systemctl daemon-reload && sudo systemctl restart apache2
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-site/test.txt'
file_put_contents(/home/user/demo-site/test.txt): Failed to open stream: Read-only file system
$ curl -s 'http://localhost/demo-write.php?p=/etc/demo-app.conf'
wrote /etc/demo-app.conf
The fix we recommend is ReadWritePaths= with the one folder. The folder is writable, a file elsewhere in /home is still read-only, and everything else in the sandbox stays in place:
ReadWritePaths=
$ printf '[Service]\nReadWritePaths=/home/user/demo-site\n' | sudo tee /etc/systemd/system/apache2.service.d/demo.conf
[Service]
ReadWritePaths=/home/user/demo-site
$ sudo systemctl daemon-reload && sudo systemctl restart apache2
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-site/test.txt'
wrote /home/user/demo-site/test.txt
$ curl -s 'http://localhost/demo-write.php?p=/home/user/demo-other.txt'
file_put_contents(/home/user/demo-other.txt): Failed to open stream: Read-only file system
For sudo, you cannot remove one path from InaccessiblePaths=: the assignments pile up. You empty the list with an empty assignment, then you list the others again, without the sudoers lines. Result: sudo answers, and /etc/ssh is still d--------- for Apache.
InaccessiblePaths=
$ printf '[Service]\nInaccessiblePaths=\nInaccessiblePaths=/boot /root -/etc/ssh -/etc/apt -/etc/.git -/etc/.svn\n' | sudo tee /etc/systemd/system/apache2.service.d/demo.conf
[Service]
InaccessiblePaths=
InaccessiblePaths=/boot /root -/etc/ssh -/etc/apt -/etc/.git -/etc/.svn
$ sudo systemctl daemon-reload && sudo systemctl restart apache2
$ systemctl show apache2 -p InaccessiblePaths
InaccessiblePaths=/boot /root -/etc/ssh -/etc/apt -/etc/.git -/etc/.svn
$ curl -s http://localhost/demo-sudo.php
uid=0(root) gid=0(root) groups=0(root)
$ sudo nsenter -t $(systemctl show -p MainPID --value apache2) -m ls -ld /etc/ssh
d--------- 2 root root 40 Nov 6 19:22 /etc/ssh
A website that needs sudo is exactly what this hardening is aimed at: a PHP flaw then becomes a command as root. If you can, move that job out of the web process instead of opening the wall.
The only answer to the Ask Ubuntu question, from another user and with a score of 0, says to install or reconfigure php-fpm. It is correct, and it does not say why. We measured it: php8.5-fpm.service has no sandbox directive at all, so PHP leaves the Apache process and its sandbox. /home passes, and /etc now returns the real error, Permission denied. It works because PHP moved, not because the old configuration was wrong.
06. When Apache won't start at all
Same sandbox, other symptom. A virtual host puts its ErrorLog in /home. configtest is happy, the restart fails, and the journal does not tell you why:
log in /home
$ sudo rm /etc/systemd/system/apache2.service.d/demo.conf && sudo systemctl daemon-reload
$ printf '%s\n' '<VirtualHost *:80>' 'ServerName demo.invalid' 'ErrorLog /home/user/demo-site/error.log' '</VirtualHost>' | sudo tee /etc/apache2/sites-available/demo.conf >/dev/null
$ sudo a2ensite -q demo
Enabling site demo.
$ sudo apache2ctl configtest
Syntax OK
$ sudo systemctl restart apache2
Job for apache2.service failed because the control process exited with error code.
See "systemctl status apache2.service" and "journalctl -xeu apache2.service" for details.
$ sudo journalctl -u apache2 -n 3 --no-pager -o cat
apache2.service: Main process exited, code=exited, status=1/FAILURE
apache2.service: Failed with result 'exit-code'.
Failed to start apache2.service - The Apache HTTP Server.
$ sudo tail -n 2 /var/log/apache2/error.log
(30)Read-only file system: AH00091: apache2: could not open error log file /home/user/demo-site/error.log.
AH00015: Unable to open logs
status=1/FAILURE and nothing else: the cause is in /var/log/apache2/error.log. Apache cannot open its log in /home, (30)Read-only file system. Move the log to /var/log/apache2, which the service may write, or give its folder a ReadWritePaths=: we measured it, Apache starts again and creates its log there. We disable the site:
disable the site
$ sudo a2dissite -q demo && sudo systemctl restart apache2 && systemctl is-active apache2
Site demo disabled.
active
The second symptom is nastier. mod_cache_disk is in ReadWritePaths=, and in 26.04 that path must exist, otherwise systemd cannot build the sandbox. We move the folder away and read the last 5 journal lines. The most visible line accuses /usr/sbin/apachectl: No such file or directory, while apachectl is right there. The real cause is the line above it.
mod_cache_disk moved
$ sudo mv /var/cache/apache2/mod_cache_disk /var/cache/apache2/demo-moved
$ sudo systemctl restart apache2
Job for apache2.service failed because the control process exited with error code.
See "systemctl status apache2.service" and "journalctl -xeu apache2.service" for details.
$ sudo journalctl -u apache2 -n 5 --no-pager -o cat
apache2.service: Failed to set up mount namespacing: /var/cache/apache2/mod_cache_disk: No such file or directory
apache2.service: Failed at step NAMESPACE spawning /usr/sbin/apachectl: No such file or directory
apache2.service: Main process exited, code=exited, status=226/NAMESPACE
apache2.service: Failed with result 'exit-code'.
Failed to start apache2.service - The Apache HTTP Server.
$ sudo mv /var/cache/apache2/demo-moved /var/cache/apache2/mod_cache_disk
$ sudo systemctl restart apache2 && systemctl is-active apache2
active
The status 226/NAMESPACE is the clue. Version 2.4.68-1ubuntu4 of the package, after 26.04, writes these paths with the - prefix (ignored if absent), but 26.04 has the old form. So do not delete /var/cache/apache2/mod_cache_disk. And a - in your own drop-in is not enough: lists pile up. We measured a drop-in with ReadWritePaths=-/var/cache/apache2/mod_cache_disk alone, and it still fails with 226, because the original entry stays in the list. An empty ReadWritePaths= followed by the full list, - included, did start without the folder.
07. Clean up
We remove everything that carries demo, reload systemd and restart Apache. The drop-in folder goes only if it is empty: if you already had your own drop-in there, it stays.
teardown
$ sudo rmdir --ignore-fail-on-non-empty /etc/systemd/system/apache2.service.d
$ sudo rm -r /etc/sudoers.d/50-demo-www /etc/demo-app.conf /etc/apache2/sites-available/demo.conf /var/www/html/demo-write.php /var/www/html/demo-sudo.php /home/user/demo-site
$ sudo systemctl daemon-reload && sudo systemctl restart apache2 && systemctl is-active apache2
active
Scope: measured on one Ubuntu 26.04.1 arm64 virtual machine, Apache with mod_php, on 2026-11-06. Not measured: Nginx, Apache on Debian testing. Applications that write in /var/www are not touched by this sandbox, which only restricts /home, /root, /run/user, /usr, /boot, /efi, /etc and the inaccessible list, and gives Apache its own /dev and /tmp.