Secured and effective PHP FPM hosting setup for multiple websites

Published:  12/12/2019 12:06


Running PHP as FPM (FastCGI Process Manager) is nothing new and is pretty much the only way to couple it with Nginx.

However, securing such a setup might not be straightforward.

We'll hereby present an easy way to achieve a satisfactory level of security on a server running multiple separate PHP apps (could apply to a shared hosting environment).

In this example we're using GNU/Linux Debian 10 "buster" but the instructions are pretty much the same for Debian 9.

PHP execution models

Hosting PHP apps, be it through container based solutions or virtual servers, usually implies using the prefork model of Apache and having PHP as an Apache module.

Nowadays it's often seen coupled with Nginx but it used to be closely tied to Apache for some reason.

For instance, Docker images tagged with "Apache" all use the prefork model, as is suggested in many howtos and tutorials about setting up a PHP Linux server.

The prefork Apache model basically spawns an entire new Apache process per request, together with its very own memory space and copies of all the enabled Apache modules.

The degree of isolation is maximum but so is the RAM usage and process spawning is overall very inneffective, on top of the intrinsic nature of PHP itself which is to rebuild the entire context for every request.

In practice, prefork setups are very brittle when put up against any kind of traffic spike and as such we tend to always use another execution model, which consists in using FPM together with either Apache in event loop mode or Nginx.

PHP-FPM creates daemon processes that accept the piping of PHP code through them be it through a UNIX or TCP socket. These processes are kept in memory and are reused for subsequent requests, whereas the Apache prefork mode would re-created the whole environment.

The Apache or Nginx server in front will have to be configured to pass PHP files through the PHP-FPM processes. In turn they can use the most effective execution model, which is some form of event loop with usually one process per CPU for Nginx, and a mix of threads for Apache.

Both servers have similar performances when it comes to PHP apps so it comes down to preferences as to which one you want to use.

Installing everything

We'll be showing the procedure for Apache mainly but here will be a later section about Nginx. Just keep in mind this article mostly assumes you want to use Apache.

We also assume you're logged in as root. You can prepend everything with sudo if you're using it on your system.

apt update && apt install apache2 php7.3-fpm php7.3-curl \
  php7.3-gd php7.3-imap php7.3-intl php7.3-json php7.3-mbstring \ 
  php7.3-mysql php7.3-opcache php7.3-pgsql php7.3-pspell \
  php7.3-readline php7.3-recode php7.3-sqlite3 php7.3-tidy \ 
  php7.3-xml php7.3-xmlrpc php7.3-xsl php7.3-zip zip

Setting up PHP-FPM

You'll find all the PHP configuration files in /etc/php/<VERSION>. In case you have multiple versions installed, they will all show here.

It's technically possible to have separate versions of PHP-FPM running if you have other version of the php7.3-fpm package we installed before.

Once in the version specific directory, you'll find a fpm directory.

Baseline PHP configuration

Let's first edit the php.ini configuration file that's in the fpm directory to adjust a few global settings.

You'll have to look up the following lines and set adequate values for your case:

max_execution_time = 60
memory_limit = 256M
date.timezone = 'Europe/Brussels'
upload_max_filesize = 100M
post_max_size = 110M
expose_php = Off

PHP-FPM process pools setup

We now need to setup process pools for the PHP apps we want to run in isolation.

PHP-FPM will process any pool configuration file it finds in the /etc/php/<VERSION>/pool.d directory.

You should see a heavily annotated www.conf which can be used as a baseline to create new pools.

The default pool can basically access the whole system as long as its user (www-data) has read permission to these files and directories. This is pretty bad, not only can PHP apps see the files of other PHP apps, they can just see everything on the server.

Sensible files should have permissions set to prevent the www-data to read their contents, but it's easy to make a mistake and, for instance, copy a certificate private key with the wrong permissions that could then be read and copied by one of the PHP apps.

To fix that issue we'll want more restrictive rights on the process pools we're going to use.

Let's copy www.conf to a new file, for instance myapp.conf, and edit that file.

Changing the pool name

It's easy to mistakingly forget about that step.

At the top of the file you should see the pool name inside brackets. For instance this is the beginning of the www.conf file:

; Start a new pool named 'www'.
; the variable $pool can be used in any directive and will be replaced by the
; pool name ('www' here)

Don't forget to edit the name and make it the new pool name. For instance "myapp".

Changing the socket

First, change the UNIX socket file name (or the IP address or port if you want to use TCP):

listen = /run/php/php7.3-fpm-myapp.sock

Running as a specific system user

Ideally you'd want to create a specific user per application. So in our example we need a user called "myapp". Don't give it a password to make it impossible to login as that user through any system service that may allow loging in.

We can now configure that user to run the pool using these two directives:

user = myapp
group = myapp

Now we could make it so that user's home directory is the PHP app directory, and actually chroot the PHP-FPM processes into there. This is the way to get maximum security but it also complicates the structure of that directory, as it has to have all files necessary to a chroot in there, including a temporary directory, something to store the PHP sessions in, and more.

Fortunately there is an easier way that is less secure by default but can be brought to about the same level by banning some PHP functions. It involves using the open_basedir directive.

Apply open_basedir restrictions

The open_basedir directive restricts the files that can be accessed by any PHP script to the directories provided in the directive.

It's sometimes seen as also including /tmp but as an extra security we'll create our own tmp directory for the application.

Assuming our app is in /srv/apps/myapp directory and that the pool will be running as the "myapp" system user, let's create the tmp directory:

