CentOS 7 - Nginx + PHP-FPM + Mariadb Setup

Mariadb

On Centos 7, standard is mariadb 5.5. If you want stable mariadb (currently 10.4), add yum repo:

# vim /etc/yum.repos.d/MariaDB.repo

# MariaDB 10.4 CentOS repository list - as of 2020-02-16
# http://downloads.mariadb.org/mariadb/repositories/
[mariadb]
name = MariaDB
baseurl = http://yum.mariadb.org/10.4/centos7-amd64
gpgkey=https://yum.mariadb.org/RPM-GPG-KEY-MariaDB
gpgcheck=1

Install:

# yum -y install mariadb mariadb-server
# vim /etc/my.cnf.d/server.cnf
...
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci

innodb_file_format = Barracuda
innodb_file_per_table = on
innodb_default_row_format = dynamic
innodb_large_prefix = 1
...

# systemctl start mariadb.service
# systemctl enable mariadb.service
# mysql_secure_installation
Enter current password for root (enter for none): <Enter>

 ---> only for newer mariadb (10.4): Switch to unix_socket authentication [Y/n] n

Set root password? [Y/n] <Enter>
New password: <your_new_root_password>
Re-enter new password: <your_new_root_password>
Remove anonymous users? [Y/n] <Enter>
Disallow root login remotely? [Y/n] <Enter>
Remove test database and access to it? [Y/n] <Enter>
Reload privilege tables now? [Y/n] <Enter>
# systemctl status mariadb.service

Nginx

# yum -y install nginx
# vim /etc/nginx/nginx.conf
http {
...
        #if we want to support big image uploads, match php.ini ....
        client_max_body_size 10M;
        #it's good to put this default here
        index index.html index.htm index.php;
        #enable caching, most important lines are gzip on; and gzip_types;
        gzip on;
        gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript;
        gzip_disable "msie6";
        gzip_vary on;
        gzip_proxied any;
        gzip_comp_level 6;
        gzip_buffers 16 8k;
        gzip_http_version 1.1;

        server {
                # we might want to specify other default homepage of server so we dont give attacker any info
                #root         /usr/share/nginx/html;
                root         /var/www/html;
                
                location / {
                    index  index.html index.htm;
                }
        }
}

# mkdir -p /var/www/html
# vim /var/www/html/index.html
hello

# systemctl restart nginx.service
# systemctl status nginx.service
# systemctl enable nginx.service

Make sure http protocol is unblocked in firewall.

Check by loading http://<ip_address> in your browser, it should display our "hello" page.

Virtual Website / User

# useradd -m example
# passwd example
# usermod -a -G nginx example
# mkdir /home/example/html
# echo "<?php phpinfo(); ?>" > /home/example/html/index.php
# chown example.example -R /home/example/html
# chmod 755 /home/example

PHP-FPM

Make sure you have installed remi repo php72.

# yum -y install php php-fpm php-mysqlnd php-cli php-xml php-gd php-pecl-zip php-opcache php-intl php-process php-mbstring php-bcmath php-pecl-ds
# cp /etc/php.ini /etc/php.ini.original
# vim /etc/php.ini
;optional tweak - performance in symfony app
realpath_cache_ttl = 600
;optional tweak - lot of times we need more memory
memory_limit = 256M
;optional tweak - error reporting adjust
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT & ~E_NOTICE
;optional tweak
post_max_size = 12M
;optional tweak
upload_max_filesize = 10M
;set default PHP timezone
date.timezone = America/New_York
# vim /etc/php.d/10-opcache.ini
;for typical symfony app, default number is low
opcache.max_accelerated_files=20000
# cp /etc/php-fpm.d/www.conf /etc/php-fpm.d/www.conf.default
# mv /etc/php-fpm.d/www.conf /etc/php-fpm.d/example.conf
[example]
user = example
group = example
listen = 127.0.0.1:9001
listen.allowed_clients = 127.0.0.1
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
slowlog = /var/log/php-fpm/example-slow.log
php_admin_value[error_log] = /var/log/php-fpm/example-error.log
php_admin_flag[log_errors] = on
php_value[session.save_handler] = files
php_value[session.save_path] = /var/lib/php/example/session
php_value[soap.wsdl_cache_dir]  = /var/lib/php/example/wsdlcache
# mkdir /var/lib/php/example
# mkdir /var/lib/php/example/session
# mkdir /var/lib/php/examplewsdlcache
# chown example.example -R /var/lib/php/example
# systemctl restart php-fpm.service
# systemctl enable php-fpm.service
# systemctl status php-fpm.service

