Joomla Apache VirtualHost Hardening — A Complete Configuration Guide
Joomla's biggest server-side weakness isn't the CMS core — it's a VirtualHost that never got hardened past the tutorial defaults. A stock Apache config runs every site under the shared www-data user, executes PHP anywhere a file lands, and leaves configuration.php sitting right next to the front page. This guide walks through a VirtualHost built specifically for Joomla: a dedicated PHP-FPM pool per site, Apache rules that block PHP execution in every writable Joomla directory, and the handful of file-extension tricks attackers actually use to get around a half-finished hardening job. You'll end up with a configuration you can paste in, test with a handful of curl commands, and trust.
The Apache VirtualHost for Joomla generator — what it does and who it's for
The Apache VirtualHost for Joomla generator on vps-web.com turns six form fields into a ready-to-paste Apache config: your domain, an optional subdomain alias, the PHP-FPM version, the dedicated system username the site will run under, whether to request a Let's Encrypt certificate, and whether you want per-site access and error logs instead of the shared Apache log. Submit the form and you get a full sites-available/*.conf file plus the shell commands to create the system user, the PHP-FPM pool, and a set of verification checks — everything a manual setup usually spreads across four or five separate guides.
It's built for admins standing up a Joomla site on their own VPS or dedicated server, not a shared-hosting cPanel account — anyone who runs apache2ctl configtest themselves and wants a config that doesn't rely on the CMS's own .htaccess file to do the actual isolation work. If you manage more than one Joomla install on the same box, this is exactly the scenario the generator is built around: every site gets its own Linux user, its own PHP-FPM socket, and its own open_basedir, so a compromised extension on one site can't read the files of the site next to it.
Filling in the form and pasting the output is faster than assembling the equivalent config by hand from a handful of Stack Exchange answers, and it saves you from the two mistakes that keep showing up in real Joomla hardening writeups: forgetting to block configuration.php by name, and leaving the writable directories — images, media, tmp, cache — able to execute PHP at all.
Inline CTA: Open the Apache VirtualHost for Joomla generator on vps-web.com →
Hands-on — hardening a Joomla VirtualHost step by step
The setup below assumes Ubuntu or Debian with Apache 2.4, PHP-FPM, and a Joomla 5 or 6 installation living under /var/www/. Every step matters — skip the directory-level FilesMatch blocks and the PHP-FPM isolation stops meaning anything.
Step 1 — A dedicated Linux user for the site
Joomla, like every PHP CMS, is only as isolated as the process that runs it. If every site on the server shares the www-data FPM pool, a single vulnerable extension gives an attacker read access to every other site's configuration.php — not just the one they broke into. The fix is a dedicated system user per domain.
# create a system user with no shell and no home directory content
useradd -r -M -d /var/www/example.com -s /usr/sbin/nologin exampleuser
install -d -o exampleuser -g exampleuser /var/www/example.com/tmp
chown -R exampleuser:exampleuser /var/www/example.com/public_html
-r marks it as a system account, -M skips creating a home directory (Joomla doesn't need one), and /usr/sbin/nologin means nobody can SSH in as this user even if a password ever leaked. Skip useradd entirely if you're intentionally running under the existing www-data account — but understand that choice reverts you to the shared-pool model this whole guide is trying to get away from.
Step 2 — A PHP-FPM pool dedicated to the site
; /etc/php/8.5/fpm/pool.d/example.com.conf
[example.com]
user = exampleuser
group = exampleuser
listen = /run/php/example.com.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 10s
pm.max_requests = 500
; confines PHP to this site's directory tree - a webshell here can't read
; the neighbor site's configuration.php
php_admin_value[open_basedir] = /var/www/example.com/:/tmp/
php_admin_value[upload_tmp_dir] = /var/www/example.com/tmp
php_admin_value[session.save_path] = /var/www/example.com/tmp
; neutralizes .user.ini as a persistence vector - an attacker who drops
; one in a writable directory can't have PHP load it
php_admin_value[user_ini.filename] =
; strips the functions most webshells rely on
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,proc_nice,proc_terminate,pcntl_exec,dl
php_admin_flag[allow_url_include] = off
open_basedir is the line doing the heaviest lifting here — Joomla's own security documentation recommends it explicitly, and it's the single setting that turns "one compromised extension" into "one compromised site" instead of "the whole server." disable_functions removes the shell-execution primitives that turn an arbitrary file upload into remote code execution; if a specific extension genuinely needs one of them — rare, and worth questioning — loosen that one function rather than dropping the whole list. Leave allow_url_fopen on, since Joomla's update checker and several core extensions depend on it, and control outbound access at the firewall instead if server-side request forgery is a concern.
systemctl reload php8.5-fpm
Step 3 — The Apache VirtualHost itself
This is the file that ties the PHP-FPM socket to the domain and layers the actual Apache-side hardening on top.
# /etc/apache2/sites-available/example.com.conf
<VirtualHost *:80>
ServerName example.com
Alias /.well-known/acme-challenge/ /var/lib/letsencrypt/.well-known/acme-challenge/
<Directory "/var/lib/letsencrypt/">
Require all granted
</Directory>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
RewriteRule ^ https://example.com%{REQUEST_URI} [L,R=301]
</VirtualHost>
<VirtualHost *:443>
ServerAdmin root@example.com
ServerName example.com
Protocols h2 http/1.1
DocumentRoot /var/www/example.com/public_html
<Directory "/var/www/example.com/public_html">
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# ============================ HARDENING ============================
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/example.com.sock|fcgi://localhost"
</FilesMatch>
<FilesMatch "\.(phtml|pht|phps|phar|php[0-9])$">
Require all denied
</FilesMatch>
<FilesMatch "(^\.ht|^\.user\.ini$|configuration\.php|\.(sql|sqlite|log|bak|swp|inc)$)">
Require all denied
</FilesMatch>
<DirectoryMatch "/\.(git|svn)(/|$)">
Require all denied
</DirectoryMatch>
<Directory "/var/www/example.com/public_html/images">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/media">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/tmp">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/cache">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/files">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/administrator/cache">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
<Directory "/var/www/example.com/public_html/administrator/logs">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$">
Require all denied
</FilesMatch>
</Directory>
# ==================================================================
ErrorLog ${APACHE_LOG_DIR}/example.com-error.log
CustomLog ${APACHE_LOG_DIR}/example.com-access.log combined
SSLEngine On
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLHonorCipherOrder off
</VirtualHost>
A few directives are worth calling out specifically. SetHandler "proxy:unix:...|fcgi://localhost" is what routes .php requests to this site's dedicated FPM socket instead of a shared pool — get the socket path wrong here and PHP simply won't execute, which is a safer failure mode than accidentally landing on someone else's pool. The second FilesMatch block closes the classic bypass: an attacker who can't upload shell.php because it's blocked elsewhere will often try shell.phtml or shell.php5, betting on a config that only thought about the one extension. configuration\.php in the sensitive-files block is Joomla's equivalent of WordPress's wp-config.php — it holds your database credentials in plain text, and it needs the same treatment. The DirectoryMatch for .git and .svn exists because deploying via git clone or git pull directly into public_html is common enough that leaving version-control metadata web-accessible happens constantly, handing an attacker your full commit history along with anything ever committed and later "removed."
Then there are the seven <Directory> blocks — images, media, tmp, cache, files, administrator/cache, administrator/logs. These aren't arbitrary; they're the directories Joomla itself writes to, and per Joomla's own documentation, images and media can't easily be relocated because too many third-party extensions hardcode the path. Since you can't move them out of the web root, the only real defense is making sure nothing inside them can execute as PHP — AllowOverride None on top means a compromised .htaccess dropped into one of those folders can't re-enable what the block above just denied.
Step 4 — SSL via Let's Encrypt
If you're terminating TLS on this Apache instance directly, rather than behind a reverse proxy, request the certificate with certbot's webroot plugin before the :443 block goes live — the challenge has to pass over plain HTTP first.
certbot certonly --agree-tos --email root@example.com --webroot -w /var/lib/letsencrypt/ \
-d example.com
certonly obtains the certificate without letting certbot rewrite your VirtualHost automatically, which matters when you already have a hand-built hardened config you don't want a plugin silently modifying. --webroot serves the ACME HTTP-01 challenge through the Alias block defined in the :80 VirtualHost above, which is exactly why that alias has to be reachable before the redirect rule kicks in.
Step 5 — Enable the site and verify
a2ensite example.com
apache2ctl configtest && systemctl reload apache2
a2ensite symlinks the config from sites-available into sites-enabled. configtest catches syntax errors before they take the whole vhost down, and reload — not restart — re-reads the config without dropping in-flight connections.
# --- hardening checks: each of these should return 403 ---
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/images/x.php # 403 (no PHP in images)
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/x.phtml # 403 (alt extension)
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/configuration.php # 403 (sensitive file)
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.git/HEAD # 403 (no exposed vcs)
curl -sI http://example.com/ | grep -i location # 301 -> https
# --- the site is actually up, and PHP-FPM runs as the dedicated user ---
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/ # 200
ps -o user=,args= -C php-fpm8.5 | grep -v grep | grep exampleuser # worker runs as exampleuser
If any of the four hardening checks come back as anything other than 403 — especially a 200 — stop and go back to the matching FilesMatch or Directory block before you consider the site live. A 404 on configuration.php instead of a 403 usually just means Joomla was installed somewhere other than the path you tested; it isn't a pass.
Common mistakes and pitfalls
Single-line <FilesMatch> fails apache2ctl configtest
Some older Joomla hardening writeups show <FilesMatch "..."> Require all denied </FilesMatch> written on a single line inside a <Directory> block. On current Apache builds this throws a syntax error during apache2ctl configtest instead of silently working. Always use the three-line form — opening tag, directive, closing tag — inside <Directory> blocks.
configuration.php left unblocked
It's easy to copy a WordPress-oriented Apache hardening guide and forget that Joomla's sensitive file isn't called wp-config.php. If your FilesMatch sensitive-files pattern still references wp-config\.php instead of configuration\.php, Joomla's database credentials are one curl request away from anyone who knows the file exists.
Sharing www-data across multiple Joomla sites
Running every site's PHP under the same www-data FPM pool is the single biggest thing that turns a one-site incident into a whole-server incident. If a webshell lands on a Joomla install running as www-data, it can potentially read every other site on the box that's also configured to run as www-data — a per-site open_basedir doesn't help you if the pool itself isn't dedicated.
Missing AllowOverride None on writable directories
Setting AllowOverride All at the document root, which Joomla's .htaccess-based SEF URLs require, and stopping there leaves every subdirectory able to inherit that permissiveness. If an attacker manages to write a .htaccess file into images/ or tmp/, they can potentially re-enable PHP execution that your top-level FilesMatch block just spent effort denying. Set AllowOverride None explicitly on each writable directory.
ACME challenge blocked before the certificate exists
Building the :443 block and hardening rules first, then trying to run certbot, is a common ordering mistake. If the :80 VirtualHost with the .well-known/acme-challenge alias isn't live and reachable yet, the HTTP-01 challenge fails and certbot gives up. Bring up the plain-HTTP vhost first, get the certificate, then add the SSL directives.
Over-aggressive disable_functions breaks a legitimate extension
The RCE-focused function list above — exec, shell_exec, proc_open, and similar — is safe for a stock Joomla install, but some extensions, particularly ones that shell out to ImageMagick, wkhtmltopdf, or a backup tool, call one of these deliberately. If an extension fails with a vague PHP Fatal error: Uncaught Error: Call to undefined function, check whether it's calling a function you just disabled before assuming it's broken.
Forgetting to reload PHP-FPM after a pool change
Editing a pool file in pool.d/ and reloading Apache does nothing — Apache never reads that file directly. You need systemctl reload php8.5-fpm (with the correct version number) for new pool settings to take effect; otherwise you'll be debugging a config that "should" be working.
.git directory deployed straight into public_html
If your deployment process is git clone or git pull directly inside the web root instead of a build-and-copy step, the .git folder ships with it. Without the DirectoryMatch block above, it's fully downloadable, exposing your commit history and anything ever committed to it.
FAQ
Does Joomla need its own hardened VirtualHost, or is the default Apache setup enough?
A default Apache VirtualHost runs PHP everywhere a .php file lands and shares one FPM pool across every site on the server. Joomla's own attack surface — third-party extensions with wildly varying code quality — makes that combination risky the moment you're hosting more than a single trusted site, or any site at all on a multi-tenant box.
Why does Joomla use configuration.php instead of an .env file?
Joomla predates the .env convention that frameworks like Laravel later popularized, and it kept configuration.php for backward compatibility across major versions. The file is a plain PHP class containing your database credentials, so it needs the same file-level protection as any secrets file: Apache-level blocking, restrictive permissions, and ideally moving it outside the web root if your hosting setup supports it.
Can I run Joomla under www-data instead of a dedicated user?
You can, and plenty of small single-site setups do exactly that without incident. The tradeoff is isolation: on a server hosting only one site, a shared user changes little, but the moment a second site shares that same www-data pool, a vulnerability in either one exposes both.
Do I need open_basedir if I'm already using disable_functions?
They cover different attack paths. disable_functions stops a webshell from shelling out to the operating system; open_basedir stops PHP from reading or writing files outside its own site directory, even without shelling out. Use both — they aren't redundant.
What's the difference between the alternate-extensions block and the sensitive-files block?
The alternate-extensions block (.phtml, .pht, .phar, .php5) exists because some servers historically executed those extensions as PHP too, so an attacker blocked from uploading .php files tries a lookalike extension. The sensitive-files block targets specific filenames and patterns — configuration.php, .sql dumps, .log files — regardless of whether they'd execute; the goal there is simply keeping them unreadable.
Why block .git and .svn directories specifically?
Both store the full project history, including files and lines that were later deleted from the working copy. If either directory is web-accessible, anyone with a directory-listing tool can pull your entire commit history, credentials that were ever committed and "removed," and the exact code paths your site is running.
My apache2ctl configtest fails on the <FilesMatch> block — why?
Almost always a single-line <FilesMatch>...</FilesMatch> written inside a <Directory> block. Some Apache builds parse this fine; others reject it. Split it into three lines — opening tag, Require all denied on its own line, closing tag — and configtest passes.
Do I still need the writable-directory blocks if I'm already blocking PHP uploads elsewhere?
Yes — Joomla itself, not an attacker, writes to images, media, tmp, cache, files, and the two administrator subdirectories during normal operation. You can't just deny write access to them; you have to allow writes but deny PHP execution specifically, which is what the AllowOverride None plus FilesMatch combination does.
Next steps
Generate the full VirtualHost, PHP-FPM pool, and verification script with your own domain and user in the Apache VirtualHost for Joomla generator on vps-web.com — filling out the form is faster than assembling the equivalent config from five different tutorials.
A hardened vhost is only one layer. A few related generators round out the setup:
- Apache VirtualHost configuration for Ubuntu/Debian — the general-purpose version of this generator, useful if your next site isn't running Joomla.
- DNS zone file configuration — get the domain pointed at the right server before you request a certificate.
- Apache mod_remoteip behind Nginx Proxy Manager — if you're terminating TLS on a reverse proxy in front of Apache instead of directly on the vhost, this is what keeps your access logs showing real visitor IPs instead of the proxy's.
If you'd rather watch the setup than read it, I cover server hardening and VPS administration topics like this one on my YouTube channel.