Custom PHP App on Apache - Hardened VirtualHost and a Dedicated PHP-FPM Pool
Most Apache tutorials assume you're deploying WordPress or Joomla, so they hardcode directory names that your own application doesn't have. If you wrote the code yourself - Laravel, Symfony, Slim, or a flat set of PHP files that grew over five years - you need a different starting point: a VirtualHost that knows nothing about your directory structure, and a PHP-FPM pool that confines the application to its own corner of the filesystem. This guide walks through that configuration, explains why each directive is there, and lists the errors that show up when one piece is missing. The Custom PHP Application generator on vps-web.com produces the whole thing from eight fields, but the reasoning below is what makes the output safe to paste into a production server.
The Custom PHP Application generator - what it does and who it's for
The generator asks for eight values and outputs six blocks of shell commands: system user and directories, the PHP-FPM pool, an optional Let's Encrypt certificate, the VirtualHost file, activation, and a short verification routine.
Those eight fields are the domain, an optional subdomain alias, the PHP version, the system user, the application layout, .htaccess handling, whether Apache should terminate TLS, and whether the site gets its own log files. Everything else - process manager mode, memory limits, disable_functions, security headers, static-asset caching - is fixed in the templates at values that work in production. That's a deliberate choice. A form with twenty-five inputs turns a five-minute task into an afternoon of second-guessing, and anyone who genuinely needs pm.max_spare_servers already knows which file to open.
Two of the eight fields do the real work. Application layout decides whether DocumentRoot points at a public subdirectory (Laravel, Symfony, Slim) or at the project root (flat PHP). .htaccess handling decides whether Apache reads per-directory override files at all. Get those two right and the rest follows.
The output targets Ubuntu and Debian with Apache 2.4 and PHP-FPM 8.1 through 8.5. It assumes nothing about your framework, your database, or your deployment method.
Hands-on: a hardened vhost and pool for your own PHP app
The example below uses example.com, the system user appuser, and PHP 8.3. Adjust to taste.
One system user, one pool, one socket
The single most valuable thing you can do on a server hosting more than one site is stop running everything as www-data. When every application shares that identity, a file-read vulnerability in one site exposes the database credentials of every other site on the box.
# Dedicated account: no shell, no home directory, no login
useradd -r -M -d /var/www/example.com -s /usr/sbin/nologin appuser 2>/dev/null || true
install -d -o appuser -g appuser /var/www/example.com/public_html
install -d -o appuser -g appuser -m 0750 /var/www/example.com/tmp
install -d -o appuser -g appuser -m 0750 /var/www/example.com/logs
Mode 0750 on tmp and logs matters more than it looks. PHP session files live in tmp; at the default 0755 any user with shell access on the server can read active session identifiers and hijack a logged-in account.
The pool then runs as that user and listens on its own Unix socket:
; /etc/php/8.3/fpm/pool.d/example.com.conf
[example.com]
user = appuser
group = appuser
listen = /run/php/example.com.sock
; The socket is owned by www-data because Apache is what connects to it
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 10s
pm.max_requests = 500
ondemand starts workers on the first request and lets them exit after ten seconds of idling, which suits a VPS running several low-traffic sites. Switch to dynamic when the site gets steady traffic and the cold-start latency starts to show. pm.max_requests = 500 recycles each worker periodically, which caps the damage from a slow memory leak in a third-party library.
Note the socket ownership split: the socket belongs to www-data because Apache workers need to connect to it, while the PHP processes behind it run as appuser. Apache can reach every pool on the server - that's expected - but a compromised PHP process is confined to one identity.
Choosing the layout: public/ or the project root
Modern PHP frameworks ship a public directory containing index.php and static assets, with the application code, vendor, and configuration one level above it. That structure exists precisely so the web server cannot reach the sensitive parts.
# Layout "public" - framework applications
DocumentRoot /var/www/example.com/public_html/public
# Layout "root" - flat PHP, index.php in the project root
DocumentRoot /var/www/example.com/public_html
With the first layout, vendor/ and .env sit above DocumentRoot and Apache has no path to them. With the second, everything is inside the served tree and you're relying entirely on explicit deny rules - which is why the generator adds them automatically for vendor, node_modules, storage, config, var, and bin when you pick root:
<Directory "/var/www/example.com/public_html/vendor">
AllowOverride None
Require all denied
</Directory>
Denying a directory in Apache does not stop PHP from reading it. require 'vendor/autoload.php' still works, because that's a filesystem operation, not an HTTP request. The rule only blocks the outside world.
Confining PHP to a single directory
open_basedir is the one PHP directive with an outsized security return. It restricts every file operation the interpreter performs to a directory tree, so a local file inclusion bug stops producing /etc/passwd.
php_admin_value[open_basedir] = /var/www/example.com/
php_admin_value[upload_tmp_dir] = /var/www/example.com/tmp
php_admin_value[session.save_path] = /var/www/example.com/tmp
php_admin_value[sys_temp_dir] = /var/www/example.com/tmp
; Empty value disables .user.ini files entirely
php_admin_value[user_ini.filename] =
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,dl
php_admin_flag[allow_url_include] = off
php_admin_flag[allow_url_fopen] = off
php_admin_value and php_admin_flag cannot be overridden by ini_set() at runtime, unlike their php_value counterparts. That distinction is the whole point - a compromised script cannot lift its own restrictions.
Disabling user_ini.filename closes a subtle hole: without it, anyone who can write a file into an upload directory can drop a .user.ini and change PHP settings for that directory. The PHP-FPM configuration reference documents the full set of per-pool directives.
allow_url_fopen = off is the setting most likely to break an existing application, and it's off on purpose. It stops file_get_contents('https://…') from working, which is the classic entry point for server-side request forgery. Applications that need outbound HTTP should use cURL.
Routing without .htaccess
Every front-controller framework needs the same thing: requests that don't map to a real file should end up at index.php. The usual answer is a .htaccess file with three RewriteCond lines. Apache has a one-line equivalent:
<Directory "/var/www/example.com/public_html/public">
Options -Indexes +SymLinksIfOwnerMatch
AllowOverride None
FallbackResource /index.php
Require all granted
</Directory>
AllowOverride None means Apache never looks for .htaccess files, which removes a filesystem check on every request and one avenue for an attacker who gains write access. FallbackResource handles routing, and the original URL is still available to the application through $_SERVER['REQUEST_URI'].
One constraint from the mod_dir documentation: FallbackResource only fires when no other handler has been assigned to the request. That's why the FastCGI handler belongs in a <FilesMatch> block scoped to .php files rather than in the <Directory> block:
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/example.com.sock|fcgi://localhost"
</FilesMatch>
A request for /users/42 matches no file, gets no handler, falls back to /index.php, and only then hits the FastCGI proxy. Put SetHandler in the <Directory> block instead and every request - including nonexistent paths - is claimed by the handler, FallbackResource never runs, and the application returns 404s for routes that clearly exist.
If your framework's own .htaccess does something you'd rather keep, switch the field to allow and Apache will read it with a restricted override set.
Closing the file-level traps
Three patterns cover most of what leaks from a PHP deployment:
# Executable extensions that should never be served or interpreted
<FilesMatch "\.(phtml|pht|phps|phar|php[0-9])$">
Require all denied
</FilesMatch>
# Dotfiles, dependency manifests, dumps, backups, editor leftovers
<FilesMatch "(^\.|^composer\.(json|lock)$|^package(-lock)?\.json$|\.(env|ini|sql|sqlite|db|log|bak|old|swp|dist|inc|sh|md|ya?ml)$)">
Require all denied
</FilesMatch>
# Version control metadata
<DirectoryMatch "/\.(git|svn|hg)(/|$)">
Require all denied
</DirectoryMatch>
An exposed .git directory is not a theoretical problem - automated scanners hit /.git/HEAD on every new domain, and a readable .git/config often contains a repository URL with an access token in it. An exposed .env gives away database credentials and application keys in one request.
The ^\. pattern denies every dotfile by basename. ACME validation still works, because FilesMatch matches the filename, not the directory, and the challenge token itself is not a dotfile.
TLS, or terminating on a proxy
When Apache handles TLS directly, the port-80 vhost keeps one path open for certificate renewal and redirects everything else:
<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>
certbot certonly --agree-tos --email root@example.com \
--webroot -w /var/lib/letsencrypt/ -d example.com
Behind Nginx Proxy Manager or a similar reverse proxy, TLS terminates upstream and Apache only listens on port 80. Two things change: skip the HSTS header here (it belongs on whatever terminates TLS), and configure mod_remoteip so your logs record real client addresses instead of the proxy's - covered in a separate guide on mod_remoteip.
Finish by enabling the modules the configuration depends on:
a2enmod headers expires proxy_fcgi setenvif
a2ensite example.com
apache2ctl configtest && systemctl reload apache2
Common mistakes and pitfalls
403 on every request, and AH01276 in the log. The full message is AH01276: No matching DirectoryIndex (index.php,index.html) found, and server-generated directory index forbidden by Options directive. With layout public, this almost always means DocumentRoot points at a directory that doesn't exist yet or is empty - you configured Apache before deploying the code. Create the directory first; the vhost passes configtest either way, which is what makes this confusing.
AH01630: client denied by server configuration. Either the <Directory> block is missing Require all granted, or the path in the block doesn't match DocumentRoot character for character. A trailing slash mismatch is enough.
Got error 'Primary script unknown' and a blank 404. PHP-FPM received the request but couldn't find the script. The usual cause is a SetHandler socket path that doesn't match the pool's listen value - a typo in the domain name, or a pool file that was written but never loaded because php-fpm wasn't reloaded.
502 or 503 with AH01079: failed to make connection to backend. The socket doesn't exist or Apache can't open it. Check ss -xl | grep example.com.sock first. If the socket is there, the problem is listen.owner or listen.mode - Apache runs as www-data and needs 0660 with matching ownership.
Routes return 404 but /index.php/users/42 works. FallbackResource isn't firing. Nine times out of ten, SetHandler was placed inside the <Directory> block instead of a <FilesMatch>, so every request already has a handler assigned.
open_basedir restriction in effect after deploying. Something outside the confined tree is being read - a system-wide library in /usr/share/php, or a symlinked storage directory pointing elsewhere. Append the path with a colon rather than removing the directive: php_admin_value[open_basedir] = /var/www/example.com/:/usr/share/php/.
Security headers silently missing. <IfModule mod_headers.c> fails quietly when the module isn't enabled - no error, no warning, no headers. Run a2enmod headers expires and verify with curl -I.
Code changes don't appear after deployment. If you've enabled OPcache with opcache.validate_timestamps = 0 for performance, every deployment must end with systemctl reload php8.3-fpm. The generator leaves OPcache at distribution defaults specifically to avoid this trap.
FAQ
Do I need a separate PHP-FPM pool for every site?
On a server with more than one application, yes. A shared pool means every site runs as the same user, so a file-read bug in one exposes the credentials of all the others. Separate pools also give you per-site memory limits and error logs, which makes debugging considerably faster.
What's the difference between php_admin_value and php_value in a pool file?
php_admin_value and php_admin_flag cannot be changed by ini_set() from inside the application. php_value and php_flag can. Anything security-relevant - open_basedir, disable_functions, allow_url_fopen - belongs in the admin variants.
Can I use this configuration for Laravel?
Yes. Pick the public layout so DocumentRoot lands on public/, and .htaccess handling set to none so Apache routes through FallbackResource instead of Laravel's shipped rewrite rules. Both approaches work; the second removes a per-request filesystem lookup.
Why is allow_url_fopen disabled and what breaks?
It blocks file_get_contents() and fopen() from accepting a URL, which is the most common vector for server-side request forgery. Libraries that fetch remote data without cURL will break. The correct fix is to switch to cURL; the fast fix is setting the flag to on and accepting the risk.
How do I run a custom PHP app as www-data instead of a dedicated user?
Enter www-data as the system user and the generator skips account creation. It's a legitimate choice on a single-site server, and it avoids permission problems with existing deployment scripts. On a shared box it removes the isolation that makes per-pool configuration worth doing.
Is FallbackResource as good as mod_rewrite for front controllers?
For standard front-controller routing, yes, and it's one line instead of five. It won't do conditional redirects, HTTPS enforcement, or trailing-slash normalization - those still need mod_rewrite. Some shared hosts disable FallbackResource, but on your own VPS that isn't a concern.
What does SymLinksIfOwnerMatch actually protect against?
It allows symlinks only when the link and its target share an owner, which prevents someone with write access to a served directory from linking to a file elsewhere on the disk. Static files are served by Apache directly, so open_basedir never sees those requests - this option is what covers them.
How many pm.max_children should I set?
Divide the memory you can spare for PHP by the peak memory of a single request. With 256 MB per worker and 2 GB available, eight is a reasonable ceiling. Setting it too high converts a traffic spike into an out-of-memory kill rather than a queue.
Does this configuration work on Nginx?
The PHP-FPM pool file is identical - it's a PHP-side configuration. The VirtualHost is Apache-specific and would need translating to a server block with fastcgi_pass.
Next steps
Generate the full configuration for your own domain in the Custom PHP Application generator on vps-web.com - eight fields, six blocks of commands, ready to paste into a terminal.
If your application is a CMS rather than custom code, the hardening rules differ enough to warrant a dedicated tool: Joomla VirtualHost hardening knows which Joomla directories need write access and which must never execute PHP. For a plain vhost without the PHP-FPM isolation layer, start with Apache VirtualHost configuration for Ubuntu and Debian.
I cover server configuration like this on video, with live demos on real machines, on my YouTube channel - subscribe if you'd rather watch the setup than read it.