Creating standard nginx virtual host:

# vim /etc/nginx/conf.d/example.conf
server {
        server_name example.com;
        root /home/example/html;

        location / {
                index index.html index.htm index.php;
        }

        location ~ \.php$ {
                include /etc/nginx/fastcgi_params;
                fastcgi_pass 127.0.0.1:9001;
                fastcgi_index index.php;
                fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        }
}

Creating Wordpress nginx virtual host:

# vim /etc/nginx/fastcgi.conf
fastcgi_param  SCRIPT_FILENAME    $document_root$fastcgi_script_name;
fastcgi_param  QUERY_STRING       $query_string;
fastcgi_param  REQUEST_METHOD     $request_method;
fastcgi_param  CONTENT_TYPE       $content_type;
fastcgi_param  CONTENT_LENGTH     $content_length;
fastcgi_param  SCRIPT_NAME        $fastcgi_script_name;
fastcgi_param  REQUEST_URI        $request_uri;
fastcgi_param  DOCUMENT_URI       $document_uri;
fastcgi_param  DOCUMENT_ROOT      $document_root;
fastcgi_param  SERVER_PROTOCOL    $server_protocol;
fastcgi_param  REQUEST_SCHEME     $scheme;
fastcgi_param  HTTPS              $https if_not_empty;
fastcgi_param  GATEWAY_INTERFACE  CGI/1.1;
fastcgi_param  SERVER_SOFTWARE    nginx/$nginx_version;
fastcgi_param  REMOTE_ADDR        $remote_addr;
fastcgi_param  REMOTE_PORT        $remote_port;
fastcgi_param  SERVER_ADDR        $server_addr;
fastcgi_param  SERVER_PORT        $server_port;
fastcgi_param  SERVER_NAME        $server_name;
fastcgi_param  REDIRECT_STATUS    200;

# vim /etc/nginx/conf.d/example.conf
server {
    listen       80;
    server_name example.com www.example.com;
    root   /home/example/public_html;
    
    location = /favicon.ico {
                log_not_found off;
                access_log off;
        }
    
        location = /robots.txt {
                allow all;
                log_not_found off;
                access_log off; 
        }

        location / {
                # This is cool because no php is touched for static content.
                # include the "?$args" part so non-default permalinks doesn't break when using query string
                try_files $uri $uri/ /index.php?$args;
        }

        location ~ \.php$ {
                include fastcgi.conf;
                fastcgi_intercept_errors on;
                fastcgi_pass   127.0.0.1:9001;
        }

        location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
                expires max;
                log_not_found off;
        }
    
    error_log /var/log/nginx/example.com-error.log;
    access_log /var/log/nginx/example.com-access.log;
}

Creating Symfony nginx virtual host:

