Running PHP as a different user under Apache with FPM and mod_proxy_fcgi
Introduction and Purpose
I use the Apache web server to run this and other sites. Some of those sites use PHP applications. Apache has a facility for running PHP code, called mod_php, but it runs the PHP engine within the httpd process itself. That means all your PHP apps have to run with the same security permissions and execution context. That I didn't want.
What I found was a way to run different PHP apps in their own contexts, using FPM (FastCGI PHP Manager) to host the PHP engine, and mod_proxy_fcgi to connect Apache to FPM.
Pieces and parts
The FastCGI protocol is a standard for letting web servers talk to arbitrary backend processes that produce web content. The overall idea is similar to the classic CGI model. However, CGI generally has the web server run the CGI process, and a new process is started and completed for each page request. FastCGI has the web browser make a socket connection to the FastCGI process, and the FastCGI process can be long-lived (reducing startup costs).
FPM is a daemon that speaks the FastCGI protocol to web servers, and hosts a PHP engine. This avoids initializing the PHP engine with each page request. Individual pages can be parsed once and then cached in memory. And, to my needs, the PHP processes can run under separate users vs the web server.
For illustration purposes here, I will be using Roundcube web mail as the PHP app being deployed. Most of the concepts generalize to other PHP apps. Debian provides a Roundcube package, which installs the meat of the app /var/lib/roundcube/. The top-level PHP entry point is then /var/lib/roundcube/public_html/index.php. We'll suppose the web site's DocumentRoot is the /srv/web/example.com/ directory.
Getting started
To make best use of FPM from a performance standpoint, one should change the Apache MPM (Multi Process Module) from the default "pre-fork" to "event". Otherwise one has to manually balance Apache vs FPM processes, and "event" has other advantages. Debian has a facility to manage Apache modules, so I just did:
a2dismod mpm_prefork a2enmod mpm_event
I needed to install the FPM package. I also wanted to disable mod_php, since the whole point of this is to move PHP outside of Apache itself:
a2dismod php7.4 apt install php-fpm
I created a user account to run the Roundcube processes:
adduser --system --group --home /var/lib/roundcube roundcube
Configuring the PHP-FPM daemon
On Debian, the config for FPM is stored in /etc/php/7.4/fpm/php-fpm.conf (varying by PHP version, of course). The [global] section defines the overall daemon configuration; individual apps are configured in pools (explained below).
A copy of my php-fpm.conf is included in the samples at the end of this page. There isn't much to it. The highlights are:
- Establish some limits to hopefully guard against runaway programs and/or flooding attacks.
- Use syslog for logging
I discovered that when FPM logs to syslog, all messages get the same syslog priority (severity). Whatever log_level one configures in php-fpm.conf is the priority that will be given in the syslog protocol. Separately from the syslog protocol, FPM also inserts a text prefix tag in the syslog message text, reflecting the actual severity of the message. log_level also determines the minimum severity of message that gets sent to syslog in the first place. FPM uses alert, error, warning, notice, and debug (but not info).
Configuring the FPM pools
FPM runs PHP apps in groups of processes called pools. A given pool shares a FastCGI socket and Unix user/group. The Debian FPM packaging expects individual pools to be defined in files in /etc/php/7.4/fpm/pool.d, which are include'd by php-fpm.conf. We'll configure a pool for Roundcube, by creating /etc/php/7.4/fpm/pool.d/roundcube.conf.
First we define the pool name in [square brackets], and the user and group the PHP apps will be run as:
[roundcube] user = roundcube group = roundcube
Next we define the FastCGI socket which Apache will use to talk to FPM. The listen.user, .group, and .mode are the ownership and permissions applied to the socket — not the pool process! — and thus must be suitable for the process running Apache (running as www-data in Debian).
listen = /run/php/roundcube.sock listen.owner = www-data listen.group = www-data listen.mode = 0600
While the FPM global config can log to syslog, the individual pools cannot. The pools can only log to an Apache-style access log. Given the log format, I put it in with the logs I had configured Apache to maintain for the rest of the website:
access.log = /var/log/apache2/example.com/roundcube-access catch_workers_output = yes decorate_workers_output = yes
Finally we configure the performance parameters of the pool:
pm = dynamic pm.max_children = 10 pm.start_servers = 1 pm.min_spare_servers = 1 pm.max_spare_servers = 3 pm.max_requests = 100
pm defines the pool model. One can have a fixed pool of processes, a dynamic pool, or start every worker on demand. I decided on dynamic, although demand would likely have worked as well.
max_children define how many running workers there can be. This should match the max= parameter in the Apache ProxyMatchPass directive (see below). start_servers is the initial number of workers to spawn when the pool is first started. The spare_servers directives control how the pool is grown or shrunk dynamically. max_requests forces workers to exit after that many requests from Apache, and mainly serves to guard against memory leaks.
I also renamed the stock www.conf pool, so that it would not be used. Further, I did the rename using dpkg-divert, so that future package updates would respect the change:
dpkg-divert --rename --divert /etc/php/7.4/fpm/pool.d/www.stock --add /etc/php/7.4/fpm/pool.d/www.conf
Managing PHP engine configuration
Any PHP environment is configured by php.ini. With all this, I have two possible PHP environments now, mod_php and FPM. They each get their own configuration files:
Mode | Config File |
---|---|
mod_php | /etc/php/7.4/apache2/php.ini |
FPM | /etc/php/7.4/fpm/php.ini |
In my case I wanted PHP to use the same configuration for both mod_php and FPM, so I can switch between them if needed. To do this I set-up one file and some symlinks:
mv /etc/php/7.4/apache2/php.ini /etc/php/7.4/php.ini ln -s ../php.ini /etc/php/7.4/apache2/php.ini ln -s ../php.ini /etc/php/7.4/fpm/php.ini
The only problem with this idea is that dpkg bombed out during an update of the PHP packages:
Setting up php7.4-fpm (7.4.30-1+deb11u1) ... ucfr: Attempt from package php7.4-fpm to take /etc/php/7.4/php.ini away from package libapache2-mod-php7.4 ucfr: Aborting. dpkg: error processing package php7.4-fpm (--configure): installed php7.4-fpm package post-installation script subprocess returned error exit status 4
Apparently Debian uses something called UCF (Update Configuration File) to manage config files that are auto-generated by the distribution. The idea is it will also respect user edits to those same files. Unfortunately it apparently can't cope with symlinks, and also doesn't respect dpkg-divert relocations. The only "fix" I could find was to temporarily move the symlinks away (and files back), and run dpkg --configure php7.4-fpm.
It's up to you to decide if the UCF upgrade lossage is worth the benefit from having a single PHP config file to worry about.
Hooking mod_proxy_fcgi into the site
First I had to tell Apache to load the mod_proxy_fcgi and mod_proxy modules. "a2enmod proxy_fcgi" and done. Then I added appropriate Apache config directives to send PHP requests to FPM, within the VirtualHost sections for the sites:
DirectoryIndex index.html index.php ProxyPassMatch \ \.php$ \ unix:/run/php/roundcube.sock|fcgi://localhost/srv/web/roundcube \ max=10 \ acquire=10 \ enablereuse=On \ retry=10 \ ttl=120
In the DirectoryIndex directive, index.html must come first. If index.php comes first, Apache of course tries that first. Any *.php request gets grabbed by ProxyPassMatch and sent to PHP-FPM. If PHP-FPM does not find an index.php file, it generates its own "file not found" error page. Apache sees that error page as valid page content to return to the HTTP client, and thus Apache never goes looking for index.html as a fallback.
In the ProxyMatchPass directive, the max= parameter should match the max_children directive for the FPM pool (see above).
Aliasing the app URL path
I did not want to put the app at the root of the virtual host. I wanted it under a named path in the URL, so that it appeared like https://www.example.com/roundcube/.
Originally, I tried using an Apache Alias directive to do this. However, with ProxyPassMatch, Apache passes the URL path through to FastCGI, which is unaware of Apache aliases. For example the top-level /roundcube/index.php became /var/lib/roundcube/public_html/roundcube/index.php by the time FPM went looking for a file. That extra "roundcube" just before index.php messes everything up.
I could work around that in the Apache config by using a RewriteRule and the [proxy] flag, but that causes everything to get funneled through a single Apache proxy worker. I wanted this to be able to scale if I ever needed it to.
I could have configured a symlink at /var/lib/roundcube/public_html/roundcube/ pointing up one level, but that would have created a circular filesystem path, something I strive to avoid. (Software could chase into /var/lib/roundcube/public_html/roundcube/roundcube/roundcube/... endlessly.)
Instead I created a symlink within the site, pointing to the proper directory. That makes the URL and file line up properly.
ln -s ../../../var/lib/roundcube/public_html /srv/web/example.com/roundcube
Config file samples
These are not a working config. You'll need to incorporate into your own config. But hopefully they'll provide a starting point.
In these files, I'm using an example site at example.com, and roundcube as the example PHP app.