High Performance Mautic with Apache Event, NGINX, and PHP-FPM

Most novice Mautic users deploy Mautic using Apache and mod_php but higher performance and more scalable configurations do exist. Apache is a general-purpose web server that by default, runs in mpm_prefork mode. MPM stands for Multi-Processing Module, describing the way that Apache handles incoming requests. In Prefork mode, Apache is process-driven and not event-driven. This means that each Apache process can only handle one connection at any given time.

  • Even though it is the least scalable, the Prefork MPM is the default for Apache with many distributions as it is the most compatible with legacy apps. It supports “non-thread safe” processes such as mod_php, which is the default method of parsing PHP applications on an Apache server. Because mod_php runs PHP together with the Apache server process, it is impossible for multiple Apache processes to share memory, which is required for the Worker or Event MPM to function.
  • The Worker MPM spawns a pool of threads under an Apache parent process and each thread (instead of process) can serve one connection. Worker is more performant than Prefork because a new process does not have to be started every time a connection is initiated, if there is an available thread.
  • The Event MPM goes one step further by supporting KeepAlive which means that a thread is left open for a connection unless it is idle for a longer period of time than the KeepAliveTimeout threshold (by default 5 seconds but can be overridden in httpd.conf).

Starting with Apache 2.4, mpm_event is the recommended MPM for Apache because it minimizes the frequency which an Apache thread has to be respawned to handle long-lived connections. It delivers even better performance than Worker, which provides an incremental improvement over Prefork.

Switching from mod_php to php-fpm as the execution mode makes PHP thread safe, opening up the possibility of using a different MPM than Prefork, or switching to different web server altogether such as NGINX. PHP-FPM runs an FPM pool separately from the web server to handle PHP requests, accepting connections through a TCP/IP port (default is 127.0.0.1:9000) or a Unix socket (e.g. /var/run/php/php7.3-fpm.sock).

PHP-FPM is a more modern way to deploy PHP applications because PHP can even reside in a separate container or virtual server from the web server. Unlike Apache, NGINX does not even support running PHP in the same process. Switching to PHP-FPM is a requirement to use NGINX as a web server for a PHP application, such as Mautic.

Based on the memory available on your server, the parameters for PHP-FPM can be tuned so that there are always a minimum number of child processes available to answer requests. This can be accomplished by either setting your PHP-FPM process management type to static, which always has a fixed number of child processes, or dynamic, and tuning the pm.max_children, pm.start_servers, pm.min_spare_servers, pm.max_spare_servers, pm.process_idle_timeout values for the PHP-FPM pool responding to Mautic requests. The optimal values are calculated based on the memory used by the Linux kernel and system services, each individual FPM child process, and the available physical RAM.

Carefully tuning these values will prevent either the underutilization or overutilization of RAM – both of which can cause serious performance issues. Underutilization causes the server to perform under its potential and respond to incoming requests slowly, or not at all when they timeout. Overutilization will cause the web server to crash and/or excessive paging to the swap file, resulting in the disk I/O thrashing.

What the advantage of hosting web applications such as Mautic using NGINX instead of Apache? NGINX uses an event-driven model to handle incoming HTTP requests which means it is much more efficient, especially at serving up static files such as HTML, CSS, JS, or images. This is a similar approach to Apache’s Event MPM with the additional performance benefits of NGINX as a lightweight web server that has a small memory footprint.

Sample NGINX Config for Hosting Mautic

Here a sample NGINX configuration file that can be used to host Mautic with NGINX. It redirects HTTP to HTTPS by default and assumes that your Mautic files reside at /var/www/html/ – the default webroot. Some distributions of NGINX, for example from  the CentOS package manager may use a different, default webroot at /usr/share/nginx/html/.

It assumes you have obtained a Let’s Encrypt certificate for the Mautic hostname (i.e. subdomain) located at /etc/letsencrypt/live/mautic.example.com/ using Certbot. The configuration supports automatic certificate renewal as the /.well-known/ directory is set up to accept HTTP and HTTPS requests for the ACME challenge.

The SSL protocol versions and cipher suites follow the recommendations given by https://ssl-config.mozilla.org/ and result in an A+ (or A, if HSTS is disabled) at SSL Labs SSL Test as of the time of this writing.

You need to generate a Diffie-Hellman parameter file using the OpenSSL toolchain at /etc/nginx/dhparam.pem prior to loading the NGINX config.

openssl dhparam -out dhparams.pem 4096 > /etc/nginx/dhparam.pem