# vim /etc/nginx/conf.d/example.conf
server {        
    listen       80;
    server_name  example.com www.example.com;
    root   /home/example/prod/sf/web;

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

    location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
        expires 30d;
        add_header Vary Accept-Encoding;
        access_log off;
        log_not_found off;
    }

    location ~ ^/(app|config)\.php(/|$) {
        fastcgi_pass   127.0.0.1:9001;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        # When you are using symlinks to link the document root to the
        # current version of your application, you should pass the real
        # application path instead of the path to the symlink to PHP
        # FPM.
        # Otherwise, PHP's OPcache may not properly detect changes to
        # your PHP files (see https://github.com/zendtech/ZendOptimizerPlus/issues/126
        # for more information).
        fastcgi_param  SCRIPT_FILENAME  $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
    }

    error_log /var/log/nginx/example.com-error.log;
    access_log /var/log/nginx/example.com-access.log;
}
server {
    listen       80;
    server_name  dev.example.com www.dev.example.com;
    root   /home/example/dev/sf/web;
    location / {
        try_files $uri /app_dev.php$is_args$args;
    }
    location ~ ^/(app_dev|config)\.php(/|$) {
        fastcgi_pass   127.0.0.1:9001;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param  SCRIPT_FILENAME  $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
    }   
    error_log /var/log/nginx/dev.example.com-error.log;
    access_log /var/log/nginx/dev.example.com-access.log;
}   

Restarting nginx and checking:

# systemctl restart nginx

Make sure example.com points to our new server ip address (e.g. modifying /etc/hosts file)

If we put http://example.com in our browser it should display phpinfo page.

HTTP Auth

# htpasswd -c /home/example/.htpasswd username
<password><enter>
<password><enter>

# vim /etc/nginx/conf.d/example.conf

server {
        ...
        auth_basic "Restricted";
        auth_basic_user_file /home/example/.htpasswd;
}


Composer

To install composer globally follow instructions here:

https://getcomposer.org/download/

and then run:

# mv composer.phar /usr/local/bin/composer

Yarn

# cd
# curl --silent --location https://dl.yarnpkg.com/rpm/yarn.repo | sudo tee /etc/yum.repos.d/yarn.repo
# curl -sL https://rpm.nodesource.com/setup_14.x | sudo bash -
# yum install -y nodejs yarn

SSL (HTTPS) - acme.sh

Install acme.sh tool: https://github.com/Neilpang/acme.sh

# cd
# curl https://get.acme.sh | sh
# CTRL+D and reopen terminal

Set letsencrypt as default issuer:

# acme.sh --set-default-ca --server letsencrypt

Issue certificate using HTTP:

# acme.sh --issue -d example.com -w /home/example/prod/current/sf/public

