5. File system: ownership, configuration.php and private paths
Ownership beats permissions
The common advice is "set configuration.php to 444". Testing showed why that is not enough. With the file at 444 but owned by www-data, config:set sitename=... running as www-data still rewrote the file: Joomla changes the mode on a file it owns, writes it, and resets it. The protection only works when the web server user does not own the code.
On a dedicated server or VPS, use this layout:
# Code owned by a deploy user (root here), read-only for PHP
chown -R root:root /var/www/html
find /var/www/html -type d -exec chmod 755 {} +
find /var/www/html -type f -exec chmod 644 {} +
# configuration.php: readable by PHP, not world-readable, not writable by PHP
chown root:www-data /var/www/html/configuration.php
chmod 640 /var/www/html/configuration.php
# Only the folders Joomla must write to
chown -R www-data:www-data /var/www/html/{images,files,tmp,cache,administrator/cache,administrator/logs}
Tested: the site and admin login worked; Joomla rebuilt its cache in administrator/cache; failed logins were written to the log; a config:set as www-data failed and left configuration.php unchanged; and PHP could no longer create files in code folders such as components/.
The trade-off is real: with this layout, saving Global Configuration and installing or updating from the admin will fail. Run updates from the shell as the owner (php cli/joomla.php core:update, extension:install), or loosen ownership briefly during maintenance. (Tested: `extension:install --path=` with a test package failed as `www-data` and succeeded as the owner.)
On shared hosting where PHP runs as your own user, this split is not possible. There, 444 on configuration.php still guards against accidental overwrites by extensions, but treat it as a speed bump, not a lock.
Move logs and tmp outside the web root
By default the CLI installer set log_path to /var/www/html/administrator/logs and tmp_path to /var/www/html/tmp. Move both somewhere the web server cannot serve:
mkdir -p /var/joomla-private/{logs,tmp}
chown -R www-data:www-data /var/joomla-private && chmod 750 /var/joomla-private
php cli/joomla.php config:set log_path=/var/joomla-private/logs
php cli/joomla.php config:set tmp_path=/var/joomla-private/tmp
(Or set Path to Log Folder and Path to Temp Folder in Global Configuration → System / Server.) If you use open_basedir, add the new path to it.
Tested: both config:set commands worked, and the next failed login was written to /var/joomla-private/logs/error.php.
Go further: the public folder (Joomla 5.0+)
Joomla can serve the site from a separate public folder that contains only entry points and symlinks to images, media and files. Everything else (configuration.php, libraries, plugins, cli, manifests) sits outside the document root.
php cli/joomla.php site:create-public-folder --public-folder=/var/www/joomla-public
Then point the virtual host's DocumentRoot at /var/www/joomla-public with Options FollowSymLinks.
Tested: the site, admin, media and images all loaded. /configuration.php, /libraries/vendor/autoload.php, /cli/joomla.php, /administrator/manifests/files/joomla.xml and plugin and template XML files all returned 404: they simply do not exist under the web root.
Two things to carry over, both found in testing:
- Your
administrator/.htaccess(Basic auth) lives in the code folder, not the public folder. On the public folder,/administrator/answered200with no extra login until it was added there too. - Apache matches
<Directory>on the path it walks, not the symlink target. A no-exec rule for/var/www/html/imagesdid not protect/var/www/joomla-public/images: a planted.htaccessmade a.jpgexecute (42from<?php echo 6*7;). Add the public paths to the server-level no-exec config (section 4); after that, the same attack returned the file as plain text.
