Apache VirtualHost for WordPress — A Hardened Configuration That Survives a Compromised Plugin
A WordPress Apache VirtualHost is the file that decides how much damage one bad plugin can do. The default config most tutorials hand you runs every site as www-data, executes PHP anywhere a file lands, and leaves xmlrpc.php wide open — so the first uploaded backdoor reads every other site on the box. This guide builds a different vhost: per-site PHP-FPM under a dedicated user, PHP execution blocked in wp-content/uploads, open_basedir jailing each site to its own directory, and the usual brute-force endpoints denied at the server level. The same hardening is baked into our Apache VirtualHost for WordPress generator on vps-web.com, so you can produce the file below with your own domain in one form submit. Below is the full configuration, every directive explained, and the mistakes that turn a "secure" vhost back into a soft target.
The WordPress VirtualHost generator — what it does and who it's for
The generator on vps-web.com takes a handful of fields — domain, optional subdomain alias, PHP version, the system user that owns the site, whether you want a Let's Encrypt certificate, and whether to split logs per domain — and emits a complete, hardened bundle: the PHP-FPM pool file, the certbot command, the <VirtualHost> block, and the activation commands. It is not the generic "five-line vhost" you find in beginner tutorials. Every site gets its own FPM pool running under a dedicated, login-less system user, with open_basedir confining PHP to that one site's tree, disable_functions stripping the RCE helpers (exec, shell_exec, proc_open, and friends), and explicit denies on the files attackers probe first.
This is for the person running more than one WordPress site on a single VPS and refusing to let a compromise on site A become a compromise on sites B through F. That's the freelancer hosting a dozen client sites on one box, the agency consolidating low-traffic brochure sites, and the self-hoster who'd rather spend an hour on isolation now than a weekend on incident response later. If you run exactly one site and nothing else on the server, you still benefit from the PHP-execution blocks and the disable_functions baseline — the per-user isolation just matters less when there's only one tenant.
What the generator saves is the part everyone gets wrong by hand: remembering that wp-content/uploads needs PHP execution killed in addition to the root rule, that .user.ini is a persistence vector unless you neutralize it in the pool, that the FPM socket owner has to be www-data even when the pool runs as a different user. Miss one and the config looks hardened but isn't.
Hands-on — building the hardened WordPress vhost
We'll stand up shop.example.com end to end: a dedicated system user, a per-site PHP-FPM pool, a Let's Encrypt certificate, and the VirtualHost itself with the full hardening block. The end state is a site where a webshell dropped through a vulnerable plugin can read and write only inside /var/www/shop.example.com, can't shell out to the OS, and can't execute anything it manages to write into the uploads directory.
Step 1 — System user and directories
The single most important decision is not running the site as www-data. The shared www-data account is what lets a compromise on one site touch the files of every other site in /var/www. A dedicated, login-less user per site is the foundation everything else rests on.
# Dedicated, login-less user; -r = system account, -M = no home created here
useradd -r -M -d /var/www/shop.example.com -s /usr/sbin/nologin shopexample 2>/dev/null || true
install -d -o shopexample -g shopexample /var/www/shop.example.com/tmp
chown -R shopexample:shopexample /var/www/shop.example.com/public_html
useradd -r creates a system account (no aging, low UID range); -M skips home-directory creation because the web root is the user's territory; -s /usr/sbin/nologin means a stolen credential for this user can't open a shell. The trailing || true keeps the command idempotent if the user already exists. The separate tmp directory owned by the site user becomes the home for upload_tmp_dir and session.save_path in the next step — keeping temp files and sessions inside the site's own jail instead of a world-shared /tmp.
Step 2 — Per-site PHP-FPM pool
This is where isolation gets real. Each site gets its own pool file, running as the site's user, with PHP locked to that site's directory tree.
; /etc/php/8.4/fpm/pool.d/shop.example.com.conf
[shop.example.com]
user = shopexample
group = shopexample
listen = /run/php/shop.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
catch_workers_output = yes
; --- isolation: a webshell is confined to one site ---
php_admin_value[open_basedir] = /var/www/shop.example.com/:/tmp/
php_admin_value[upload_tmp_dir] = /var/www/shop.example.com/tmp
php_admin_value[session.save_path] = /var/www/shop.example.com/tmp
; --- neutralize .user.ini (a persistence vector) ---
php_admin_value[user_ini.filename] =
; --- strip RCE helpers ---
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
; allow_url_fopen stays ON (WP updates/REST/oEmbed) — restrict egress at the firewall
Two ownership lines confuse people: the pool runs as shopexample, but listen.owner/listen.group are www-data. That's deliberate — the worker runs as the site user (so files it writes are owned correctly and it can't read other sites), while the socket is owned by www-data so Apache (running as www-data) can connect to it. The open_basedir line is the jail: PHP refuses any file operation outside /var/www/shop.example.com/ and /tmp/, so a webshell can't cat /var/www/other-site/wp-config.php. disable_functions removes the functions a webshell uses to break out of PHP into the OS shell — without exec and proc_open, most off-the-shelf backdoors are inert. The user_ini.filename = line is subtle but matters: PHP reads per-directory .user.ini files by default, and an attacker who can write to uploads can drop a .user.ini that re-enables disable_functions functions. Setting the filename to empty turns that vector off. Reload the service afterward:
systemctl reload php8.4-fpm
WordPress workers run 30–80 MB each, so pm.max_children = 10 is a conservative starting point for a low-to-mid-traffic site; raise it only after you've measured peak per-worker memory against the RAM you've allotted. The official PHP-FPM configuration reference documents every pool directive if you want to tune further.
Step 3 — Let's Encrypt certificate
Issue the certificate via the webroot method before you enable the :443 block — the HTTP-01 challenge has to succeed over plain port 80 first.
certbot certonly --agree-tos --email root@example.com --webroot -w /var/lib/letsencrypt/ \
-d shop.example.com
certonly obtains the certificate without letting certbot rewrite your vhost (you've hand-built it, so you don't want auto-edits). --webroot -w /var/lib/letsencrypt/ tells certbot to drop the challenge token under that path — which is exactly where the Alias in the :80 block (next step) points the /.well-known/acme-challenge/ URL. The certificate lands in /etc/letsencrypt/live/shop.example.com/. You can generate the full set of certbot variants — webroot, standalone, DNS-01, wildcard — in the Let's Encrypt command generator on vps.pyrek.com.pl.
Step 4 — The VirtualHost file
Now the vhost itself: a :80 block that handles the ACME challenge then redirects to HTTPS, and a :443 block that serves WordPress with the full hardening stack.
# /etc/apache2/sites-available/shop.example.com.conf
<VirtualHost *:80>
ServerName shop.example.com
# ACME http-01 MUST pass before the redirect:
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://shop.example.com%{REQUEST_URI} [L,R=301]
ErrorLog ${APACHE_LOG_DIR}/shop.example.com-error.log
CustomLog ${APACHE_LOG_DIR}/shop.example.com-access.log combined
</VirtualHost>
<VirtualHost *:443>
ServerAdmin root@example.com
ServerName shop.example.com
Protocols h2 http/1.1
DocumentRoot /var/www/shop.example.com/public_html
# WP permalinks need .htaccess in the root:
<Directory "/var/www/shop.example.com/public_html">
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# ============================ HARDENING ============================
# PHP ONLY from this site's per-user pool:
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/shop.example.com.sock|fcgi://localhost"
</FilesMatch>
# block alternate PHP extensions (.phtml/.phar/.php5...):
<FilesMatch "\.(phtml|pht|phps|phar|php[0-9])$">
Require all denied
</FilesMatch>
# sensitive files / dotfiles / dumps / version leaks:
<FilesMatch "(^\.ht|^\.user\.ini$|wp-config\.php|readme\.html|license\.txt|\.(sql|sqlite|log|bak|swp|inc)$)">
Require all denied
</FilesMatch>
<DirectoryMatch "/\.(git|svn)(/|$)">
Require all denied
</DirectoryMatch>
# xmlrpc.php — brute/pingback vector (unblock if you use Jetpack/mobile app):
<Files "xmlrpc.php">
Require all denied
</Files>
# ZERO PHP in writable directories:
<Directory "/var/www/shop.example.com/public_html/wp-content/uploads">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$"> Require all denied </FilesMatch>
</Directory>
<Directory "/var/www/shop.example.com/public_html/wp-content/cache">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$"> Require all denied </FilesMatch>
</Directory>
<Directory "/var/www/shop.example.com/public_html/wp-content/upgrade">
AllowOverride None
<FilesMatch "\.(php[0-9]?|phtml|pht|phar|phps|inc)$"> Require all denied </FilesMatch>
</Directory>
# ==================================================================
ErrorLog ${APACHE_LOG_DIR}/shop.example.com-error.log
CustomLog ${APACHE_LOG_DIR}/shop.example.com-access.log combined
SSLEngine On
SSLCertificateFile /etc/letsencrypt/live/shop.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/shop.example.com/privkey.pem
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLHonorCipherOrder off
</VirtualHost>
Walking the directives that matter:
SetHandler "proxy:unix:/run/php/shop.example.com.sock|fcgi://localhost" inside <FilesMatch "\.php$"> routes PHP to this site's socket — not a shared FPM pool. This is the line that ties Apache to the per-user pool from Step 2. Get the socket path wrong and you either get a 503 or, worse, you silently fall back to another site's pool and lose your isolation.
The two <FilesMatch> deny blocks close the gaps the main PHP handler leaves. The first denies alternate PHP extensions — .phtml, .phar, .php5 — because an attacker who can't write evil.php will happily write evil.phtml and hope your handler only matched .php. The second denies the files that leak information or contain secrets: wp-config.php (database credentials), readme.html (exact WordPress version), SQL dumps, backup files, swap files. Require all denied is the Apache 2.4 syntax; the old Order deny,allow / Deny from all pair throws errors on modern Apache. The Apache project's own securing PHP guide covers the open_basedir-in-vhost approach if you want the upstream reference.
The three <Directory> blocks for uploads, cache, and upgrade are the single most valuable WordPress-specific hardening. These directories are writable by the web server — that's where an unauthenticated file-upload vulnerability drops its payload. By denying execution of any PHP-like extension inside them, a backdoor uploaded to wp-content/uploads/2026/evil.php becomes a static file the server refuses to run. AllowOverride None on these directories also means a dropped .htaccess can't re-enable execution. This is the WordPress-recommended directory hardening, done at the vhost level so it can't be overridden from inside the document root.
Step 5 — Activate and reload
a2ensite shop.example.com
apache2ctl configtest && systemctl reload apache2
a2ensite symlinks the file from sites-available into sites-enabled. apache2ctl configtest validates syntax — never skip it, because a reload on a broken config can take the whole server down. The && ensures reload only runs if configtest passed. reload re-reads the configuration without dropping live connections, unlike restart which kills active requests.
Common mistakes and pitfalls
Running the pool as www-data and thinking you're isolated
The most common failure: people copy a per-site pool template but leave user = www-data. Now every "isolated" pool runs as the same user, open_basedir is the only thing standing between sites, and any function that ignores open_basedir (and several do) gives cross-site read access. If the pool's user matches any other site's pool user, you don't have isolation — you have the illusion of it. Each site needs its own dedicated user.
Forgetting to block PHP in wp-content/uploads
The root-level PHP handler happily executes wp-content/uploads/evil.php unless you explicitly deny it. This is the path almost every WordPress compromise takes: a plugin with a broken upload form lets an attacker write a .php file into uploads, then they browse to it and it runs. Blocking execution there — and in cache and upgrade — is what turns a successful upload into a harmless static file. Verify with curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/wp-content/uploads/x.php — you want 403.
.user.ini left active
PHP reads .user.ini from the directory of the executing script by default. An attacker who lands a file in uploads can also drop a .user.ini that re-enables disable_functions-blocked functions for any PHP that runs from that directory. Setting php_admin_value[user_ini.filename] = (empty) in the pool disables the mechanism. Skip this and your disable_functions line has a documented bypass.
Socket owner mismatch — AH01067 / 503 on every PHP request
If listen.owner/listen.group aren't www-data, Apache (running as www-data) can't connect to the FPM socket, and every PHP request returns a 503 with a permission error in the error log. The worker runs as the site user; the socket must be readable by Apache. Set listen.owner = www-data, listen.group = www-data, listen.mode = 0660.
"AH00558: Could not reliably determine the server's fully qualified domain name"
A warning, not a fatal error, but it clutters every reload. It means Apache couldn't resolve a global ServerName. Fix it by adding ServerName your.fqdn to /etc/apache2/apache2.conf (or just ServerName localhost). It does not affect the vhost-level ServerName directives — those are separate.
xmlrpc.php block breaks Jetpack or the mobile app
<Files "xmlrpc.php"> Require all denied </Files> returns 403 to everything hitting that endpoint — which is what you want, since xmlrpc.php is a brute-force and pingback-amplification magnet. But if you actually use Jetpack, the WordPress mobile app, or remote publishing, those depend on xmlrpc.php and will break. Either remove the block or replace it with an IP allow-list for the services that need it.
Certbot fails because the redirect catches the challenge
If your :80 block redirects everything to HTTPS before the ACME challenge can be served, certbot's HTTP-01 validation fails. The RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ line is what exempts the challenge path from the redirect. Drop that condition and certbot can't validate. Order matters: the Alias and <Directory> for the challenge must come before the redirect rule.
FAQ
How do I stop one hacked WordPress site from infecting the others on my server?
Run each site under its own system user with its own PHP-FPM pool, and set open_basedir per pool to confine PHP to that site's directory. With a shared www-data user, a compromise on one site can read and write every other site's files; with per-user pools and open_basedir, a webshell is jailed to the site it landed on. This is the core of the configuration the WordPress vhost generator produces.
How do I disable PHP execution in the WordPress uploads folder with Apache?
Add a <Directory> block for wp-content/uploads with AllowOverride None and a <FilesMatch> denying .php, .phtml, .phar, and similar extensions with Require all denied. Doing it at the vhost level (not in .htaccess) means a dropped .htaccess inside uploads can't re-enable execution. Repeat for cache and upgrade, which are also web-server-writable.
Is Require all granted the same as Allow from all?
No. Allow from all is Apache 2.2 syntax; Apache 2.4 replaced it with mod_authz_core and Require all granted (or Require all denied to block). The old syntax on 2.4 throws AH01630: client denied by server configuration and serves a 403. If you see Order allow,deny paired with Allow from all, the config predates 2012.
Should I block xmlrpc.php on WordPress?
Yes, unless you use Jetpack, the mobile app, or remote publishing — those need it. xmlrpc.php is a favorite target for brute-force attacks (it lets attackers try many credentials per request) and pingback-based DDoS amplification. Blocking it at the server level with Require all denied serves a cheap 403 without loading PHP or the database, which is far lighter than a plugin-based block.
Why does my PHP-FPM pool run as one user but the socket is owned by www-data?
The worker process runs as the site's dedicated user so files it creates are owned correctly and it can't read other sites. The socket is owned by www-data because Apache runs as www-data and needs permission to connect to the socket. Two different concerns: process identity (isolation) and socket access (connectivity). Set user/group to the site user and listen.owner/listen.group to www-data.
Do I need a separate PHP-FPM pool for each WordPress site?
If you host more than one site on the server and care about isolation, yes. A shared pool means a compromise on any site runs as the same user with access to all sites' files. A per-site pool under a dedicated user, with per-site open_basedir, is the difference between one compromised site and all of them. For a single-site server it matters less, but the disable_functions and PHP-execution blocks still apply.
What PHP version should I use for WordPress?
Use a currently supported branch — PHP 8.2, 8.3, or 8.4 as of this writing. Running an end-of-life version (anything 7.x or 8.0/8.1 once support lapses) means unpatched interpreter vulnerabilities that an attacker can chain off a plugin foothold. The generator lets you pick the FPM version so the pool path and socket name match what's installed.
How do I verify the hardening actually works?
Use curl against the endpoints that should be blocked and confirm they return 403: curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/wp-content/uploads/x.php (uploads PHP block), the same against /x.phtml (alternate extension), /wp-config.php (sensitive file), and /xmlrpc.php. Check the redirect with curl -sI http://shop.example.com/ | grep -i location (should show a 301 to https). Confirm the pool user with ps -o user= -C php-fpm8.4 | sort -u — your site user should appear.
Next steps
Generate your hardened configuration with your own domain, PHP version, and user in the Apache VirtualHost for WordPress generator on vps-web.com — fill the form, get the pool file, certbot command, and vhost ready to paste. Every isolation setting in this article is applied automatically.
Related topics that build out a hardened stack:
- Apache VirtualHost configuration for Ubuntu/Debian — the standard vhost generator when you're not running WordPress and don't need the per-site hardening.
- Apache mod_remoteip behind Nginx Proxy Manager — recover the real client IP in your logs when this vhost sits behind a reverse proxy that terminates TLS.
- Proxmox port forwarding with iptables — expose ports 80/443 to the VM hosting this WordPress site if it runs on a private Proxmox subnet.
If you prefer video, the channel covers Linux administration, Proxmox, and web hosting hardening in practice: Praktycznie o informatyce on YouTube. Subscribe if isolated multi-site hosting and server security are your thing.