Issue certificate using DNS - cloudflare API token (https://github.com/acmesh-official/acme.sh/wiki/dnsapi):
Cloudflare -> My profile -> API Tokens -> Create token -> Custom -> Permissions: Zone.Zone Read, Zone.DNS Edit
CF_Account_ID from sidebar on overview page.

# export CF_Token="xxxxxxxxxxxxxxxx"
# export CF_Account_ID="yyyyyyyyyyyyyyyy"
# acme.sh --issue --dns dns_cf -d example.com -d www.example.com

Install certificate:

# acme.sh --install-cert -d example.com --key-file /etc/pki/tls/private/example.com.key --fullchain-file /etc/pki/tls/certs/example.com.fullchain.crt --reloadcmd "systemctl reload nginx"

Nginx generate strong DHE parameter:

# cd /etc/ssl/certs
# openssl dhparam -out dhparam.pem 4096

Nginx config:

server {
    listen              443 ssl;
    ...

    ssl_certificate     /etc/pki/tls/certs/example.com.fullchain.crt;
    ssl_certificate_key /etc/pki/tls/private/example.com.key;
    ssl_protocols       TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:ECDHE-RSA-AES128-GCM-SHA256:AES256+EECDH:DHE-RSA-AES128-GCM-SHA256:AES256+EDH:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA:ECDHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_dhparam /etc/ssl/certs/dhparam.pem;

    ...
}

Add redirect for notsecure version:

server {
    listen     80;
    server_name  example.com www.example.com;
    access_log  off;
    error_log   off;
    root   /path/to/webroot;
    location /.well-known/acme-challenge/ {
    }
    location / {
        return 301 https://$server_name$request_uri;
    }
}

Test website/server on https://www.ssllabs.com/ssltest/index.html (should be A rating) or https://ssldecoder.org/

Sudo

To add ability to rotate nginx and php-fpm for user example:

# vim /etc/sudoers

...
example ALL=(root) NOPASSWD: /usr/bin/systemctl reload php-fpm
example ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx

Limits

Make sure nginx, php-fpm and mariadb users have raised limits:

Old settings (not used anymore):

# vim /etc/security/limits.conf
*       soft    nofile      65536
*       hard    nofile      65536
*       soft    nproc       65536
*       hard    nproc       65536

# reboot

New settings for all services run from systemd:

# vim /etc/systemd/system/nginx.service.d/override.conf

[Service]
LimitNOFILE=65536

# vim /etc/systemd/system/php-fpm.service.d/override.conf

[Service]
LimitNOFILE=65536

# systemctl daemon-reload

Update nginx max open files configuration for workers:

# vim /etc/nginx/nginx.conf

#worker_processes auto;
#number of processor cores: cat /proc/cpuinfo |grep processor
worker_processes 8;
# to prevent error: socket() failed (24: Too many open files) while connecting to upstream
# must be smaller than total global limit for nginx
worker_rlimit_nofile 30000; 

# systemctl restart nginx
# systemctl restart php-fpm


E.g. to find out actual Nginx limits, ps aux | grep nginx to locate nginx’s process IDs. Then query each process’s file handle limits using cat /proc/pid/limits

OS tuning

http://www.tweaked.io/guide/kernel/

https://wiki.mikejung.biz/Sysctl_tweaks

https://blog.cloudflare.com/syn-packet-handling-in-the-wild/

https://medium.com/@pawilon/tuning-your-linux-kernel-and-haproxy-instance-for-high-loads-1a2105ea553e

# vim /etc/sysctl.d/99-sysctl.conf

# by default, OS uses only 28k ports (32768-60999) for sockets, let's utilize more
# make sure we dont have any service running on port above the min limit
net.ipv4.ip_local_port_range = 16384 64999

# dealing with burst traffic
# if all sockets are listening and busy a connection-backlog will pile up
# let's increase the size of the backlog (if backlog is full, connection will fail - will be refused)
# The maximum number of "backlogged sockets".  Default is 128.
net.core.somaxconn = 32768
# Increase size of uncomplete connections backlog (4k is safe value on systems with few gigs of memory)
net.ipv4.tcp_max_syn_backlog = 4096
# Increase size of established connections backlog (4k is safe value on systems with few gigs of memory)
net.core.netdev_max_backlog = 4096
# 16MB per socket
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
#
#net.ipv4.tcp_wmem = 4096 65536 16777216
#net.ipv4.tcp_rmem = 4096 87380 16777216

# let's lower limit for SYN and SYNACK retries during establishing connection
# this might drop very slow clients - defaults are ~5
#net.ipv4.tcp_syn_retries = 2
#net.ipv4.tcp_synack_retries = 2

Apply changes:

# sysctl -p

Mariadb tuning

Use https://github.com/major/MySQLTuner-perl

Following config is for dedicated db machine with 16GB RAM

# vim /etc/my.cnf

[mysqld]
...

#all user's hosts must be defined with ip address
skip-name-resolve

thread_cache_size = 4

query_cache_type = 1
query_cache_limit = 1M
query_cache_min_res_unit = 2k
query_cache_size = 300M

innodb_file_per_table = 1

innodb_buffer_pool_size = 8G #on dedicated db machine with innodb db should be 65%-75% of system memory
innodb_buffer_pool_instances = 8 #recommended by mysqltuner
innodb_log_file_size = 1G # should be ~25% of innodb_buffer_pool_size

innodb_flush_log_at_trx_commit = 0 # performance optimization (default value 1 is for reliability) - use only on UPS-backed-up machine if you dont want to lose data

slow_query_log = 1
long_query_time = 1
slow_query_log_file = /var/log/mariadb/slow-query.log
log_queries_not_using_indexes