Also, if you want to be able to use TLS v1.3, the latest recommended version of TLS that is unbroken by exploits that affect older versions such as TLS v1.1, TLS v1.0, and SSL v3, you may need to install a newer version of NGINX (> 1.13.0) other than from your distribution’s default repositories.

server {
listen 443 ssl;
listen [::]:443 ssl;

server_name mautic.example.com;
root /var/www/html/;
server_tokens off;

add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';

ssl_certificate /etc/letsencrypt/live/mautic.example.com/fullchain.pem; 
    	ssl_certificate_key /etc/letsencrypt/live/mautic.example.com/privkey.pem;

ssl_protocols TLSv1.3 TLSv1.2;
    	ssl_prefer_server_ciphers on;
    	ssl_dhparam /etc/nginx/dhparam.pem;
    	ssl_ciphers EECDH+AESGCM:EDH+AESGCM;
    	ssl_ecdh_curve secp384r1;
    	ssl_session_timeout 10m;
    	ssl_session_cache shared:SSL:10m;
    	ssl_stapling on;
    	ssl_stapling_verify on;
    	resolver 8.8.8.8;
    	resolver_timeout 5s;
    	add_header Strict-Transport-Security 'max-age=63072000; includeSubDomains; preload' always;

ssl_session_tickets off;

if ($scheme != "https") {
return 301 https://$host$request_uri;
}

location ~ /.well-known {
  		allow all;
}

location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_index index.php;
fastcgi_pass  127.0.0.1:9000;
fastcgi_param   SCRIPT_FILENAME $document_root$fastcgi_script_name;
include       fastcgi_params;
}

client_max_body_size 64M;

	gzip on;
	gzip_disable "msie6";
	gzip_min_length 256;

    	gzip_vary on;
    	gzip_proxied any;
    	gzip_comp_level 6;
    	gzip_buffers 16 8k;
    	gzip_http_version 1.1;
gzip_types
            font/truetype
            font/opentype
            font/woff2
            text/plain
            text/css
            text/js
            text/xml
            text/javascript
            application/javascript
            application/x-javascript
            application/json
            application/xml
            application/rss+xml
            image/svg+xml;

            error_page 404 /index.php;

# redirect index.php to root
rewrite ^/index.php/(.*) /$1  permanent;

# redirect some entire folders
rewrite ^/(vendor|translations|build)/.* /index.php break;

location / {
try_files $uri /index.php$is_args$args;
}

    # Deny everything else in /app folder except Assets folder in bundles
location ~ /app/bundles/.*/Assets/ {
allow all;
access_log off;
	}

	location ~ /app/ { deny all; }

    # Deny everything else in /addons or /plugins folder except Assets folder in bundles
	location ~ /(addons|plugins)/.*/Assets/ {
		allow all;
		access_log off;
    	}
    
    # location ~ /(addons|plugins)/ { deny all; }

    # Deny all php files in themes folder
	location ~* ^/themes/(.*)\.php {
		deny all;
	}

    # Don't log favicon
	location = /favicon.ico {
		log_not_found off;
		access_log off;
	}

    # Don't log robots
	location = /robots.txt  {
		access_log off;
		log_not_found off;
	}

    # Deny yml, twig, markdown, init file access
	location ~* /(.*)\.(?:markdown|md|twig|yaml|yml|ht|htaccess|ini)$ {
		deny all;
		access_log off;
		log_not_found off;
    	}

    # Deny all attempts to access hidden files/folders such as .htaccess, .htpasswd, .DS_Store (Mac), etc...
	location ~ /\. {
		deny all;
		access_log off;
		log_not_found off;
	}

    # Deny all grunt, composer files
	location ~* (Gruntfile|package|composer)\.(js|json)$ {
		deny all;
		access_log off;
		log_not_found off;
	}

	location ~*  \.(jpg|jpeg|png|ico|pdf)$ {
		expires 15d;
	}

    # Deny access to any files with a .php extension in the uploads directory
	location ~* /(?:uploads|files)/.*\.php$ {
		deny all;
	}

    # Solve email tracking pixel not found
	location ~ email/(.*).gif {
		try_files $uri /index.php?$args;
	}

    # Solve JS Loading 404 Error
	location ~ (.*).js {
		try_files $uri /index.php?$args;
	}
}

server {
	listen 80;
	listen [::]:80;
	server_name mautic.example.com;
	return 404;

	if ($host = mautic.example.com) {
		return 301 https://$host$request_uri;
	} 

	location ~ /.well-known {
		allow all;
	}
}