mkdir -p /srv/apps/myapp/tmp
chown -R myapp:myapp /srv/apps/myapp/tmp

In our pool configuration file, assuming you're also using Debian, there should be a few examples of PHP configuration overrides which are commented out at the end of the www.conf Debian template.

The main php.ini file we edited before is the base for all of the PHP configuration, but we can override most settings per pool basis.

For our current example, let's add these directives:

php_admin_value[open_basedir] = /srv/apps/myapp
php_admin_value[sys_temp_dir] = /srv/apps/myapp/tmp
php_admin_value[upload_tmp_dir] = /srv/apps/myapp/tmp
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source,dl,setenv

Keep in minde that some apps use curl_exec for features such as auto-update from the web app. If that's the case you'll want to remove it from the disable_functions line above.

Quick note: these restrictions only get applied to FPM and not to CLI PHP scripts you might want to run, CLI PHP uses a totally different php.ini file.

The disable_functions restrictions also have the quirk that they actually get added to the ones that are already in the main php.ini file (the one in /etc/php/<VERSION>/fpm) - You cannot override the functions that are disabled in the main php.ini or remove any of them, you can only append more.

Adjusting process count

One last thing you may want to adjust in the pool configuration is how many processes it'll have.

More processes means more requests can be handled in a given time, in addition to increased parallelism at the cost of using more memory.

You will want to make sure that all the running pools you have won't fill up the entire server memory if they spawn all of their child processes.

That calculation depends on how many other services the host is currently running.

We like to use a safe value of 80MB per process. Check how much free memory your server currently has:

$ free -m
        total   used    free    shared  buff/cache   available
Mem:    2004    300     721     139     981           1389
Swap:   379     1       378

The available memory at OS level will be the total minus used minus shared minus buff/cache, which is also shown in the available column.

However we shouldn't prevent the OS from having any buffer/cache space, so a good number would be the available memory times 0.6, which we can then divide by the previously chosen 80MB:


That gives us 10 processes.

Don't forget that amount is the max you can have for all pools combined. So we want to use less than that for our myapp pool.

Quick remark: It usually doesn't make sense to enable too many processes on a server that only has one CPU, unless your PHP scripts are mostly IO bound (e.g. use the filesystem or databases for most requests). Many blog-type or e-shop apps are IO bound because pretty much every request makes a database connection, in that case a single CPU could handle more than 10 processes.

If you're going to allow a lot of processes to be used, you will want to use the "dynamic" spawn mode, whereas the "static" mode will be more efficient for small amounts of processes.

Here's an example static pool config (these directives are in the pool .conf file):

pm = static
pm.max_children = 5

That config will spawn 5 processes at all time.

The default configuration copied from www.conf will show a dynamic pool example and has everything documented thoroughly.

Reload the PHP-FPM configuration

You should probably first check that PHP FPM reads your new configuration correctly and adds the right process pools.

You might want to remove the default "www" pool when you don't intend on using it or configure it to be fully on demand so that it doesn't use any memory at all.

You can list the configuration using the following command:

$ php-fpm7.3 -tt

If that looks good, restart the FPM processes:

$ systemctl restart php-fpm7.3

Setting up the web server (Apache)

Just installing PHP FPM doesn't do anything as far as the web server goes. We have to configure it to pass PHP code through an existing PHP FPM process pool.

General configuration

The installation steps should have added a configuration file that we can use as a template in /etc/apache2/conf-available/php7.3-fpm.conf.

We'll want to copy that file to keep a backup of its original state then open it in a text editor.

Locate the following lines and cut them out of the file (keep them handy, we'll need these lines later on):

<FilesMatch ".+\.ph(ar|p|tml)$">
  SetHandler "proxy:unix:/run/php/php7.3-fpm.sock|fcgi://localhost"

Save the changes and enable that configuration file alongside a few modules required for PHP FPM to work:

$ a2enconf php7.3-fpm
$ a2enmod proxy
$ a2enmod proxy_fcgi

PHP-FPM pools configuration

We're going to need a directory to store all the pool-specific configuration files to include in the relevant Apache virtual host definitions.

We'll use /etc/apache2/php-pools for our example, create that directory if it doesn't exist.

If you left the default "www" pool and want to use it, create a php7.3-default.conf file and add the three lines we copied earlier (the FilesMatch directive) and save.

You can use that template to create configuration files for your other pools. For instance, for our myapp pool, let's create php7.3-myapp.conf and add the following lines:

<FilesMatch ".+\.ph(ar|p|tml)$">
  SetHandler "proxy:unix:/run/php/php7.3-fpm-myapp.sock|fcgi://localhost"

Where we have modified the socket file path with the one we defined in the pool configuration.

Enabling PHP in a Virtual Host

With the current configuration, PHP won't work unless we add a specific directive in the virtual host definitions, which is an Include directive for the pool configuration files we created above (in /etc/apache2/php-pools) such as:

Include /etc/apache2/php-pools/php7.3-myapp.conf

Just add that directive in the main body of any <VirtualHost> section to both enable PHP file handling and do it through the "myapp" FPM pool.

Applying the configuration

Always first check if your configuration is valid:

$ apachectl configtest

Everything fine? Reload the configuration:

$ systemctl reload apache2

About Nginx

We chose to leave Nginx out of scope for this article, but the principle is pretty much the same. You'd create code snippets with fastcgi_pass directive pointing to the right socket and include them in the location sections that match a PHP app.